feat(runtime): Gateway 收尾——session 传输暴露、host Bearer 通道、flask 依赖拆解、审批链路收口

- session.* 传输暴露:新增 gateway_api 蓝图(list/create/history),RuntimeService 补 create_session
- host Bearer 通道:gateway_auth 双通道认证(host 模式+回环限定,token 存 DATA_DIR/host_api_token),tasks/approval 路由接入
- api_v1 会话路由转调公共入口消双轨,会话索引/列表补齐 run_mode/model_key/custom_prompt_name/personalization_name
- flask 包依赖拆解:_flask_bridge 延迟桥接 + context 子包 PEP 562 懒加载,import server.tasks/runtime 不再拉起 flask
- 审批链路:mark_expired 终态回写(超时/软停止/取消三路径)+ 终态 TTL 惰性清理(3600s)
- 测试 patch 点随迁;全量 75 测试失败恰为 4 项存量,独立启动验收 4/4 全绿

Co-authored-by: Astrion powered by Kimi-K3 <astrion-agent@users.noreply.github.com>
This commit is contained in:
JOJO 2026-09-08 11:22:09 +08:00
parent 0381557fe9
commit aa9bf377a3
27 changed files with 1209 additions and 174 deletions

View File

@ -0,0 +1,129 @@
# Client ↔ Gateway 链路审核报告(前端/CLI 消费面)
**审核日期**2026-09-08
**审核对象**`<repo>`Flask 后端 + Vue3 Web + React/Ink CLI
**审核方式**只读。grep/符号定位 + 精确行段阅读,禁止整文件通读。行号以本次审核时代码为准。
**背景**:验证设计文档「①②③ 链路Client ↔ Gateway ↔ Runtime已贯通」四个实例声明是否属实。
**结论前缀**:✅属实 / ⚠️部分属实 / ❌不属实 / 「未验证」
---
## 1. 每项声明的验证结论 + 代码证据
### 声明 A全部任务创建入口已收敛到 runtime_service.create_task(ctx)
**结论:✅ 属实(主 Run 受理层面)。**
**证据:**
- 唯一的 `task_manager.create_chat_task` 生产调用点收敛在网关内:
- `server/runtime/service.py:44``return task_manager.create_chat_task(ctx, conversation_id=conversation_id)`
- 全仓 `grep '\.create_chat_task('` 除以下之外无生产调用:`test/test_runtime_service.py:164`(测试用,验证 None ctx 抛错)、`.claude/worktrees/stoic-matsumoto-ac8ca5/`(旧 worktree不在当前主工作树、`_experiments/verify_conversation_level_terminal.py:83,93`(实验脚本,非生产入口)。
- `runtime_service.create_task(ctx)` 调用点共 6 处,均经公开入口:
- `server/tasks/api.py:187`Web 创建任务路由)
- `server/api_v1.py:335`API v1 消息路由,`@api_token_required`
- `server/workflow_runtime_api.py:192`workflow 激活)、`:269`workflow notice 派发)
- `server/chat_flow_task_main.py:618`(多智能体完成通知派发 `_dispatch_completion_user_notice`)、`:1424`(多智能体闲时派发 `dispatch_ma_idle`
- `RuntimeContext` 三层构造符合契约:适配层直接构造两个点(`server/tasks/api.py:170`、`server/api_v1.py:323`principal 经 `principal_from_session_snapshot` 由认证后的 session 构造);内部通知链/工作流/多智能体用 `RuntimeContext.from_terminal``server/runtime/context.py` 定义,身份取自 terminal/workspace不带客户端可提交的门闸 token 等内部指令。
- 执行链 `create_chat_task → 线程 → _run_chat_task → run_chat_task_sync → process_message_task → handle_task_with_sender` 的调用均在运行线程内部(`server/tasks/models.py:991` 调用 `run_chat_task_sync``chat_flow_task_main.py:664/1899/2500` 在运行线程内 spawn 子分支),不存在独立于网关的直拉过程。
**说明/例外:**
- 子智能体任务、传统后台命令是**独立的派生工作者实体**,不走 `runtime_service.create_task`(被 `server/chat_flow_task_main.py:2642` 注释明确说明「与传统后台任务完全分离,避免两者竞争 create_chat_task 的单工作区互斥」)。这与 `docs/runtime_protocol.md` §2「子智能体/后台命令是主 Run 派生的后台工作者」一致,不算漏网。
### 声明 Bserver/chat/approval.py 六个路由全部转调 runtime_service
**结论:✅ 属实。**
**证据(`server/chat/approval.py`,六路由全部经公共入口):**
- `list_pending_user_questions``runtime_service.list_pending_approvals(..., kind="question")``:56`
- `answer_user_question``runtime_service.resolve_approval("question", ...)``:71`
- `list_pending_plan_approvals``runtime_service.list_pending_approvals(..., kind="plan")``:101`
- `answer_plan_approval``runtime_service.resolve_approval("plan", ...)``:116`
- `list_pending_tool_approvals``runtime_service.list_pending_approvals(..., kind="tool")``:145`
- `decide_tool_approval``runtime_service.resolve_approval("tool", ...)``:161`
**仪表验证:** `server/runtime/service.py``resolve_approval`/`list_pending_approvals` 均作了 kind 路由 + 权限/参数校验,再委托给 `server/state` 下的三个 manager。
**例外观察(非 Web 路由,不算入口违规):**
- `modules/auto_approval_service.py:89` 内部 `tool_approval_manager.decide(...)` 直调,是**自动审批 agent**(执行侧自动裁决),仅被 `server/chat_flow_tool_loop.py:894,1166` 调用(运行循环内)。属于 Execution Plane/Agent Runtime 侧机制,不经客户端入口,但若后续要统一裁决语义可考虑让其复用 `resolve_approval`
### 声明 C公共查询入口 list_runs / get_task / get_task_events / list_pending_approvals / list_sessions / get_session_history
**结论:⚠️ 部分属实run.* 与 approval.* 已供 HTTPsession.list/history 无传输暴露)。**
**证据:**
- `run.list`HTTP 暴露 `/api/tasks` GET`server/tasks/api.py:33-44` → `runtime_service.list_runs`)。
- `run.get`HTTP 暴露 `/api/tasks/<task_id>` GET`server/tasks/api.py:205` → `runtime_service.get_task`)。
- `run.events`HTTP 暴露 `/api/tasks/<task_id>?from=``server/tasks/api.py:220` → `runtime_service.get_task_events`,响应透传 `window_start`)。
- `approval.list`HTTP 暴露 approval.py 三个 pending 路由。
- **`session.list` / `session.history`:❌ 仅进程内服务方法,无任何 HTTP 路由调用。**
- `grep` `list_sessions|get_session_history` 仅命中 `server/runtime/service.py` 定义 + `test/runtime_standalone_checks.py:273/282/290/300`(测试)。
- Web/API v1 的会话查询走的是**旧链路**,未复用公共入口:
- `server/conversation.py:571` `GET /api/conversations` 直接 `terminal.get_conversations_list(...)`
- `server/api_v1.py:351` `GET /workspaces/<id>/conversations` 直接 `conv_dir.glob("conv_*.json")``:385` `GET .../<conv_id>` 直接 `json.loads(_conversation_path...)`
- 即:`list_sessions`/`get_session_history` 是「为 CLI/定时任务预留的进程内直调入口」,但当前 Web/CLI 实际消费会话列表/历史仍在走旧 Web 路由。
### 声明 D事件协议 task 级 idx/offset + meta.window_start 缺口Web 与 CLI 客户端缺口处理
**结论:✅ 属实的服务端/Web 实现;⚠️ CLI 缺口处理不完整(见差距清单)。**
**证据:**
- 服务端:`server/runtime/service.py:get_task_events` 返回 `meta.window_start`(缺口水位);`server/tasks/api.py:220-225` 从 `ev_meta``window_start` 透传 HTTP。
- Web`static/src/stores/task.ts`
- 轮询 `/api/tasks/<id>?from=<offset>``:191`
- 读 `data.window_start``:264`
- 缺口检测 `if (fromOffset > 0 && fromOffset < windowStart)` → 发合成事件 `event_window_gap``:263-285`
- `static/src/app/methods/taskPolling/lifecycle.ts:395-408` `case 'event_window_gap'` → 跳过重建期后 `this.fetchAndDisplayHistory({ force: true })` + toasti18n `stores.eventWindowGap*``locales/zh-CN/stores.ts:63-64`/`en-US/stores.ts:63-64`)。**缺口 → 快照对账链路真实存在。**
- CLI`cli/src/App.tsx:491-503` `pollTask``result.window_start``if (offset > 0 && offset < windowStart)` 提示部分实时输出已被裁剪以最终结果为准 `offset = windowStart` 对齐续读。**检测+对齐窗口存在但仅提示不对账 §3 差距 2)。**
### 声明 ECLI 当前是「Web API 消费者」fetch 127.0.0.1:8091 + cookie/CSRF
**结论:✅ 属实(且依赖度比声明更重)。**
**证据(`cli/src/api.ts`**
- 认证:`ensureConnected` 调 `/api/csrf-token``fetchCsrf`)、`/host-login``hostLogin`);请求头携带 `Cookie` + `X-CSRF-Token``request`/`captureCookies``:160-215`401 时自动 `fetchCsrf`+`hostLogin` 重试。
- 直连目标:`createDefaultApiClient` 默认 `http://127.0.0.1:${port}``AGENTS_API_PORT || WEB_SERVER_PORT || 8091`)。
- 若连不通,`startServer` 直接 `spawn('python3', ['-m', 'server.app', ...])``:52-75`)拉起完整 Flask/SocketIO Web 服务;`server/app.py` → `app_legacy.run_server`(含 socketio
---
## 2. 发现的漏网路径 / 不一致清单
| # | 类型 | 描述 | 影响 | 代码证据 |
|---|---|---|---|---|
| 1 | **双轨会话查询** | `session.list/history` 公共入口 `list_sessions`/`get_session_history` **无任何 HTTP 路由调用**Web`server/conversation.py:571`)与 API v1`server/api_v1.py:351/385`)会话查询仍在直读磁盘/terminal绕过公共入口 | 会话能力未统一到协议面;「新客户端可经公共协议查询会话」目前仅能靠进程内直调 | protocol §4 表 `session.list/history``runtime_service.list_sessions/get_session_history`+ `grep` 无 HTTP 调用方 |
| 2 | **API v1 会话路由旧实施** | `GET /workspaces/<id>/conversations`、`GET .../<conv_id>` 用 `Path.glob("conv_*.json")` / `json.loads` 直读,独立于 `runtime_service` 且未做属主/工作区一致性校验的同构去重 | 与公共 `list_sessions` 载荷/校验不一致;多端字段未统一 | `server/api_v1.py:351-404` |
| 3 | **自动审批直调 manager** | `modules/auto_approval_service.py:89` `tool_approval_manager.decide(...)` 绕过 `resolve_approval` | 统一裁决语义分叉(审批条目来源/回调/审计若日后收敛到公共入口会遗漏该路径) | `modules/auto_approval_service.py:72-96`,仅被 `chat_flow_tool_loop.py:894/1166` 调用 |
| 4 | **旧 worktree/实验脚本直调** | `.claude/worktrees/stoic-matsumoto-ac8ca5/server/…` 多处 `task_manager.create_chat_task``_experiments/verify_conversation_level_terminal.py:83,93` | 不在当前主工作树/非生产;仅提示清理,不构成本机违规 | grep `.create_chat_task(` 结果 |
| 5 | **run.guide / queue 端点存在但 CLI 未消费** | `/api/tasks/<id>/runtime_guidance`tasks/api.py:282、`runtime_queue`:310/338/360已有 HTTP 路由,但 CLI `api.ts` 无对应方法 | CLI 作为待实现「Runtime Client」缺失 run.guide/追问队列能力 | `server/tasks/api.py:282-364` vs `cli/src/api.ts` |
---
## 3. CLI 成为「独立 Runtime Client」的差距清单
| # | 差距 | 证据 | 说明 |
|---|---|---|---|
| 1 | **依赖 Web 专属认证流程** | `cli/src/api.ts:48-90``/api/csrf-token` + `/host-login` + Cookie/CSRF 头 | 协议 §6 明确的 Web 适配层认证;非通道无关 Token。独立客户端不应走 CSRF。 |
| 2 | **缺口只对齐不重同步** | `cli/src/App.tsx:497-502` 检测 `offset < windowStart` 仅提示并 `offset=windowStart`**未触发 §5.4 快照对账**Web 走 `fetchAndDisplayHistory({force:true})` | 协议 §5.2 要求缺口必须「走重新同步(§5.4)」CLI 会丢被裁剪的中间内容,仅以「最终结果为准」凑合。 |
| 3 | **仅支持 tool 审批,缺 plan/question** | `cli/src/App.tsx:505-511` 只处理 `tool_approval_required``api.ts:244` 只打 `/api/tool-approvals/<id>/decision` | approval.resolve 的 plansubmit_plan/questionask_user能力 CLI 不可用。 |
| 4 | **自拉起完整 Web 服务** | `api.ts:startServer` spawn `python3 -m server.app`SocketIO Web 全量) | 协议 §6「禁止每个客户端各起一份独立状态 Runtime 却宣称共享 Gateway」的边界上本地单机场景可用但非「只连公共 Gateway」。 |
| 5 | **依赖大量 Web-only 端点完成配置/调研** | `api.ts``/api/status`、`/api/conversations*`、`/api/model`、`/api/thinking-mode`、`/api/permission-mode`、`/api/execution-mode`、`/api/tool-settings`、`/api/path-authorization`、`/api/host/workspaces` 等 | 这些不受 protocol Commands/Queries 覆盖;独立客户端需等价 run.*/session.* 能力或明确这些为 Web adapter 私有。 |
| 6 | **无 run.listRun 发现)消费** | `cli/src/api.ts` 未调用 `/api/tasks` GET 或 `list_runs` | 主 Run 发现能力(审核 F1 已落在服务层CLI 未使用;不利于 A/B 观察场景。 |
---
## 4. 总体判断
**「①②③ 链路Client ↔ Gateway ↔ Runtime已贯通」 部分成立,未达到「新客户端可不复制 Web 专属流程直接接入」的程度。**
- **服务端收敛度**:高。主 Run 创建的 6 个入口全部经 `runtime_service.create_task`approval.py 六路由全部经 `resolve_approval`/`list_pending_approvals`run.list/get/events/cancel/guide/queue 均有 HTTP 公开路由并透传 `window_start`。声明 A、B、D(服务端/Web)、E 属实。
- **Web 消费面**:已消费公共协议面的 HTTP 端点task 轮询 + approval + 缺口快照对账),基本属「公共协议能力消费方」。
- **主要缺口集中在「会话查询未传输暴露」与「CLI 独立性」两点**
1. `session.list/history``session.create/load` 显式命令**没有 HTTP 传输暴露**Web/API v1 仍在走旧会话路由——协议表虽标「✅/⚠️」但事实是**只有进程内直调入口**。
2. CLI 仍是「Web API 消费者」cookie/CSRF/hostLogin + 自拉起完整 Web 服务,仅 tool 审批、缺口不重同步、依赖大量 Web-only 端点。
- **结论**Gateway服务层公共入口本身已基本贯通且 Web 消费面已对齐;但「新客户端不复制 Web 专属流程即可接入」的**完整性目标未达成**,卡在 (a) 会话查询/创建的传输暴露、(b) CLI 认证与启动方式的 Web 耦合、以及 (c) 审批三类型覆盖与缺口快照对账。严格意义下应表述为「服务端公共入口贯通完成客户端侧仍处『Web adapter 消费者』过渡态」。
---
### 附:未验证项
- 未在本次审核中执行真实运行验收(如 ApprovalWaitChainTest / ProtocolSmokeChainTest 的运行通过性),仅静态符号与行段核对;测试伪实现(`test_runtime_service.py` 曾把 `_run_chat_task` 替换为 lambda的历史问题本报告未复验。
- HTTP 端点归属(哪些路由挂载到哪个 blueprint / app_legacy的注册细节未逐一核对仅核对路由定义文件内部映射。

View File

@ -0,0 +1,335 @@
# Runtime ↔ Execution Plane 链路审核报告
- 审核日期2026-09-08
- 审核方式只读grep/符号定位 + 精确行段阅读 + 隔离 import 实验 + 子进程验收测试运行观察)
- 审核对象Gatewayserver/runtime/)→ Agent Runtimeserver/chat_flow*.py、core/main_terminal.py→ Execution Planecore/main_terminal_parts/tools_execution.py、modules/execution_plane/
- 结论分级:✅属实 / ⚠️部分属实 / ❌不属实 / 未验证
- 验收测试运行结果(本机 venv 实测):
- `check_lifecycle`**PASSED (1.6s)**(无 Flask app 真实生命周期)
- `check_fake_exec`**PASSED (0.7s)**(替身执行 E1-E4零真实副作用
- `check_chain`**PASSED (2.7s)**(双客户端发现/观察/取消 + 历史 + 审批)
- `check_approval_wait`**PASSED (3.5s)**(执行中审批等待 → 公共入口批准 → 继续)
---
## 一、逐项声明验证结论
### 1. Runtime 层本体server/runtime/context.py + service.py
#### (a) 是否真的不 import flask —— ✅ 属实
证据:
- `server/runtime/context.py` 顶层 import 仅:`__future__` / `dataclasses` / `typing`L14-17
- `server/runtime/service.py` 顶层 import 仅:`__future__` / `typing` / `modules.i18n.tr` / `server.runtime.context`L13-18
- `server/runtime/` 全文 grep `flask|socketio`:唯一命中的是 `context.py:141` 的**注释文本**「本模块不 import flask」说明性文档非导入
- `grep -rniE "import flask|from flask|import socketio|from socketio" server/runtime/`**零实际导入命中**
import 实验佐证(本机 `.venv/bin/python3`
```
>>> import server.runtime.service
OK
>>> 'flask' in sys.modules # False未拉起
>>> 'flask_socketio' in sys.modules # False
>>> 'server.tasks' in sys.modules # False延迟导入未触发
```
结论Runtime 层本体零 Flask 依赖 ✅。注意:**模块本体不依赖 flask ≠ 调用链不依赖 flask**(见第 3 节 import 链实测)。
#### (b) TrustedPrincipal 只能由适配层构造?防自报 role 机制 —— ⚠️ 部分属实
证据:
- `context.py:20-41``TrustedPrincipal` 是普通 `@dataclass(frozen=True)`**没有私有构造/工厂限制**——任何代码均可直接 `TrustedPrincipal(username=..., workspace_id=..., role="admin")` 自报角色。
- `validate()`L30-34只校验 `username`、`workspace_id` 非空,**不校验 role 白名单、不校验 is_api_user/host_mode 组合**。
- `RuntimeContext.from_terminal`L79-105`principal_from_session_snapshot`L139-168是仅有的两个"可信构造路径"
- `from_terminal`host_mode 取 `workspace.username == "host"`、role 取 `terminal.user_role`(对话级 terminal 既有属性,非客户端自报);
- `principal_from_session_snapshot`:显式声明「调用方必须在完成认证后调用」,从适配层传入的 session dict 快照构造。
- 防御纵深确实存在:`service._resources_for_query`service.py:269-283校验 `principal.username == username``principal.workspace_id == workspace_id`**跨身份/跨工作区查询抛 PermissionError**(测试 check_chain 第 5.5 节已验证此语义)。
结论:**「只能由适配层构造」是模块 docstring 与调用约定的强语义 + 查询侧纵深防御,不是 dataclass 层面的强制**。同一进程内任何持有代码写入权的调用方(或未来 Schedule payload仍可直接构造高权限 principal。当前所有生产调用点均经由适配层HTTP session 或 from_terminal实战安全 ✅;但"防自报 role 的机制"属于**约定约束而非结构约束**,标注 ⚠️。
#### (c) service 方法集与文档声称一致性 —— ✅ 属实
文档声称create_task / cancel_task / guidance / pending 队列 / get_task / get_task_events / list_runs / list_sessions / get_session_history / resolve_approval。
实测方法集service.py
| 文档声称 | 实际方法 | 证据行 |
|---|---|---|
| create_task | `create_task(ctx)` + `_ensure_conversation_for_chat` | L47-57, L60-96 |
| cancel_task | `cancel_task(username, task_id)` | L100-103 |
| guidance | `enqueue_runtime_guidance` | L106-109 |
| pending 队列 | `enqueue_runtime_pending_message` / `remove_runtime_pending_message` / `promote_runtime_pending_to_guidance` / `get_runtime_pending_messages` | L112-124, L312-316 |
| get_task | `get_task(username, task_id)` | L318-320 |
| get_task_events | `get_task_events(username, task_id, offset)`(返回 events/next_offset/error/meta含 window_start 缺口水位) | L323-342 |
| list_runs | `list_runs(username, workspace_id, conversation_id/status 筛选)`(复用 `task_public_payload` 单序列化实现) | L294-310 |
| list_sessions | `list_sessions(username, workspace_id, principal, ...)` | L158-174 |
| get_session_history | `get_session_history(username, workspace_id, conversation_id, principal)` | L177-192 |
| resolve_approval | `resolve_approval(kind, ...)`tool/plan/question 三路由)+ `list_pending_approvals` | L127-156, L117-125 |
方法集与文档完全对齐 ✅(文档未列的 `_resources_for_query`/`_ensure_conversation_for_chat` 是私有实现细节)。
#### (d) service 是否确实不持有任务状态 —— ✅ 属实
证据:
- `service.py` docstringL8-9「本服务只做受理裁决 + 显式上下文转发 + 控制委托不持有任务状态」L24「无状态全部状态委托给 task_manager 单例」。
- 全部状态操作均**延迟导入**并委托:`from server.tasks import task_manager`L52、L101、L107 等 8 处)、任务记录/事件流/门闸/保存保护由 TaskManager / main_task_gate / conversation_manager 承载。
- 方法体内无 `self._xxx` 状态字段;`RuntimeService` 类没有 `__init__` 之外的实例属性。
- 进程级单例 `runtime_service = RuntimeService()`L353可安全共享。
结论 ✅。
---
### 2. 执行链贯通验证
链:`server/tasks/models.py::create_chat_task`L136→ 线程 `_run_chat_task`L771→ 延迟 `from server.chat_flow import run_chat_task_sync`L958-959`run_chat_task_sync`chat_flow.py:280-282`process_message_task`chat_flow.py:133→ 门闸获取L141`handle_task_with_sender`(由 chat_flow_runner → chat_flow_task_runner re-export定义于 chat_flow_task_main.py:1475
#### (a) session_data dict 零残留 —— ✅ 属实
grep `session_data`server/ core/ modules/ utils/ config/ 全部生产代码,排除 __pycache__)命中仅 3 处,全部为**注释或 i18n key 文案,无 dict 状态**
- `server/tasks/models.py:54` 注释「不再有 session_data 兼容快照 dict」
- `server/tasks/models.py:148` `raise ValueError(tr("tasks.missing_session_data"))`(错误消息 key语义已改为「缺少运行上下文请通过公共任务入口提交」——见 modules/i18n_messages/api_tasks.py:105
- `server/runtime/service.py:11` 注释。
- `TaskRecord.__slots__`models.py:35-66`principal / task_params / directives / goal_progress` 四层结构化字段,`to_session_data()` 已不存在grep 零命中)。
结论:生产代码 dict 零残留 ✅i18n key 名称未改属命名瑕疵,不影响语义)。
#### (b) test_request_context 零残留 —— ✅ 属实
grep `test_request_context`server/ core/ modules/)→ **零命中**(返回码 1 表示无匹配)。
`models.py:771-780` 任务线程装配:从 `rec.principal` 映射 `RuntimeIdentity(...)` 后直接 `get_user_resources(..., update_session=False, identity=identity)`——全程无 Flask 请求上下文。`get_user_resources` 内部resources.py:140-143`explicit = identity is not None`、`can_write_session = (not explicit) and update_session and has_request_context()`——显式身份模式下与 session 完全解耦。
#### (c) RuntimeIdentity 驱动的资源解析链路 —— ✅ 属实
- `server/context/identity.py:19-31``RuntimeIdentity` dataclasshost_mode / host_workspace_id / is_api_user / role / preferred_* 偏好快照docstring 明确「传入 get_user_resources 后,资源装配完全不读写 Flask session为 None 时保持既有行为HTTP 适配层在请求上下文内读取 session」。
- `identity.py:38-44` `_resolve_user_role`:显式身份模式不触碰 session。
- `resources.py:118-123` `get_user_resources(username, workspace_id=..., update_session=True, conversation_id=None, identity=None)`——identity 参数化真实存在。
- 调用点:
- 任务线程 `_run_chat_task`models.py:782-795传 identity
- service 查询面 `_resources_for_query`service.py:276-288传 identity
- service 补建对话 `_ensure_conversation_for_chat`service.py:71-76传 identity。
结论 ✅。
---
### 3. 独立启动验证
#### (a) 验收真实性 —— ✅ 属实(真生命周期,非假验收)
读取 `test/runtime_standalone_checks.py` 全文 + `test/test_runtime_standalone_lifecycle.py`
- **没有将 `_run_chat_task` 替换为空 lambda**。薄壳 `test_runtime_standalone_lifecycle.py` 仅用 `subprocess.run([sys.executable, checks_script, mode])` 隔离执行L26-36验收逻辑全在 checks 脚本:
- `check_lifecycle``from flask import has_app_context` 断言无 app 上下文 → 真实 `runtime_service.create_task(_make_ctx(...))` → 轮询 `get_task_events`**`api_request_start` 出现(装配证据,历史/请求构造完成、模型调用前)** → 装配后主动取消 → 断言终态 → 断言对话级 terminal 存在(`resources.state.user_terminals.get(term_key) is not None`)→ 断言 `is_main_task_gate_busy(terminal)` 为 False门闸释放→ 结尾再断言无 app 上下文且 `ext.socketio.server is None`
- 审核 F4 的"假通过"修复点明确存在:`api_request_start 之前出现 error = 装配失败,验收必须明确失败`L108-113——杜绝"模型调用失败被误判为通过"。
- `check_chain`B 客户端不持 task_id`runtime_service.list_runs` 发现 A 的活动 Run → 观察事件流等 user_message 落盘 → 跨端取消 → 会话 JSON 持久化断言 → 事件流 idx 单调/无重复/offset 续读 → 第二 Run 复用会话 → list_sessions/get_session_history + principal 不一致抛 PermissionError跨用户 & 跨工作区两条)→ list_runs 归属/会话/状态筛选 → 审批公共入口(重复裁决返回现状、越权 PermissionError、未知类型 ValueError
- `check_fake_exec`:真实 `terminal.handle_tool_call` 驱动 FakeExecutionBackend断言替身收到 E1-E4 全部调用、输出结构被编排层消费、**零真实磁盘写入**`assert not (_SMOKE_WORKSPACE / "fake_probe.txt").exists()`)、默认路径新建 terminal 的 `execution_backend is None`
- `check_approval_wait`:真实工具编排层 `_execute_tool_calls_impl` + approval 权限模式,线程执行中产生 `tool_approval_required` 事件 → 主线程经 `resolve_approval` 批准 → 替身收到命令、工具循环退出。
**本机实测venv**:四次检查全部 PASSEDlifecycle 1.6s / fake_exec 0.7s / chain 2.7s / approval_wait 3.5s)——独立 Gateway 在**无 Flask app 上下文**环境下完成了 受理→装配api_request_start 证据)→事件→取消→终态→门闸释放 的全流程。模型调用因指向 127.0.0.1:9 连接拒绝失败属预期(外部依赖非验收对象),装配与生命周期真实发生。
#### (b) import 依赖链现状 —— ⚠️ 部分属实(存在实质缺陷)
实测追踪import 钩子打印调用栈 + sys.modules 观察):
**链路 A`import server.runtime.service`** → 不拉起 flask / flask_socketio / server.tasks ✓
**链路 B`from server.tasks import task_manager`service 方法体内延迟触发)**
```
>>> flask imported BY server.auth_helpers
>>> flask imported BY server.context.personalization
>>> flask imported BY server.context.upload
>>> flask imported BY server.context.conversation
>>> flask imported BY server.context.resources
>>> flask imported BY server.context.decorators
flask: True | flask_socketio: False | server.extensions: False | server.chat_flow: False
```
即:**server.tasks 的导入链仍会级联拉起 Flask 包本体**。具体断点:`server/tasks/models.py:16` 顶层 `from server.context import RuntimeIdentity, get_user_resources, ...``server/context/__init__.py` 顶层 re-export 会加载 identity/resources/personalization 等 → `server/context/identity.py:7` 顶层 `from server.auth_helpers import get_current_user_role``server/auth_helpers.py:5` 顶层 `from flask import session, redirect, jsonify`。`resources.py:15` 也直接 `from flask import session, has_request_context`
且**在未安装 flask 的裸环境(系统 python3`from server.tasks import task_manager` 直接 ModuleNotFoundError**——"server/tasks/__init__.py 注释声称『无 Web 依赖,可独立加载』"__init__.py L5-8与事实不符不依赖 **Web app 初始化/Blueprint/SocketIO** ✅,但依赖 **flask 包本体** ❌。
**链路 C`from server.chat_flow import run_chat_task_sync`_run_chat_task 线程内延迟导入)**
```
flask: True | flask_socketio: True | server.extensions: True | server.app: False | server.app_legacy: False
```
chat_flow.py:19 顶层 `from flask import ...` + L73 `from .extensions import socketio, run_background` → extensions.py:4 `from flask_socketio import SocketIO` + L7 实例化 `socketio = SocketIO(...)`。注意:**实例化 SocketIO 对象但不 init_app**`socketio.server is None`),且不触发 `server.app` / `server.app_legacy`(无 Flask app 创建、无蓝图注册)——与"无 Flask app 上下文"验收一致。
**综合结论 ⚠️**:任务线程拆除 test_request_context、RuntimeIdentity 驱动、验收测试无 Flask app 全部属实;但**「Gateway 可脱离 Flask 初始化」的声明过头了**——真正的表述应为:
- ✅ Runtime 层本体server.runtime零 Flask 依赖,可 import
- ⚠️ 一旦调用 create_task延迟导入 server.tasks**flask 包必然被拉起**(经 server.context.identity → auth_helpers无 flask 环境直接 import 失败;
- ⚠️ 任务实际执行_run_chat_task → 延迟导入 chat_flow会拉起 **flask + flask_socketio + SocketIO 实例**(但不 init_app、不创建 app、不注册蓝图
- 即:**进程可无 Flask app 上下文运行,但无法在未安装 flask/flask_socketio 的纯环境运行**——装修层Web 适配模块)侵入核心 import 链的实质未完全消除。
残留耦合点(建议未来拆解):
1. `server/context/identity.py:7` 顶层 `from server.auth_helpers import get_current_user_role`——identity 是核心身份模型,却依赖 Web 认证适配层即使显式身份路径不用它_resolve_user_role 的 None 分支使用);
2. `server/context/resources.py:15` 顶层 `from flask import session, has_request_context`——资源装配核心模块顶层依赖 flask
3. `server/context/__init__.py` 一次 re-export 全量子模块,导致 identity/resources/personalization/upload/conversation/decorators 连带加载。
---
### 4. Runtime ↔ 执行层边界modules/execution_plane/ + docs/execution_contract.md
#### (a) ExecutionBackend 协议 E1-E4 方法签名 —— ✅ 属实
`modules/execution_plane/base.py`
```
@runtime_checkable
class ExecutionBackend(Protocol):
async def run_command(command, *, timeout, sandbox_write_access, network_permission) -> Dict # E1
def run_command_background(command, *, timeout, conversation_id, wait_seconds, network_permission, sandbox_write_access) -> Dict # E2
def write_file(path, content, *, mode="w") -> Dict # E3
def edit_file(path, replacements: List[Dict]) -> Dict # E4
```
结果结构约定docstring
- run_command: `{success, status, output, return_code, truncated, elapsed_ms}`status ∈ completed/timeout/error/cancelled
- run_command_background: `{success, command_id, status}`
- write/edit: `{success, path, original_file, new_file}`
`FakeExecutionBackend`fake.py实现全部 4 方法(内存文件系统 files dict + calls 记录 + background_commands dict签名与协议逐一对应。
#### (b) MainTerminal.execution_backend 四注入分支真实存在且默认 None 走原路径 —— ✅ 属实
- 初始化:`core/main_terminal.py:149` `self.execution_backend = None`注释None = 现有 Host/Docker 真实链路;注入 ExecutionBackend 实现后 E1-E4 分支改走该后端)。
- 四分支tools_execution.py `handle_tool_call`L1057
- **write_file**L1635-1648`backend is not None` → `backend.write_file(path, content, mode)`跳过浅备份else → `_track_shallow_versioning` + `self.file_manager.write_file`
- **edit_file**L1669-1683backend → `backend.edit_file(path, replacements)`else → `_track_shallow_versioning` + `self.file_manager.replace_many_in_file`
- **run_command 后台**L1923-1932backend → `backend.run_command_background(command, timeout=..., conversation_id=..., wait_seconds=5.0, network_permission=..., sandbox_write_access=...)`else → `bg_manager.create_background_command(...)`
- **run_command 前台**L1958-1966backend → `await backend.run_command(command, timeout=..., sandbox_write_access=..., network_permission=...)`else → `await self.terminal_ops.run_command(...)`
- 四分支共用前置权限裁决在进入分支前完成L1891-1906`permission_mode = self.get_permission_mode()` → `sandbox_write_access = not (readonly or (approval/auto_approval and not write_granted_once))``network_permission` 解析);后端分支**不重复裁决**(协议"只承诺执行语义,不构成安全边界")。
- 默认路径回归验证check_fake_exec 断言新建对话级 terminal 的 `execution_backend is None` → 真实链路。
#### (c) 权限裁决/沙箱计划在哪一层 —— ✅ 属实Runtime 编排层裁决,执行层不重复)
- `tools_execution.py:391` `evaluate_tool_permission(tool_name, arguments)`permission mode 语义readonly/approval/auto_approval/unrestricted完整在此层L394-470 多档分支)。
- 注入分支处显式传递**裁决结论**`sandbox_write_access` / `network_permission` 已解析ExecutionBackend 收到的即结论不再二次裁决base.py docstring L8-10、execution_contract.md §4
- 先读后写拦截(`_check_read_before_edit_prerequisite`与浅版本备份也在编排层write/edit 分支前置)。
- **契约注记(执行层安全边界分工)**命令校验_validate_command/FORBIDDEN_COMMANDS与路径授权_validate_path/禁读清单)仍由旧 terminal_ops/file_manager 链路承担execution_contract.md §4 + base.py docstring——审核 §4 修正后的表述OS 层强制力Seatbelt/bwrap/DAC+Landlock是最终边界。
#### (d) Host/Docker 迁入还差什么 —— ✅ 属实E5-E10 后置为独立工作)
依据 `docs/execution_contract.md` §3 表:
- 已入协议E1 run_commandterminal_ops/run.py、E2 run_command_backgroundbackground_command_manager.py、E3 write_filefile_manager/crud_mixin.py、E4 edit_filefile_manager/replace_mixin.py
- 后续纳入E5 read_file 三模式、E6 create/delete/rename/mkdir 族、E7 持久终端会话、E8 path_validate、E9 执行环境快照、E10 命令校验/超时钳制。
- 剩余工作(文档 §6 + 记忆 gateway_runtime_work_plan 中用户拍板):
1. HostDockerBackend 适配器(真实后端实现 E1-E10 + 保留现有校验分配);
2. 编排层全面切换(现仅 E1-E4 四分支注入,其余工具仍直挂 terminal_ops/file_manager
3. 结构债:执行命令后端选择逻辑在 terminal_ops/run.py、background_command_manager.py、persistent_terminal/start.py、container_file_proxy.py **4 处各有一份复制**(契约 §5E5-E10 接口面完整化后是天然收敛点。
- 生产路径不变:默认 `execution_backend=None`,无替换行为。
---
### 5. 智能体循环层独立性core/ 对 Flask 依赖残留)
#### —— ✅ 属实Agent Runtime 层零 Flask 反向依赖)
- `grep -rnE "^from flask|^import flask|^from flask_socketio|socketio" core/`**零命中**(两次独立 grep 均为空,返回码 1
- `core/main_terminal.py` 顶层 importL3-72全为 modules/*、utils/*、config/model_profiles、core.main_terminal_parts、modules.i18n——**无 flask/socketio**。
- `core/main_terminal_parts/tools_execution.py`:顶层无 flask/socketiogrep 零命中);`core/main_terminal_parts/` 其余文件同样零命中。
- 唯一注意点:`tools_execution.py:2195/2498/2514` 有 `_is_web = '/web/users/' in _data_dir` 的**路径字符串判定**Web/CLI 数据目录布局差异),属身份/路径耦合(记忆里已归入遗留),非 Flask API 依赖。
结论Agent Runtime③ 层)不反向依赖 Web 层(②)✅。
---
## 二、Gateway → Runtime → Execution 实际分层图(按代码事实)
```
┌──────────────────────────────────────────────────────────────────────┐
│ ① ClientWeb 前端 / CLI / 未来定时触发器) │
└───────────────┬──────────────────────────────────────────────────────┘
│ HTTP / 轮询事件流idx/offset 协议)
┌───────────────▼──────────────────────────────────────────────────────┐
│ ② Gatewayserver/runtime/
│ context.py: RuntimeContext = TrustedPrincipal + TaskParams │
│ + InternalDirectives门闸 token 走 internal不接受 │
│ 客户端提交principal 由适配层构造【约定级约束】) │
│ service.py: RuntimeService 无状态薄层(受理/控制/查询/审批/会话, │
│ 全部延迟委托 task_manager
│ ─── 边界清晰 ⚠️:本体零 Flask但延迟导入 server.tasks 链 │
│ 仍级联拉起 flask 包context.identity → auth_helpers
└───────────────┬──────────────────────────────────────────────────────┘
│ create_task → create_chat_task → 线程 _run_chat_task
│ RuntimeIdentity 驱动,全程无隐式上下文 / 无 │
│ test_request_context / 无 session_data │
┌───────────────▼──────────────────────────────────────────────────────┐
│ ③ Agent Runtimeserver/chat_flow*.py + core/main_terminal.py
│ 执行链run_chat_task_sync → process_message_task门闸获取/ │
│ finally 释放)→ handle_task_with_sender模型循环
│ → core/main_terminal_parts/tools_execution.py::handle_tool_call │
│ ─── core/ 零 Flask 依赖 ✅ 边界清晰 │
│ ⚠️ 残留chat_flow.py/chat_flow_task_main.py 顶层仍在 │
│ (延迟导入 chat_flow 时拉起 flask+extensions
└───────────────┬──────────────────────────────────────────────────────┘
│ E1-E4 注入分支execution_backendNone → 原路径;
│ 注入 → 替身/未来远端后端);权限裁决【编排层】完成,
│ 传裁决结论sandbox_write_access/network_permission
┌───────────────▼──────────────────────────────────────────────────────┐
│ ④ Execution Planemodules/execution_plane/
│ ExecutionBackend 协议 E1-E4Protocol, runtime_checkable
│ FakeExecutionBackend 替身(内存,验收通过) │
│ Host/Docker 真实后端terminal_ops/file_manager 直挂) │
│ ─── 边界清晰但不完整:仅 E1-E4 协议化E5-E10 + 4 处后端选择逻辑 │
│ 复制收敛 = 后置独立工作 │
└──────────────────────────────────────────────────────────────────────┘
边界评级:
[①→②] 清晰(公共协议 + 事件流 + 三层上下文;但 principal 自报 role 为约定级防线)
[②→③] ⚠️ 半清晰(任务线程纯 RuntimeIdentity但 flask 包经 server.context
identity/resources 顶层 import 侵入核心链,无 flask 环境无法运行)
[③→④] 半清晰core/ 零 Flask ✅E1-E4 注入 ✅;但权限校验分两处——
编排层裁决 + 旧链路命令校验/路径授权Host/Docker 未迁入协议)
```
---
## 三、与文档声称不符的发现清单
| # | 严重度 | 文档声称 | 代码事实 | 证据 |
|---|---|---|---|---|
| F1 | 中 | tasks/__init__.py 注释「本包只导出任务核心层…保证 server.runtime 在无 Web 应用初始化的进程中可独立加载使用」 | 可脱离 **Web app 初始化** 加载 ✅,但不可脱离 **flask 包** 加载:`from server.tasks import task_manager` 级联拉起 flask经 context.identity→auth_helpers裸环境直接 ModuleNotFoundError | identity.py:7 `from server.auth_helpers import get_current_user_role`resources.py:15 `from flask import session, has_request_context`auth_helpers.py:5 `from flask import session, redirect, jsonify`;裸 python3 import 实验 FAIL |
| F2 | 中 | 任务线程「全程 RuntimeIdentity 驱动」隐含"模块级已解耦 Web" | 线程内确实 RuntimeIdentity 驱动(无 test_request_context但**执行阶段** `_run_chat_task` 延迟导入 `server.chat_flow` → 拉起 flask_socketio + `SocketIO()` 实例(即时不 init_app、`has_app_context()` 仍 Falsesocketio.server=None 静默跳过) | models.py:958-959chat_flow.py:19,73extensions.py:4-7venv import 实验3 |
| F3 | 低 | TrustedPrincipal「只能由适配层构造」 | **约定 + 校验侧纵深防御,非结构强制**dataclass 无构造限制、validate() 不校验 role 白名单;任何代码可直接构造 admin principal | context.py:20-41 validate L30-34service.py `_resources_for_query` L269-283 仅约束查询侧 |
| F4 | 低 | 「拆除了 session_data 兼容桥」的表述 | 属实 ✅;仅 i18n key `tasks.missing_session_data` 命名保留旧词(错误文案已更新为「缺少任务运行上下文,请通过公共任务入口提交」),语义无残留 | models.py:148i18n api_tasks.py:105 |
| F5 | 低 | execution_contract.md §4「权限裁决在 Runtime 编排层」 | 属实但要精确:**编排层做①permission mode 裁决②审批一次性授权③网络权限解析**,但**命令校验FORBIDDEN_COMMANDS与路径授权禁读清单仍在旧 terminal_ops/file_manager 链路**,经本接口注入的后端分支不会自动获得这些校验(契约已加注记,与代码一致) | base.py docstring L9-11execution_contract.md §4 注记tools_execution.py:1891-1906 |
未标注项1(a)(c)(d)、2(a)(b)(c)、3(a)、4(a)(b)(c)(d)、5 全部与文档一致 ✅。
**未验证项**:无(全部声明均已通过代码定位 + import 实验 + 验收测试运行验证唯一无法在本环境复现的是「Web 生产环境真实运行中 socketio 推送」——需要用户重启服务后人工验证,属记忆 gateway_runtime_work_plan 中的遗留待办 N3
---
## 四、总体判断
**目标「③④ 层达到 Runtime 与执行环境可通过替身独立测试、换执行后端不碰智能体循环」——达到了 70% 达成度(③↔④ 边界达标;②→③ 尚有一处实质耦合)。**
逐项对照:
1. **「Runtime 与执行环境可通过替身独立测试」✅ 完全达成**`check_fake_exec`0.7s PASSED`check_approval_wait`3.5s PASSED证明真实工具编排层 handle_tool_call + FakeExecutionBackend 可以零真实副作用跑通 E1-E4且默认路径backend=None行为回归断言存在。
2. **「换执行后端不碰智能体循环」✅ 达成(限 E1-E4**:注入点是 `MainTerminal.execution_backend` 属性 + handle_tool_call 四分支,智能体循环(模型循环/工具分派/事件)不感知后端具体实现;替换 Host/Docker 后端只需实现协议并注入。❌ **但完整达成需 E5-E10 全部协议化**——当前 read_file、mkdir、终端会话、路径校验等仍直挂 terminal_ops/file_managerHost/Docker 真实后端「换后端不碰循环」仅对 E1-E4 成立。
3. **③④ 层的方向独立性已建立,但存在一个实质耦合点**:核心链 `server.tasks``server.context.identity``server.auth_helpers``flask` 使 **Gateway 进程仍必须安装 flask 包**(虽然不需要 Flask app / SocketIO 服务 / 蓝图)。独立启动验收之所以能过,是因为验收环境装了 flask+flask_socketio —— 若在未安装 Flask 的精简部署环境运行,`create_task` 将直接 ModuleNotFoundError。
4. **对「Gateway 独立启动(无 Web 初始化)」声明**:验收测试已证明无 app 上下文、无 SocketIO 服务、无蓝图注册、门闸/终态/事件流/审批全生命周期真实通过 ✅;但「零 Flask 依赖」不成立 ⚠️——准确表述应为「无 Flask **应用初始化** 依赖,仍有 Flask **包** 依赖」。
**建议(按优先级)**
1. 拆 `server/context/identity.py:7` 顶层 `from server.auth_helpers import get_current_user_role`(改为延迟导入或在 context 包内实现角色默认逻辑),`resources.py:15` 的 flask session 依赖同理下沉到适配层——可一次性切断 ②→③ 链的 flask 包依赖;
2. server/context/__init__.py 目前一次 re-export 全量子模块(连带加载),可考虑按需分包或延迟绑定;
3. Host/Docker 迁入E5-E10 + HostDockerBackend + 编排层切换 + 4 处后端逻辑收敛)按既定后置计划执行,与本轮结论无冲突。
---
### 附录import 实验记录(本机 venv=python3.9.6 / 裸 python3=3.9.6
| 实验 | 命令 | 结果 |
|---|---|---|
| 1 | `import server.runtime.service`(裸 python3 | OKflask=False、flask_socketio=False、server.tasks=False |
| 2 | `from server.tasks import task_manager`(裸 python3 | **FAIL: ModuleNotFoundError 'flask'**追踪server.tasks→models→server.context→identity→auth_helpers→flask |
| 3 | `from server.tasks import task_manager`venv | OK**flask=True**、flask_socketio=False、extensions=False、chat_flow=False |
| 4 | `from server.chat_flow import run_chat_task_sync`venv | OK**flask=True、flask_socketio=True、server.extensions=True**、server.app=False、app_legacy=False |
### 附录:验收测试实测记录
| 检查 | 用时 | 结果 | 关键证据 |
|---|---|---|---|
| check_lifecycle | 1.6s | PASSED | 无 app 上下文 → create_task → api_request_start 装配证据 → 取消 → 终态 → terminal 存在 → 门闸释放 → socketio.server is None |
| check_fake_exec | 0.7s | PASSED | E1-E4 四分支替身全部收到零真实磁盘写入backend=None 回归 |
| check_chain | 2.7s | PASSED | list_runs 发现 → 事件观察 → 跨端取消 → 历史落盘 → offset 续读 → 会话查询 → 审批语义 |
| check_approval_wait | 3.5s | PASSED | 执行中审批等待 → 公共入口批准 → 替身收到命令 → 循环退出 |

View File

@ -250,16 +250,23 @@ v1 原文把「内存态」直接判为「状态唯一 Owner 的障碍」,混
> | 能力 | 接口存在 | 适配完成(既有入口转调) | 行为验收 | > | 能力 | 接口存在 | 适配完成(既有入口转调) | 行为验收 |
> |---|---|---|---| > |---|---|---|---|
> | run.start / cancel / guide / queue | ✅ | ✅tasks/api.py、api_v1.py 全部转调) | ✅ chain 双客户端 + lifecycle | > | run.start / cancel / guide / queue | ✅ | ✅tasks/api.py、api_v1.py 全部转调) | ✅ chain 双客户端 + lifecycle |
> | run.get / run.events含 window_start | ✅ | ✅HTTP 轮询透传水位) | ✅ offset/水位断言 | > | run.get / run.events含 window_start | ✅ | ✅HTTP 轮询透传水位) | ✅ offset/水位断言 + 客户端缺口处理2026-09-08Web 检测后发 `event_window_gap` → 会话快照对账CLI 提示并对齐窗口续读) |
> | run.list发现审核 F1 | ✅ | ✅(/api/tasks 列表、running-status、api_v1 删除保护转调) | ✅ B 发现 A 的活动 Run 并取消 | > | run.list发现审核 F1 | ✅ | ✅(/api/tasks 列表、running-status、api_v1 删除保护转调) | ✅ B 发现 A 的活动 Run 并取消 |
> | approval.list / resolve | ✅ | ✅chat/approval.py 三类六个路由转调) | ✅ 等待→批准→继续approval_wait | > | approval.list / resolve | ✅ | ✅chat/approval.py 三类六个路由转调) | ✅ 等待→批准→继续approval_wait |
> | session.list / historyprincipal 用户+工作区双校验F3 | ✅ | ⬜Web 会话路由未动——属 conversation 域存量链路) | ✅ 含越权/跨工作区拒绝断言 | > | session.list / historyprincipal 用户+工作区双校验F3 | ✅ | ✅2026-09-08新增 server/gateway_api.py `GET/POST /api/runtime/sessions` + `GET .../history` 传输暴露api_v1 两会话路由转调公共入口消双轨,载荷字段经索引补字段保持兼容) | ✅ 含越权/跨工作区拒绝断言 |
> | 通道认证host Bearer token | ✅2026-09-08server/gateway_auth.pyhost 模式+回环限定token 存 `<DATA_DIR>/host_api_token` 0600 | ✅gateway_api 3 路由 + tasks/api.py 9 路由 + chat/approval.py 6 路由均支持双通道) | ⬜ 待真实环境验证 |
> | Gateway 独立初始化 | — | — | ✅ 子进程隔离ASTRION_IGNORE_DOTENV 逃生门 + 假模型自包含 + 装配证据断言F4 | > | Gateway 独立初始化 | — | — | ✅ 子进程隔离ASTRION_IGNORE_DOTENV 逃生门 + 假模型自包含 + 装配证据断言F4 |
> | ExecutionBackend 替身E1-E4 | ✅ | —(默认 None生产路径不变 | ✅ fake_exec | > | ExecutionBackend 替身E1-E4 | ✅ | —(默认 None生产路径不变 | ✅ fake_exec |
> | ③↔④ 全量贯通Host/Docker 迁入、E5-E10 | ⬜ | ⬜ | ⬜ 后置§7.5-补) | > | ③↔④ 全量贯通Host/Docker 迁入、E5-E10 | ⬜ | ⬜ | ⬜ 后置§7.5-补) |
> >
> **审核收口记录gateway_implementation_review_2026-09-07.md**F1 已补 run.list 公共发现入口F2 已完成 15+ 处路由转调tasks/api.py 7、api_v1.py 3、chat/approval.py 6 + 载荷序列化收敛至 models.py 单一实现死导入清零F3 已补 principal 工作区一致性校验F4 已修config .env 逃生门 + 测试自包含 + 假通过修复 + 审批等待链)。审核 §4 契约注释误导已修正execution_plane/base.py命令校验/路径授权仍在旧链路,真实后端接入时必须保留)。 > **审核收口记录gateway_implementation_review_2026-09-07.md**F1 已补 run.list 公共发现入口F2 已完成 15+ 处路由转调tasks/api.py 7、api_v1.py 3、chat/approval.py 6 + 载荷序列化收敛至 models.py 单一实现死导入清零F3 已补 principal 工作区一致性校验F4 已修config .env 逃生门 + 测试自包含 + 假通过修复 + 审批等待链)。审核 §4 契约注释误导已修正execution_plane/base.py命令校验/路径授权仍在旧链路,真实后端接入时必须保留)。
> >
> **第一档+第二档收尾2026-09-08 实施)**
> - **flask 包依赖拆解G4 收口)**:新增 `server/context/_flask_bridge.py`(延迟桥接 has_request_context/session_get/session_setflask 缺失按无上下文处理identity/resources/conversation/personalization 四子模块顶层 flask+auth_helpers 依赖全拆;`server/context/__init__.py` 改 PEP 562 懒加载chat_flow.py 清理死导入。实测:`import server.tasks` / `import server.runtime` 不再拉起 flask/flask_socketio/auth_helpers/chat_flowvenv 验证)。**剩余形态**任务执行阶段_run_chat_task 线程内延迟导入 chat_flow → security/extensions仍拉起 flask+flask_socketioSocketIO 空壳实例不 init_app执行层彻底脱 flask 属更大工程,本轮未动。
> - **审批链路三缺口收口**:三个 manager 新增 `mark_expired`(幂等)+ 终态 TTL 惰性清理RESOLVED_TTL_SECONDS=3600pending 永不自动清理);三个 _wait_* 等待函数超时/软停止stop_check 注入)/协程取消CancelledError三路径均回写 expired 终态;软停止打断对齐 REST 硬取消。i18n 新增 approval_stopped/approval_expired/question_stopped/question_expired 四键。
> - **测试 patch 点随迁**test_conversation_model_persistencesession→session_get/session_set、test_runtime_identity_resourcesget_current_user_*→_get_current_user_* wrapper。全量 75 测试失败恰为 4 项存量(与改造无关)。
> - **存量索引兼容注记**:会话列表 items 新增 run_mode/model_key/custom_prompt_name/personalization_name 字段(索引两个写入点同步补齐);存量索引条目在对话下次更新前这两个新字段为 None。
>
> **本轮收口决策2026-09-07 用户拍板)**:①②③ 链路Client ↔ Gateway ↔ Runtime贯通即为本轮终点③↔④ 全量贯通Host/Docker 迁入 ExecutionBackend 契约、E5-E10 纳入)后置为独立工作,期间默认路径 `execution_backend=None`(现有真实链路不变)。 > **本轮收口决策2026-09-07 用户拍板)**:①②③ 链路Client ↔ Gateway ↔ Runtime贯通即为本轮终点③↔④ 全量贯通Host/Docker 迁入 ExecutionBackend 契约、E5-E10 纳入)后置为独立工作,期间默认路径 `execution_backend=None`(现有真实链路不变)。
> >
> **②↔③ 内部形态结构化2026-09-08**`to_session_data()` 兼容桥已拆除——`create_chat_task` 签名改为收 `RuntimeContext` 必填,`TaskRecord` 三层结构化直存(`principal`/`task_params`/`directives` + 可变 `goal_progress` 字段);`_run_chat_task` 身份还原/门闸认领/事件注入/terminal 属性设置全部改为按层属性访问;`to_session_data()` 方法删除,生产代码 session_data dict 零残留。验证75 测试全量回归失败恰为 4 项存量。 > **②↔③ 内部形态结构化2026-09-08**`to_session_data()` 兼容桥已拆除——`create_chat_task` 签名改为收 `RuntimeContext` 必填,`TaskRecord` 三层结构化直存(`principal`/`task_params`/`directives` + 可变 `goal_progress` 字段);`_run_chat_task` 身份还原/门闸认领/事件注入/terminal 属性设置全部改为按层属性访问;`to_session_data()` 方法删除,生产代码 session_data dict 零残留。验证75 测试全量回归失败恰为 4 项存量。

View File

@ -80,6 +80,22 @@ MESSAGES = {
"zh-CN": "审批超时", "zh-CN": "审批超时",
"en-US": "Approval timed out", "en-US": "Approval timed out",
}, },
"tool_loop.approval_stopped": {
"zh-CN": "任务已停止,审批不再等待",
"en-US": "Task stopped; approval no longer awaited",
},
"tool_loop.approval_expired": {
"zh-CN": "审批请求已过期",
"en-US": "Approval request expired",
},
"tool_loop.question_stopped": {
"zh-CN": "任务已停止,问题不再等待回答。",
"en-US": "Task stopped; the question is no longer awaiting an answer.",
},
"tool_loop.question_expired": {
"zh-CN": "用户问题已过期。",
"en-US": "The user question has expired.",
},
"tool_loop.awaiting_approval": { "tool_loop.awaiting_approval": {
"zh-CN": "等待用户审批", "zh-CN": "等待用户审批",
"en-US": "Waiting for user approval", "en-US": "Waiting for user approval",

View File

@ -10,6 +10,10 @@ from modules.i18n import tr
# 计划文档内容送进弹窗/记录的最大字符数(超出截断,完整内容始终在计划文件里) # 计划文档内容送进弹窗/记录的最大字符数(超出截断,完整内容始终在计划文件里)
PLAN_CONTENT_MAX_CHARS = 20000 PLAN_CONTENT_MAX_CHARS = 20000
# 终态条目保留时长与任务记录终态清理3600s对齐。
# pending 条目永不自动清理;等待方退出时经 mark_expired 转为终态后惰性回收。
RESOLVED_TTL_SECONDS = 3600.0
class PlanApprovalManager: class PlanApprovalManager:
"""In-memory manager for plan-mode plan approval requests (submit_plan 工具). """In-memory manager for plan-mode plan approval requests (submit_plan 工具).
@ -22,6 +26,29 @@ class PlanApprovalManager:
self._items: Dict[str, Dict[str, Any]] = {} self._items: Dict[str, Dict[str, Any]] = {}
self._lock = threading.Lock() self._lock = threading.Lock()
def _prune_resolved(self, now: Optional[float] = None) -> None:
"""惰性清理过期终态条目(锁内调用)。"""
now = now if now is not None else time.time()
expired_keys = [
key
for key, item in self._items.items()
if item.get("status") != "pending"
and float(item.get("resolved_at") or item.get("created_at") or 0.0) + RESOLVED_TTL_SECONDS <= now
]
for key in expired_keys:
self._items.pop(key, None)
def mark_expired(self, approval_id: str) -> Optional[Dict[str, Any]]:
"""将 pending 条目标记为 expired 终态(等待方超时/停止/取消时调用,幂等)。"""
with self._lock:
self._prune_resolved()
item = self._items.get(approval_id)
if not item or item.get("status") != "pending":
return dict(item) if item else None
item["status"] = "expired"
item["resolved_at"] = time.time()
return dict(item)
def create_request( def create_request(
self, self,
*, *,
@ -60,11 +87,13 @@ class PlanApprovalManager:
def get(self, approval_id: str) -> Optional[Dict[str, Any]]: def get(self, approval_id: str) -> Optional[Dict[str, Any]]:
with self._lock: with self._lock:
self._prune_resolved()
item = self._items.get(approval_id) item = self._items.get(approval_id)
return dict(item) if item else None return dict(item) if item else None
def list_pending(self, username: str, conversation_id: Optional[str] = None) -> List[Dict[str, Any]]: def list_pending(self, username: str, conversation_id: Optional[str] = None) -> List[Dict[str, Any]]:
with self._lock: with self._lock:
self._prune_resolved()
rows = [] rows = []
for item in self._items.values(): for item in self._items.values():
if item.get("username") != username: if item.get("username") != username:

View File

@ -7,12 +7,29 @@ from typing import Any, Dict, List, Optional
from modules.i18n import tr from modules.i18n import tr
# 终态条目保留时长与任务记录终态清理3600s对齐。
# pending 条目永不自动清理——仍属合法等待;等待方退出(超时/停止/取消)
# 时会经 mark_expired 转为 expired 终态,再由本 TTL 惰性回收。
RESOLVED_TTL_SECONDS = 3600.0
class ToolApprovalManager: class ToolApprovalManager:
def __init__(self): def __init__(self):
self._items: Dict[str, Dict[str, Any]] = {} self._items: Dict[str, Dict[str, Any]] = {}
self._lock = threading.Lock() self._lock = threading.Lock()
def _prune_resolved(self, now: Optional[float] = None) -> None:
"""惰性清理过期终态条目(锁内调用)。"""
now = now if now is not None else time.time()
expired_keys = [
key
for key, item in self._items.items()
if item.get("status") != "pending"
and float(item.get("decided_at") or item.get("created_at") or 0.0) + RESOLVED_TTL_SECONDS <= now
]
for key in expired_keys:
self._items.pop(key, None)
def create_request( def create_request(
self, self,
*, *,
@ -45,11 +62,28 @@ class ToolApprovalManager:
def get(self, approval_id: str) -> Optional[Dict[str, Any]]: def get(self, approval_id: str) -> Optional[Dict[str, Any]]:
with self._lock: with self._lock:
self._prune_resolved()
item = self._items.get(approval_id) item = self._items.get(approval_id)
return dict(item) if item else None return dict(item) if item else None
def mark_expired(self, approval_id: str) -> Optional[Dict[str, Any]]:
"""将 pending 条目标记为 expired 终态(等待方超时/停止/取消时调用)。
幂等 pending 返回现状不存在返回 None过期后迟到的 decide
因状态非 pending 返回现状不再生效
"""
with self._lock:
self._prune_resolved()
item = self._items.get(approval_id)
if not item or item.get("status") != "pending":
return dict(item) if item else None
item["status"] = "expired"
item["decided_at"] = time.time()
return dict(item)
def list_pending(self, username: str, conversation_id: Optional[str] = None) -> List[Dict[str, Any]]: def list_pending(self, username: str, conversation_id: Optional[str] = None) -> List[Dict[str, Any]]:
with self._lock: with self._lock:
self._prune_resolved()
rows = [] rows = []
for item in self._items.values(): for item in self._items.values():
if item.get("username") != username: if item.get("username") != username:

View File

@ -7,6 +7,10 @@ from typing import Any, Dict, List, Optional
from modules.i18n import tr from modules.i18n import tr
# 终态条目保留时长与任务记录终态清理3600s对齐。
# pending 条目永不自动清理;等待方退出时经 mark_expired 转为终态后惰性回收。
RESOLVED_TTL_SECONDS = 3600.0
class UserQuestionManager: class UserQuestionManager:
"""In-memory manager for blocking model-to-user questions.""" """In-memory manager for blocking model-to-user questions."""
@ -15,6 +19,29 @@ class UserQuestionManager:
self._items: Dict[str, Dict[str, Any]] = {} self._items: Dict[str, Dict[str, Any]] = {}
self._lock = threading.Lock() self._lock = threading.Lock()
def _prune_resolved(self, now: Optional[float] = None) -> None:
"""惰性清理过期终态条目(锁内调用)。"""
now = now if now is not None else time.time()
expired_keys = [
key
for key, item in self._items.items()
if item.get("status") != "pending"
and float(item.get("answered_at") or item.get("created_at") or 0.0) + RESOLVED_TTL_SECONDS <= now
]
for key in expired_keys:
self._items.pop(key, None)
def mark_expired(self, question_id: str) -> Optional[Dict[str, Any]]:
"""将 pending 条目标记为 expired 终态(等待方超时/停止/取消时调用,幂等)。"""
with self._lock:
self._prune_resolved()
item = self._items.get(question_id)
if not item or item.get("status") != "pending":
return dict(item) if item else None
item["status"] = "expired"
item["answered_at"] = time.time()
return dict(item)
@staticmethod @staticmethod
def _normalize_options(options: Any) -> List[Dict[str, str]]: def _normalize_options(options: Any) -> List[Dict[str, str]]:
if not isinstance(options, list): if not isinstance(options, list):
@ -83,11 +110,13 @@ class UserQuestionManager:
def get(self, question_id: str) -> Optional[Dict[str, Any]]: def get(self, question_id: str) -> Optional[Dict[str, Any]]:
with self._lock: with self._lock:
self._prune_resolved()
item = self._items.get(question_id) item = self._items.get(question_id)
return dict(item) if item else None return dict(item) if item else None
def list_pending(self, username: str, conversation_id: Optional[str] = None) -> List[Dict[str, Any]]: def list_pending(self, username: str, conversation_id: Optional[str] = None) -> List[Dict[str, Any]]:
with self._lock: with self._lock:
self._prune_resolved()
rows = [] rows = []
for item in self._items.values(): for item in self._items.values():
if item.get("username") != username: if item.get("username") != username:

View File

@ -10,7 +10,7 @@ from flask import Blueprint, request, jsonify, send_file, session
from .api_auth import api_token_required from .api_auth import api_token_required
from .security import rate_limited from .security import rate_limited
from .tasks import task_manager from .tasks import task_manager
from server.runtime import RuntimeContext, TaskParams, principal_from_session_snapshot, runtime_service from server.runtime import RuntimeContext, TaskParams, TrustedPrincipal, principal_from_session_snapshot, runtime_service
from .context import get_user_resources, ensure_conversation_loaded, get_upload_guard, apply_conversation_overrides from .context import get_user_resources, ensure_conversation_loaded, get_upload_guard, apply_conversation_overrides
from .utils_common import sanitize_filename_preserve_unicode from .utils_common import sanitize_filename_preserve_unicode
from .utils_common import debug_log from .utils_common import debug_log
@ -353,33 +353,34 @@ def send_message_api(workspace_id: str):
def list_conversations_api(workspace_id: str): def list_conversations_api(workspace_id: str):
username = session.get("username") username = session.get("username")
ws = _resolve_workspace(username, workspace_id) ws = _resolve_workspace(username, workspace_id)
terminal, workspace = get_user_resources(username, workspace_id=ws.workspace_id)
if not terminal or not workspace:
return jsonify({"success": False, "error": tr("api_v1.system_not_initialized")}), 503
limit = max(1, min(int(request.args.get("limit", 20)), 100)) limit = max(1, min(int(request.args.get("limit", 20)), 100))
offset = max(0, int(request.args.get("offset", 0))) offset = max(0, int(request.args.get("offset", 0)))
conv_dir = Path(workspace.data_dir) / "conversations" # 数据通道统一走公共入口(消除直读磁盘双轨);载荷字段保持 api_v1 既有形态
items = [] principal = TrustedPrincipal(
for p in sorted(conv_dir.glob("conv_*.json"), key=lambda x: x.stat().st_mtime, reverse=True): username=str(username or ""), workspace_id=ws.workspace_id, role="api", is_api_user=True
)
try: try:
data = json.loads(p.read_text(encoding="utf-8")) result = runtime_service.list_sessions(
meta = data.get("metadata") or {} str(username or ""), ws.workspace_id, principal, limit=limit, offset=offset
items.append({ )
"id": data.get("id"), except RuntimeError as exc:
"title": data.get("title"), return jsonify({"success": False, "error": str(exc)}), 503
"created_at": data.get("created_at"), items = [
"updated_at": data.get("updated_at"), {
"run_mode": meta.get("run_mode"), "id": it.get("id"),
"model_key": meta.get("model_key"), "title": it.get("title"),
"custom_prompt_name": meta.get("custom_prompt_name"), "created_at": it.get("created_at"),
"personalization_name": meta.get("personalization_name"), "updated_at": it.get("updated_at"),
"run_mode": it.get("run_mode"),
"model_key": it.get("model_key"),
"custom_prompt_name": it.get("custom_prompt_name"),
"personalization_name": it.get("personalization_name"),
"workspace_id": ws.workspace_id, "workspace_id": ws.workspace_id,
"messages_count": len(data.get("messages", [])), "messages_count": it.get("total_messages", 0),
}) }
except Exception: for it in (result.get("conversations") or [])
continue ]
sliced = items[offset:offset + limit] return jsonify({"success": True, "data": items, "total": result.get("total", len(items))})
return jsonify({"success": True, "data": sliced, "total": len(items)})
@api_v1_bp.route("/workspaces/<workspace_id>/conversations/<conv_id>", methods=["GET"]) @api_v1_bp.route("/workspaces/<workspace_id>/conversations/<conv_id>", methods=["GET"])
@ -387,20 +388,25 @@ def list_conversations_api(workspace_id: str):
def get_conversation_api(workspace_id: str, conv_id: str): def get_conversation_api(workspace_id: str, conv_id: str):
username = session.get("username") username = session.get("username")
ws = _resolve_workspace(username, workspace_id) ws = _resolve_workspace(username, workspace_id)
_, workspace = get_user_resources(username, workspace_id=ws.workspace_id) # 数据通道统一走公共入口(磁盘权威快照);载荷形态保持 api_v1 既有语义
if not workspace: normalized = conv_id if conv_id.startswith("conv_") else f"conv_{conv_id}"
return jsonify({"success": False, "error": tr("api_v1.system_not_initialized")}), 503 principal = TrustedPrincipal(
path = _conversation_path(workspace, conv_id) username=str(username or ""), workspace_id=ws.workspace_id, role="api", is_api_user=True
if not path.exists(): )
return jsonify({"success": False, "error": tr("api_v1.conversation_not_found")}), 404
try: try:
data = json.loads(path.read_text(encoding="utf-8")) data = runtime_service.get_session_history(
str(username or ""), ws.workspace_id, normalized, principal
)
except RuntimeError as exc:
return jsonify({"success": False, "error": str(exc)}), 503
except Exception as exc:
return jsonify({"success": False, "error": str(exc)}), 500
if data is None:
return jsonify({"success": False, "error": tr("api_v1.conversation_not_found")}), 404
include_messages = request.args.get("full", "0") == "1" include_messages = request.args.get("full", "0") == "1"
if not include_messages: if not include_messages:
data["messages"] = None data["messages"] = None
return jsonify({"success": True, "data": data, "workspace_id": ws.workspace_id}) return jsonify({"success": True, "data": data, "workspace_id": ws.workspace_id})
except Exception as exc:
return jsonify({"success": False, "error": str(exc)}), 500
@api_v1_bp.route("/workspaces/<workspace_id>/conversations/<conv_id>", methods=["DELETE"]) @api_v1_bp.route("/workspaces/<workspace_id>/conversations/<conv_id>", methods=["DELETE"])

View File

@ -40,6 +40,7 @@ from server.multi_agent import multi_agent_bp
from server.workflow_page import workflow_page_bp from server.workflow_page import workflow_page_bp
from server.workflow_runtime_api import workflow_runtime_bp from server.workflow_runtime_api import workflow_runtime_bp
from server.conversation_bootstrap import conversation_bootstrap_bp from server.conversation_bootstrap import conversation_bootstrap_bp
from server.gateway_api import gateway_bp
from server.socket_handlers import socketio from server.socket_handlers import socketio
from server.security import attach_security_hooks from server.security import attach_security_hooks
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
@ -308,6 +309,7 @@ app.register_blueprint(multi_agent_bp)
app.register_blueprint(workflow_page_bp) app.register_blueprint(workflow_page_bp)
app.register_blueprint(workflow_runtime_bp) app.register_blueprint(workflow_runtime_bp)
app.register_blueprint(conversation_bootstrap_bp) app.register_blueprint(conversation_bootstrap_bp)
app.register_blueprint(gateway_bp)
# 安全钩子CSRF 校验 + 响应头) # 安全钩子CSRF 校验 + 响应头)
attach_security_hooks(app) attach_security_hooks(app)

View File

@ -33,7 +33,8 @@ from modules.user_manager import UserWorkspace
from core.web_terminal import WebTerminal from core.web_terminal import WebTerminal
from config.model_profiles import get_model_context_window from config.model_profiles import get_model_context_window
from server.auth_helpers import api_login_required, resolve_admin_policy, get_current_user_record, get_current_username from server.auth_helpers import resolve_admin_policy, get_current_user_record, get_current_username
from server.gateway_auth import api_login_or_host_token_required
from server.context import with_terminal, get_gui_manager, get_upload_guard, build_upload_error_response, ensure_conversation_loaded, get_or_create_usage_tracker from server.context import with_terminal, get_gui_manager, get_upload_guard, build_upload_error_response, ensure_conversation_loaded, get_or_create_usage_tracker
from server.security import rate_limited, prune_socket_tokens from server.security import rate_limited, prune_socket_tokens
from server.utils_common import debug_log from server.utils_common import debug_log
@ -46,7 +47,7 @@ from modules.i18n import tr
UPLOAD_FOLDER_NAME = ".astrion/user_upload" UPLOAD_FOLDER_NAME = ".astrion/user_upload"
@chat_bp.route('/api/user-questions/pending', methods=['GET']) @chat_bp.route('/api/user-questions/pending', methods=['GET'])
@api_login_required @api_login_or_host_token_required
@with_terminal @with_terminal
def list_pending_user_questions(terminal: WebTerminal, workspace: UserWorkspace, username: str): def list_pending_user_questions(terminal: WebTerminal, workspace: UserWorkspace, username: str):
"""获取当前用户待回答的问题列表。""" """获取当前用户待回答的问题列表。"""
@ -61,7 +62,7 @@ def list_pending_user_questions(terminal: WebTerminal, workspace: UserWorkspace,
}) })
@chat_bp.route('/api/user-questions/<question_id>/answer', methods=['POST']) @chat_bp.route('/api/user-questions/<question_id>/answer', methods=['POST'])
@api_login_required @api_login_or_host_token_required
@with_terminal @with_terminal
@rate_limited("user_question_answer", 120, 60, scope="user") @rate_limited("user_question_answer", 120, 60, scope="user")
def answer_user_question(terminal: WebTerminal, workspace: UserWorkspace, username: str, question_id: str): def answer_user_question(terminal: WebTerminal, workspace: UserWorkspace, username: str, question_id: str):
@ -91,7 +92,7 @@ def answer_user_question(terminal: WebTerminal, workspace: UserWorkspace, userna
}) })
@chat_bp.route('/api/plan-approvals/pending', methods=['GET']) @chat_bp.route('/api/plan-approvals/pending', methods=['GET'])
@api_login_required @api_login_or_host_token_required
@with_terminal @with_terminal
def list_pending_plan_approvals(terminal: WebTerminal, workspace: UserWorkspace, username: str): def list_pending_plan_approvals(terminal: WebTerminal, workspace: UserWorkspace, username: str):
"""获取当前用户待批准的计划列表work_mode=plan 的 submit_plan 工具)。""" """获取当前用户待批准的计划列表work_mode=plan 的 submit_plan 工具)。"""
@ -106,7 +107,7 @@ def list_pending_plan_approvals(terminal: WebTerminal, workspace: UserWorkspace,
}) })
@chat_bp.route('/api/plan-approvals/<approval_id>/answer', methods=['POST']) @chat_bp.route('/api/plan-approvals/<approval_id>/answer', methods=['POST'])
@api_login_required @api_login_or_host_token_required
@with_terminal @with_terminal
@rate_limited("plan_approval_answer", 120, 60, scope="user") @rate_limited("plan_approval_answer", 120, 60, scope="user")
def answer_plan_approval(terminal: WebTerminal, workspace: UserWorkspace, username: str, approval_id: str): def answer_plan_approval(terminal: WebTerminal, workspace: UserWorkspace, username: str, approval_id: str):
@ -135,7 +136,7 @@ def answer_plan_approval(terminal: WebTerminal, workspace: UserWorkspace, userna
}) })
@chat_bp.route('/api/tool-approvals/pending', methods=['GET']) @chat_bp.route('/api/tool-approvals/pending', methods=['GET'])
@api_login_required @api_login_or_host_token_required
@with_terminal @with_terminal
def list_pending_tool_approvals(terminal: WebTerminal, workspace: UserWorkspace, username: str): def list_pending_tool_approvals(terminal: WebTerminal, workspace: UserWorkspace, username: str):
"""获取当前用户待审批工具列表。""" """获取当前用户待审批工具列表。"""
@ -150,7 +151,7 @@ def list_pending_tool_approvals(terminal: WebTerminal, workspace: UserWorkspace,
}) })
@chat_bp.route('/api/tool-approvals/<approval_id>/decision', methods=['POST']) @chat_bp.route('/api/tool-approvals/<approval_id>/decision', methods=['POST'])
@api_login_required @api_login_or_host_token_required
@with_terminal @with_terminal
@rate_limited("tool_approval_decision", 60, 60, scope="user") @rate_limited("tool_approval_decision", 60, 60, scope="user")
def decide_tool_approval(terminal: WebTerminal, workspace: UserWorkspace, username: str, approval_id: str): def decide_tool_approval(terminal: WebTerminal, workspace: UserWorkspace, username: str, approval_id: str):

View File

@ -16,8 +16,7 @@ from datetime import datetime, timedelta
from pathlib import Path from pathlib import Path
from typing import Dict, Any, Optional, List, Tuple from typing import Dict, Any, Optional, List, Tuple
from flask import Blueprint, request, jsonify, session from flask import Blueprint
from werkzeug.utils import secure_filename
from config import ( from config import (
OUTPUT_FORMATS, OUTPUT_FORMATS,

View File

@ -10,6 +10,7 @@ from typing import Optional, Dict, Any, List
from .utils_common import debug_log from .utils_common import debug_log
from .state import MONITOR_FILE_TOOLS, MONITOR_MEMORY_TOOLS, MONITOR_SNAPSHOT_CHAR_LIMIT, MONITOR_MEMORY_ENTRY_LIMIT from .state import MONITOR_FILE_TOOLS, MONITOR_MEMORY_TOOLS, MONITOR_SNAPSHOT_CHAR_LIMIT, MONITOR_MEMORY_ENTRY_LIMIT
from .state import tool_approval_manager, user_question_manager, plan_approval_manager from .state import tool_approval_manager, user_question_manager, plan_approval_manager
from .state import get_stop_flag as _state_get_stop_flag
from .monitor import cache_monitor_snapshot from .monitor import cache_monitor_snapshot
from .security import compact_web_search_result from .security import compact_web_search_result
from .chat_flow_helpers import detect_tool_failure from .chat_flow_helpers import detect_tool_failure
@ -245,8 +246,14 @@ def _approval_timeout_for(web_terminal) -> Optional[float]:
return None return None
async def _wait_for_tool_approval(*, approval_id: str, username: str, timeout_seconds: float = 3600.0) -> Dict[str, Any]: async def _wait_for_tool_approval(*, approval_id: str, username: str, timeout_seconds: float = 3600.0, stop_check=None) -> Dict[str, Any]:
"""阻塞等待工具审批裁决。
等待方退出路径超时/软停止/协程取消一律回写 expired 终态
保证 manager 条目不再孤悬 pending迟到回答不再生效
"""
started = time.time() started = time.time()
try:
while True: while True:
row = tool_approval_manager.get(approval_id) row = tool_approval_manager.get(approval_id)
if not row: if not row:
@ -256,9 +263,18 @@ async def _wait_for_tool_approval(*, approval_id: str, username: str, timeout_se
status = row.get("status") status = row.get("status")
if status in {"approved", "rejected"}: if status in {"approved", "rejected"}:
return {"decision": status, "item": row} return {"decision": status, "item": row}
if status == "expired":
return {"decision": "rejected", "code": "approval_expired", "reason": tr("tool_loop.approval_expired")}
if (time.time() - started) >= timeout_seconds: if (time.time() - started) >= timeout_seconds:
tool_approval_manager.mark_expired(approval_id)
return {"decision": "rejected", "code": "approval_timeout", "reason": tr("tool_loop.approval_timeout")} return {"decision": "rejected", "code": "approval_timeout", "reason": tr("tool_loop.approval_timeout")}
if stop_check is not None and stop_check():
tool_approval_manager.mark_expired(approval_id)
return {"decision": "rejected", "code": "approval_stopped", "reason": tr("tool_loop.approval_stopped")}
await asyncio.sleep(0.2) await asyncio.sleep(0.2)
except asyncio.CancelledError:
tool_approval_manager.mark_expired(approval_id)
raise
def _safe_parse_tool_arguments_for_question(web_terminal, tool_call: Dict[str, Any]) -> Optional[Dict[str, Any]]: def _safe_parse_tool_arguments_for_question(web_terminal, tool_call: Dict[str, Any]) -> Optional[Dict[str, Any]]:
@ -278,10 +294,11 @@ def _safe_parse_tool_arguments_for_question(web_terminal, tool_call: Dict[str, A
return None return None
async def _wait_for_user_questions(*, question_ids: List[str], username: str, timeout_seconds: float = 3600.0) -> Dict[str, Dict[str, Any]]: async def _wait_for_user_questions(*, question_ids: List[str], username: str, timeout_seconds: float = 3600.0, stop_check=None) -> Dict[str, Dict[str, Any]]:
started = time.time() started = time.time()
pending = {str(qid) for qid in question_ids if qid} pending = {str(qid) for qid in question_ids if qid}
answered: Dict[str, Dict[str, Any]] = {} answered: Dict[str, Dict[str, Any]] = {}
try:
while pending: while pending:
for qid in list(pending): for qid in list(pending):
row = user_question_manager.get(qid) row = user_question_manager.get(qid)
@ -296,31 +313,53 @@ async def _wait_for_user_questions(*, question_ids: List[str], username: str, ti
if row.get("status") == "answered": if row.get("status") == "answered":
answered[qid] = {**row, "answer_text": format_user_question_answer(row)} answered[qid] = {**row, "answer_text": format_user_question_answer(row)}
pending.remove(qid) pending.remove(qid)
elif row.get("status") == "expired":
answered[qid] = {"status": "expired", "answer_text": tr("tool_loop.question_expired")}
pending.remove(qid)
if not pending: if not pending:
break break
if (time.time() - started) >= timeout_seconds: if (time.time() - started) >= timeout_seconds:
for qid in list(pending): for qid in list(pending):
user_question_manager.mark_expired(qid)
answered[qid] = {"status": "timeout", "answer_text": tr("tool_loop.question_timeout")} answered[qid] = {"status": "timeout", "answer_text": tr("tool_loop.question_timeout")}
pending.remove(qid) pending.remove(qid)
break break
if stop_check is not None and stop_check():
for qid in list(pending):
user_question_manager.mark_expired(qid)
answered[qid] = {"status": "stopped", "answer_text": tr("tool_loop.question_stopped")}
pending.remove(qid)
break
await asyncio.sleep(0.2) await asyncio.sleep(0.2)
except asyncio.CancelledError:
for qid in list(pending):
user_question_manager.mark_expired(qid)
raise
return answered return answered
async def _wait_for_plan_approval(*, approval_id: str, username: str, timeout_seconds: float = 3600.0) -> Dict[str, Any]: async def _wait_for_plan_approval(*, approval_id: str, username: str, timeout_seconds: float = 3600.0, stop_check=None) -> Dict[str, Any]:
"""阻塞等待计划批准结果(轮询管理器,与 _wait_for_user_questions 同构)。""" """阻塞等待计划批准结果(轮询管理器,与 _wait_for_user_questions 同构)。"""
started = time.time() started = time.time()
try:
while True: while True:
row = plan_approval_manager.get(approval_id) row = plan_approval_manager.get(approval_id)
if not row: if not row:
return {"status": "missing"} return {"status": "missing"}
if row.get("username") != username: if row.get("username") != username:
return {"status": "forbidden"} return {"status": "forbidden"}
if row.get("status") in {"approved", "rejected"}: if row.get("status") in {"approved", "rejected", "expired"}:
return row return row
if (time.time() - started) >= timeout_seconds: if (time.time() - started) >= timeout_seconds:
plan_approval_manager.mark_expired(approval_id)
return {"status": "timeout"} return {"status": "timeout"}
if stop_check is not None and stop_check():
plan_approval_manager.mark_expired(approval_id)
return {"status": "stopped"}
await asyncio.sleep(0.3) await asyncio.sleep(0.3)
except asyncio.CancelledError:
plan_approval_manager.mark_expired(approval_id)
raise
async def _handle_workflow_tool(*, function_name: str, web_terminal, arguments, sender, workspace, messages, conversation_id: Optional[str]) -> str: async def _handle_workflow_tool(*, function_name: str, web_terminal, arguments, sender, workspace, messages, conversation_id: Optional[str]) -> str:
@ -447,11 +486,12 @@ async def _handle_submit_plan(*, web_terminal, arguments: Dict[str, Any], sender
'conversation_id': conversation_id, 'conversation_id': conversation_id,
}) })
# 4. 阻塞等待用户决定 # 4. 阻塞等待用户决定(软停止/取消时经 stop_check 退出并回写终态)
resolved = await _wait_for_plan_approval( resolved = await _wait_for_plan_approval(
approval_id=str(approval.get("approval_id") or ""), approval_id=str(approval.get("approval_id") or ""),
username=username, username=username,
timeout_seconds=_approval_timeout_for(web_terminal) or 3600.0, timeout_seconds=_approval_timeout_for(web_terminal) or 3600.0,
stop_check=(lambda: _state_get_stop_flag(task_id, username, include_user=False)) if task_id else None,
) )
status = str(resolved.get("status") or "") status = str(resolved.get("status") or "")
comment = str(resolved.get("comment") or "").strip() comment = str(resolved.get("comment") or "").strip()
@ -595,6 +635,7 @@ async def _execute_tool_calls_impl(*, web_terminal, tool_calls, sender, messages
question_ids=[str(q.get("question_id") or "") for q in created_questions], question_ids=[str(q.get("question_id") or "") for q in created_questions],
username=username, username=username,
timeout_seconds=_approval_timeout_for(web_terminal) or 3600.0, timeout_seconds=_approval_timeout_for(web_terminal) or 3600.0,
stop_check=lambda: get_stop_flag(client_sid, username, include_user=False),
) )
for question in created_questions: for question in created_questions:
qid = str(question.get("question_id") or "") qid = str(question.get("question_id") or "")
@ -907,6 +948,7 @@ async def _execute_tool_calls_impl(*, web_terminal, tool_calls, sender, messages
approval_id=approval_item.get("approval_id"), approval_id=approval_item.get("approval_id"),
username=username, username=username,
timeout_seconds=_approval_timeout_for(web_terminal) or 3600.0, timeout_seconds=_approval_timeout_for(web_terminal) or 3600.0,
stop_check=lambda: get_stop_flag(client_sid, username, include_user=False),
) )
sender('tool_approval_resolved', { sender('tool_approval_resolved', {
'approval_id': approval_item.get("approval_id"), 'approval_id': approval_item.get("approval_id"),
@ -1179,6 +1221,7 @@ async def _execute_tool_calls_impl(*, web_terminal, tool_calls, sender, messages
approval_id=approval_item.get("approval_id"), approval_id=approval_item.get("approval_id"),
username=username, username=username,
timeout_seconds=_approval_timeout_for(web_terminal) or 3600.0, timeout_seconds=_approval_timeout_for(web_terminal) or 3600.0,
stop_check=lambda: get_stop_flag(client_sid, username, include_user=False),
) )
sender('tool_approval_resolved', { sender('tool_approval_resolved', {
'approval_id': approval_item.get("approval_id"), 'approval_id': approval_item.get("approval_id"),

View File

@ -1,28 +1,46 @@
# server/context/__init__.py - 兼容入口(原 server/context.py 拆分为子包) # server/context/__init__.py - 兼容入口(原 server/context.py 拆分为子包)
# 所有历史导入路径 `from server.context import X` 不变。 # 所有历史导入路径 `from server.context import X` 不变。
from server.context.identity import NoWorkspaceError, RuntimeIdentity, _resolve_user_role #
from server.context.broadcast import ( # 2026-09-08G4 拆解):改为 PEP 562 模块级 __getattr__ 懒加载——
make_terminal_callback, # 符号首次被引用时才加载对应子模块避免任务核心层server.tasks
attach_user_broadcast, # import 单个符号就连带拉起全部子模块(含 Web 适配层的 flask 依赖链)。
_wrap_callback_with_conversation_id, from importlib import import_module as _import_module
)
from server.context.personalization import _apply_workspace_personalization_preferences _SYMBOL_MODULE = {
from server.context.usage import get_or_create_usage_tracker, emit_user_quota_update # identity
from server.context.upload import get_gui_manager, get_upload_guard, build_upload_error_response "NoWorkspaceError": "server.context.identity",
from server.context.conversation import ensure_conversation_loaded, apply_conversation_overrides "RuntimeIdentity": "server.context.identity",
from server.context.resources import ( "_resolve_user_role": "server.context.identity",
get_user_resources, # broadcast
_make_terminal_key, "make_terminal_callback": "server.context.broadcast",
_touch_terminal_activity, "attach_user_broadcast": "server.context.broadcast",
_set_terminal_workspace_label, "_wrap_callback_with_conversation_id": "server.context.broadcast",
_ensure_workspace_skills_synced, # personalization
) "_apply_workspace_personalization_preferences": "server.context.personalization",
from server.context.decorators import with_terminal, get_terminal_for_sid # usage
from server.context.reaper import ( "get_or_create_usage_tracker": "server.context.usage",
reset_system_state, "emit_user_quota_update": "server.context.usage",
reap_idle_conversation_terminals, # upload
start_conversation_terminal_reaper, "get_gui_manager": "server.context.upload",
) "get_upload_guard": "server.context.upload",
"build_upload_error_response": "server.context.upload",
# conversation
"ensure_conversation_loaded": "server.context.conversation",
"apply_conversation_overrides": "server.context.conversation",
# resources
"get_user_resources": "server.context.resources",
"_make_terminal_key": "server.context.resources",
"_touch_terminal_activity": "server.context.resources",
"_set_terminal_workspace_label": "server.context.resources",
"_ensure_workspace_skills_synced": "server.context.resources",
# decorators
"with_terminal": "server.context.decorators",
"get_terminal_for_sid": "server.context.decorators",
# reaper
"reset_system_state": "server.context.reaper",
"reap_idle_conversation_terminals": "server.context.reaper",
"start_conversation_terminal_reaper": "server.context.reaper",
}
__all__ = [ __all__ = [
"NoWorkspaceError", "NoWorkspaceError",
@ -43,3 +61,16 @@ __all__ = [
"reap_idle_conversation_terminals", "reap_idle_conversation_terminals",
"start_conversation_terminal_reaper", "start_conversation_terminal_reaper",
] ]
def __getattr__(name):
module_name = _SYMBOL_MODULE.get(name)
if module_name is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
value = getattr(_import_module(module_name), name)
globals()[name] = value # 缓存,后续访问直接命中
return value
def __dir__():
return sorted(set(list(globals()) + list(_SYMBOL_MODULE)))

View File

@ -0,0 +1,40 @@
"""Flask 请求上下文的延迟桥接任务核心层解耦用2026-09-08
背景server.context 子包中仅兼容模式未传 RuntimeIdentity的代码路径
需要读写 Flask session本模块把 flask 依赖收敛为使用点延迟导入
flask 包不可用时按无请求上下文处理读返回默认值写静默跳过
使 server.tasks 等核心层在无 flask 环境中也能独立加载G4 拆解
函数命名与 flask 原符号保持一致has_request_context调用点改动最小
调用方通过 `from server.context._flask_bridge import ...` 引入
"""
from __future__ import annotations
from typing import Any
def has_request_context() -> bool:
"""探测 Flask 请求上下文flask 包不可用时返回 False。"""
try:
from flask import has_request_context as _flask_has_request_context
except ImportError:
return False
return _flask_has_request_context()
def session_get(key: str, default: Any = None) -> Any:
"""读取 Flask session无 flask 包或无请求上下文时返回 default。"""
if not has_request_context():
return default
from flask import session
return session.get(key, default)
def session_set(key: str, value: Any) -> None:
"""回写 Flask session无 flask 包或无请求上下文时静默跳过。"""
if not has_request_context():
return
from flask import session
session[key] = value

View File

@ -5,7 +5,7 @@ import json
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
from flask import session, has_request_context from server.context._flask_bridge import has_request_context, session_set
from core.web_terminal import WebTerminal from core.web_terminal import WebTerminal
from modules.i18n import tr from modules.i18n import tr
@ -31,8 +31,8 @@ def ensure_conversation_loaded(
raise RuntimeError(result.get("message", tr("context.create_conversation_failed"))) raise RuntimeError(result.get("message", tr("context.create_conversation_failed")))
conversation_id = result["conversation_id"] conversation_id = result["conversation_id"]
if update_session and has_request_context(): if update_session and has_request_context():
session['run_mode'] = terminal.run_mode session_set('run_mode', terminal.run_mode)
session['thinking_mode'] = terminal.thinking_mode session_set('thinking_mode', terminal.thinking_mode)
created_new = True created_new = True
else: else:
conversation_id = conversation_id if conversation_id.startswith('conv_') else f"conv_{conversation_id}" conversation_id = conversation_id if conversation_id.startswith('conv_') else f"conv_{conversation_id}"
@ -66,9 +66,9 @@ def ensure_conversation_loaded(
except (ValueError, AttributeError): except (ValueError, AttributeError):
pass pass
if update_session and has_request_context(): if update_session and has_request_context():
session['run_mode'] = terminal.run_mode session_set('run_mode', terminal.run_mode)
session['thinking_mode'] = terminal.thinking_mode session_set('thinking_mode', terminal.thinking_mode)
session['model_key'] = getattr(terminal, "model_key", None) session_set('model_key', getattr(terminal, "model_key", None))
except Exception: except Exception:
pass pass
if workspace is not None: if workspace is not None:

View File

@ -4,7 +4,9 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from typing import Optional from typing import Optional
from server.auth_helpers import get_current_user_role # 注意本模块属于任务核心层依赖链server.tasks → server.context
# 禁止顶层 import Web 适配层server.auth_helpers 依赖 flask
# 兼容模式下的角色解析在使用点函数内延迟导入(见 _resolve_user_role
class NoWorkspaceError(RuntimeError): class NoWorkspaceError(RuntimeError):
@ -38,4 +40,7 @@ def _resolve_user_role(identity: Optional[RuntimeIdentity], record, default: str
if identity.role: if identity.role:
return identity.role return identity.role
return (record.role if record and getattr(record, "role", None) else default) return (record.role if record and getattr(record, "role", None) else default)
# 兼容模式HTTP 请求上下文):延迟导入 Web 认证辅助
from server.auth_helpers import get_current_user_role
return get_current_user_role(record) return get_current_user_role(record)

View File

@ -3,7 +3,7 @@ from __future__ import annotations
from typing import Optional from typing import Optional
from flask import session, has_request_context from server.context._flask_bridge import has_request_context, session_get, session_set
from core.web_terminal import WebTerminal from core.web_terminal import WebTerminal
from modules.personalization_manager import load_personalization_config from modules.personalization_manager import load_personalization_config
@ -30,7 +30,7 @@ def _apply_workspace_personalization_preferences(
if isinstance(session_model, str) and session_model.strip(): if isinstance(session_model, str) and session_model.strip():
resolved_session_model = session_model.strip() resolved_session_model = session_model.strip()
elif allow_session_io and has_request_context(): elif allow_session_io and has_request_context():
raw_session_model = session.get("model_key") raw_session_model = session_get("model_key")
if isinstance(raw_session_model, str) and raw_session_model.strip(): if isinstance(raw_session_model, str) and raw_session_model.strip():
resolved_session_model = raw_session_model.strip() resolved_session_model = raw_session_model.strip()
@ -84,8 +84,8 @@ def _apply_workspace_personalization_preferences(
except Exception: except Exception:
pass pass
if allow_session_io and has_request_context() and update_session: if allow_session_io and has_request_context() and update_session:
session["run_mode"] = getattr(terminal, "run_mode", session.get("run_mode")) session_set("run_mode", getattr(terminal, "run_mode", session_get("run_mode")))
session["thinking_mode"] = getattr(terminal, "thinking_mode", session.get("thinking_mode")) session_set("thinking_mode", getattr(terminal, "thinking_mode", session_get("thinking_mode")))
session["model_key"] = getattr(terminal, "model_key", session.get("model_key")) session_set("model_key", getattr(terminal, "model_key", session_get("model_key")))
except Exception as exc: except Exception as exc:
debug_log(f"[Personalization] 应用工作区偏好失败: {exc}") debug_log(f"[Personalization] 应用工作区偏好失败: {exc}")

View File

@ -12,7 +12,7 @@ from typing import Optional, Tuple, TYPE_CHECKING
if TYPE_CHECKING: if TYPE_CHECKING:
import modules.user_manager import modules.user_manager
from flask import session, has_request_context from server.context._flask_bridge import has_request_context, session_get, session_set
from core.web_terminal import WebTerminal from core.web_terminal import WebTerminal
from modules.personalization_manager import load_personalization_config from modules.personalization_manager import load_personalization_config
@ -28,9 +28,32 @@ from config.model_profiles import get_registered_model_keys
from modules.i18n import tr from modules.i18n import tr
from server import state from server import state
from server.utils_common import debug_log from server.utils_common import debug_log
from server.auth_helpers import get_current_username, get_current_user_record, get_current_user_role
from utils.host_workspace_debug import write_host_workspace_debug from utils.host_workspace_debug import write_host_workspace_debug
# 兼容模式(未传 RuntimeIdentity需要的 Web 认证辅助改为使用点延迟导入,
# 保持任务核心层依赖链server.tasks → server.context.resources无 flask 包依赖。
def _get_current_username() -> Optional[str]:
"""兼容模式专用:延迟导入 Web 认证辅助。"""
from server.auth_helpers import get_current_username
return get_current_username()
def _get_current_user_record():
"""兼容模式专用:延迟导入 Web 认证辅助。"""
from server.auth_helpers import get_current_user_record
return get_current_user_record()
def _get_current_user_role(record=None) -> str:
"""兼容模式专用:延迟导入 Web 认证辅助。"""
from server.auth_helpers import get_current_user_role
return get_current_user_role(record)
from server.context.identity import NoWorkspaceError, RuntimeIdentity, _resolve_user_role from server.context.identity import NoWorkspaceError, RuntimeIdentity, _resolve_user_role
from server.context.broadcast import make_terminal_callback, attach_user_broadcast from server.context.broadcast import make_terminal_callback, attach_user_broadcast
from server.context.personalization import _apply_workspace_personalization_preferences from server.context.personalization import _apply_workspace_personalization_preferences
@ -133,7 +156,7 @@ def get_user_resources(
读取 session 并回写 读取 session 并回写
""" """
from modules.user_manager import UserWorkspace from modules.user_manager import UserWorkspace
username = (username or get_current_username()) username = (username or _get_current_username())
if not username: if not username:
return None, None return None, None
@ -146,7 +169,7 @@ def get_user_resources(
if explicit: if explicit:
host_mode_session = identity.host_mode host_mode_session = identity.host_mode
else: else:
host_mode_session = bool(session.get("host_mode")) if has_request_context() else False host_mode_session = bool(session_get("host_mode"))
sandbox_is_host = (TERMINAL_SANDBOX_MODE or "host").lower() == "host" sandbox_is_host = (TERMINAL_SANDBOX_MODE or "host").lower() == "host"
if host_mode_session and sandbox_is_host: if host_mode_session and sandbox_is_host:
# 宿主机多工作区并行:资源选择必须优先由显式 workspace_id / 当前请求 session 决定, # 宿主机多工作区并行:资源选择必须优先由显式 workspace_id / 当前请求 session 决定,
@ -157,8 +180,8 @@ def get_user_resources(
selected_workspace_id = identity.host_workspace_id selected_workspace_id = identity.host_workspace_id
else: else:
selected_workspace_id = ( selected_workspace_id = (
(session.get("host_workspace_id") if has_request_context() else None) session_get("host_workspace_id")
or (session.get("workspace_id") if has_request_context() else None) or session_get("workspace_id")
) )
with state.HOST_ACTIVE_WORKSPACE_LOCK: with state.HOST_ACTIVE_WORKSPACE_LOCK:
active_workspace_id = state.HOST_ACTIVE_WORKSPACE_ID active_workspace_id = state.HOST_ACTIVE_WORKSPACE_ID
@ -283,8 +306,8 @@ def get_user_resources(
run_mode = identity.preferred_run_mode run_mode = identity.preferred_run_mode
thinking_mode_flag = identity.preferred_thinking_mode thinking_mode_flag = identity.preferred_thinking_mode
else: else:
run_mode = session.get('run_mode') if has_request_context() else None run_mode = session_get('run_mode')
thinking_mode_flag = session.get('thinking_mode') if has_request_context() else None thinking_mode_flag = session_get('thinking_mode')
if run_mode not in {"fast", "thinking", "deep"}: if run_mode not in {"fast", "thinking", "deep"}:
run_mode = "fast" run_mode = "fast"
thinking_mode_flag = False thinking_mode_flag = False
@ -306,18 +329,18 @@ def get_user_resources(
terminal.user_role = "admin" terminal.user_role = "admin"
terminal.quota_update_callback = None terminal.quota_update_callback = None
if can_write_session: if can_write_session:
session['run_mode'] = terminal.run_mode session_set('run_mode', terminal.run_mode)
session['thinking_mode'] = terminal.thinking_mode session_set('thinking_mode', terminal.thinking_mode)
session['workspace_id'] = getattr(workspace, "workspace_id", None) session_set('workspace_id', getattr(workspace, "workspace_id", None))
session['host_workspace_id'] = getattr(workspace, "workspace_id", None) session_set('host_workspace_id', getattr(workspace, "workspace_id", None))
else: else:
terminal.update_container_session(container_handle) terminal.update_container_session(container_handle)
attach_user_broadcast(terminal, "host") attach_user_broadcast(terminal, "host")
terminal.username = "host" terminal.username = "host"
terminal.user_role = "admin" terminal.user_role = "admin"
if can_write_session: if can_write_session:
session['workspace_id'] = getattr(workspace, "workspace_id", None) session_set('workspace_id', getattr(workspace, "workspace_id", None))
session['host_workspace_id'] = getattr(workspace, "workspace_id", None) session_set('host_workspace_id', getattr(workspace, "workspace_id", None))
_set_terminal_workspace_label( _set_terminal_workspace_label(
terminal, terminal,
host_workspace.get("label") or host_workspace.get("workspace_id") or getattr(workspace, "workspace_id", None), host_workspace.get("label") or host_workspace.get("workspace_id") or getattr(workspace, "workspace_id", None),
@ -328,8 +351,8 @@ def get_user_resources(
from core.tool_config import ToolCategory from core.tool_config import ToolCategory
from modules import admin_policy_manager from modules import admin_policy_manager
record = None if explicit else get_current_user_record() record = None if explicit else _get_current_user_record()
role = _resolve_user_role(identity, record, default="admin") if explicit else (get_current_user_role(record) if record else "admin") role = _resolve_user_role(identity, record, default="admin") if explicit else (_get_current_user_role(record) if record else "admin")
invite_code = getattr(record, "invite_code", None) if record else None invite_code = getattr(record, "invite_code", None) if record else None
policy = admin_policy_manager.get_effective_policy( policy = admin_policy_manager.get_effective_policy(
record.username if record else username, record.username if record else username,
@ -356,7 +379,7 @@ def get_user_resources(
try: try:
terminal.set_model(candidate) terminal.set_model(candidate)
if can_write_session: if can_write_session:
session["model_key"] = terminal.model_key session_set("model_key", terminal.model_key)
break break
except Exception: except Exception:
continue continue
@ -383,7 +406,7 @@ def get_user_resources(
) )
return terminal, workspace return terminal, workspace
is_api_user = identity.is_api_user if explicit else (bool(session.get("is_api_user")) if has_request_context() else False) is_api_user = identity.is_api_user if explicit else bool(session_get("is_api_user"))
# API 用户与网页用户使用不同的 manager # API 用户与网页用户使用不同的 manager
if is_api_user: if is_api_user:
record = None record = None
@ -391,13 +414,13 @@ def get_user_resources(
raise RuntimeError(tr("context.missing_workspace_id")) raise RuntimeError(tr("context.missing_workspace_id"))
workspace = state.api_user_manager.ensure_workspace(username, workspace_id) workspace = state.api_user_manager.ensure_workspace(username, workspace_id)
else: else:
record = (state.user_manager.get_user(username) if explicit else get_current_user_record()) record = (state.user_manager.get_user(username) if explicit else _get_current_user_record())
if explicit: if explicit:
selected_workspace_id = workspace_id or "default" selected_workspace_id = workspace_id or "default"
else: else:
selected_workspace_id = ( selected_workspace_id = (
workspace_id workspace_id
or (session.get("workspace_id") if has_request_context() else None) or session_get("workspace_id")
or "default" or "default"
) )
workspace = state.user_manager.ensure_user_workspace(username, selected_workspace_id) workspace = state.user_manager.ensure_user_workspace(username, selected_workspace_id)
@ -423,8 +446,8 @@ def get_user_resources(
run_mode = identity.preferred_run_mode run_mode = identity.preferred_run_mode
thinking_mode_flag = identity.preferred_thinking_mode thinking_mode_flag = identity.preferred_thinking_mode
else: else:
run_mode = session.get('run_mode') if has_request_context() else None run_mode = session_get('run_mode')
thinking_mode_flag = session.get('thinking_mode') if has_request_context() else None thinking_mode_flag = session_get('thinking_mode')
if run_mode not in {"fast", "thinking", "deep"}: if run_mode not in {"fast", "thinking", "deep"}:
preferred_run_mode = None preferred_run_mode = None
try: try:
@ -460,10 +483,10 @@ def get_user_resources(
terminal.user_role = "api" if is_api_user else _resolve_user_role(identity, record) terminal.user_role = "api" if is_api_user else _resolve_user_role(identity, record)
terminal.quota_update_callback = (lambda metric=None: emit_user_quota_update(username)) if not is_api_user else None terminal.quota_update_callback = (lambda metric=None: emit_user_quota_update(username)) if not is_api_user else None
if can_write_session: if can_write_session:
session['run_mode'] = terminal.run_mode session_set('run_mode', terminal.run_mode)
session['thinking_mode'] = terminal.thinking_mode session_set('thinking_mode', terminal.thinking_mode)
session['model_key'] = getattr(terminal, "model_key", None) session_set('model_key', getattr(terminal, "model_key", None))
session['workspace_id'] = getattr(workspace, "workspace_id", None) session_set('workspace_id', getattr(workspace, "workspace_id", None))
else: else:
terminal.update_container_session(container_handle) terminal.update_container_session(container_handle)
attach_user_broadcast(terminal, username) attach_user_broadcast(terminal, username)
@ -471,7 +494,7 @@ def get_user_resources(
terminal.user_role = "api" if is_api_user else _resolve_user_role(identity, record) terminal.user_role = "api" if is_api_user else _resolve_user_role(identity, record)
terminal.quota_update_callback = (lambda metric=None: emit_user_quota_update(username)) if not is_api_user else None terminal.quota_update_callback = (lambda metric=None: emit_user_quota_update(username)) if not is_api_user else None
if can_write_session: if can_write_session:
session['workspace_id'] = getattr(workspace, "workspace_id", None) session_set('workspace_id', getattr(workspace, "workspace_id", None))
if is_api_user: if is_api_user:
workspace_label = workspace_id_value workspace_label = workspace_id_value
@ -516,7 +539,7 @@ def get_user_resources(
try: try:
terminal.set_model(candidate) terminal.set_model(candidate)
if can_write_session: if can_write_session:
session["model_key"] = terminal.model_key session_set("model_key", terminal.model_key)
break break
except Exception: except Exception:
continue continue

111
server/gateway_api.py Normal file
View File

@ -0,0 +1,111 @@
"""Gateway 公共协议路由2026-09-08 新增)。
定位docs/runtime_protocol.md session.* 能力传输暴露
CLI/GUI 等非 Web 客户端提供不依赖 Web 专属会话路由的公共入口
Web 端既有 /api/conversations 路由保持不动
路由全部支持双通道认证Web session host Bearer token
- GET /api/runtime/sessions session.list
- POST /api/runtime/sessions session.create显式
- GET /api/runtime/sessions/<cid>/history session.history
加载会话用于展示= session.history agent 在某会话中执行=
run.start 携带 conversation_id不单独提供 session.load 命令
Web load 语义是切换 terminal 内存态 Web 适配层视图概念
"""
from __future__ import annotations
from flask import Blueprint, jsonify, request, session
from modules.i18n import tr
from server.gateway_auth import api_login_or_host_token_required
from server.runtime.context import principal_from_session_snapshot
from server.runtime.service import runtime_service
gateway_bp = Blueprint("gateway", __name__)
def _resolve_workspace_id(explicit: str = "") -> str:
"""查询/创建未显式指定工作区时,回落当前会话工作区。"""
return str(explicit or session.get("workspace_id") or "default")
@gateway_bp.route("/api/runtime/sessions", methods=["GET"])
@api_login_or_host_token_required
def list_runtime_sessions():
"""session.list会话列表工作区/数量/多智能体筛选)。"""
workspace_id = _resolve_workspace_id(request.args.get("workspace_id", ""))
limit = request.args.get("limit", 50, type=int)
offset = request.args.get("offset", 0, type=int)
ma_param = request.args.get("multi_agent_mode", None)
multi_agent_mode = None if ma_param is None else ma_param in ("1", "true", "True")
username = str(session.get("username") or "")
principal = principal_from_session_snapshot(dict(session), workspace_id, username)
try:
result = runtime_service.list_sessions(
username,
workspace_id,
principal,
limit=limit,
offset=offset,
multi_agent_mode=multi_agent_mode,
)
return jsonify({"success": True, **(result or {})})
except PermissionError as exc:
return jsonify({"success": False, "error": str(exc)}), 403
except ValueError as exc:
return jsonify({"success": False, "error": str(exc)}), 400
except RuntimeError as exc:
return jsonify({"success": False, "error": str(exc)}), 503
@gateway_bp.route("/api/runtime/sessions", methods=["POST"])
@api_login_or_host_token_required
def create_runtime_session():
"""session.create显式创建会话返回 conversation_id。"""
data = request.get_json(silent=True) or {}
workspace_id = _resolve_workspace_id(str(data.get("workspace_id") or ""))
username = str(session.get("username") or "")
principal = principal_from_session_snapshot(dict(session), workspace_id, username)
try:
result = runtime_service.create_session(
username,
workspace_id,
principal,
run_mode=data.get("run_mode"),
thinking_mode=data.get("thinking_mode"),
model_key=data.get("model_key"),
multi_agent_mode=bool(data.get("multi_agent_mode")),
)
return jsonify({"success": True, **result})
except PermissionError as exc:
return jsonify({"success": False, "error": str(exc)}), 403
except ValueError as exc:
return jsonify({"success": False, "error": str(exc)}), 400
except RuntimeError as exc:
return jsonify({"success": False, "error": str(exc)}), 503
@gateway_bp.route("/api/runtime/sessions/<conversation_id>/history", methods=["GET"])
@api_login_or_host_token_required
def get_runtime_session_history(conversation_id: str):
"""session.history会话历史磁盘权威快照"""
workspace_id = _resolve_workspace_id(request.args.get("workspace_id", ""))
username = str(session.get("username") or "")
principal = principal_from_session_snapshot(dict(session), workspace_id, username)
try:
result = runtime_service.get_session_history(
username, workspace_id, conversation_id, principal
)
except PermissionError as exc:
return jsonify({"success": False, "error": str(exc)}), 403
except ValueError as exc:
return jsonify({"success": False, "error": str(exc)}), 400
except RuntimeError as exc:
return jsonify({"success": False, "error": str(exc)}), 503
if result is None:
return jsonify({"success": False, "error": tr("ctx_mgr.conversation_not_found", conversation_id=conversation_id)}), 404
return jsonify({"success": True, "conversation": result})
__all__ = ["gateway_bp"]

142
server/gateway_auth.py Normal file
View File

@ -0,0 +1,142 @@
"""Gateway 本机客户端通道认证host Bearer token2026-09-08 新增)。
定位协议 docs/runtime_protocol.md §6认证与授权按通道裁剪 host 通道
实现 CLI/GUI 等本机客户端无需 Web 会话流程Cookie/CSRF/host-login
即可接入 Gateway 公共协议端点
安全模型与 /host-login 完全对齐host 模式定位为本机单人使用
- TERMINAL_SANDBOX_MODE=host且非 LINUX_SAFETY生效
- 仅允许回环地址127.0.0.1/::1/localhost直连调用
- token 为本机生成的随机串存于运行态数据目录
<DATA_DIR>/host_api_token权限 0600本机客户端读取后经
`Authorization: Bearer <token>` 携带
"""
from __future__ import annotations
import functools
import hmac
import os
import secrets
from pathlib import Path
from typing import Optional
from flask import current_app, jsonify, request, session
from config import DATA_DIR, TERMINAL_SANDBOX_MODE
from config.terminal import LINUX_SAFETY
from modules.host_workspace_manager import resolve_host_workspace
from modules.i18n import tr
from server.auth_helpers import is_logged_in
_LOOPBACK_ADDRS = {"127.0.0.1", "::1", "localhost"}
_TOKEN_FILENAME = "host_api_token"
def _host_channel_enabled() -> bool:
return (TERMINAL_SANDBOX_MODE or "").lower() == "host" and not LINUX_SAFETY
def host_api_token_path() -> Path:
return Path(DATA_DIR).expanduser() / _TOKEN_FILENAME
def get_or_create_host_api_token() -> Optional[str]:
"""读取不存在则生成host 通道 token非 host 模式返回 None。
生成使用临时文件 + os.replace 原子替换 + 0600 权限本地单进程
场景下并发首次生成概率极低万一竞争产生两个 token后写覆盖先写
持旧 token 的一方 401 后重读文件即可自愈
"""
if not _host_channel_enabled():
return None
path = host_api_token_path()
try:
existing = path.read_text(encoding="utf-8").strip()
if existing:
return existing
except OSError:
pass
token = secrets.token_urlsafe(32)
try:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_name(path.name + ".tmp")
tmp.write_text(token + "\n", encoding="utf-8")
os.chmod(tmp, 0o600)
os.replace(tmp, path)
except OSError:
return None
return token
def _extract_bearer_token() -> str:
auth_header = request.headers.get("Authorization") or ""
if not auth_header.lower().startswith("bearer "):
return ""
return auth_header.split(" ", 1)[1].strip()
def _verify_host_bearer(token: str) -> bool:
"""校验 host Bearer tokenhost 模式 + 回环地址 + token 匹配。"""
if not token or not _host_channel_enabled():
return False
remote_addr = (request.remote_addr or "").strip()
if remote_addr not in _LOOPBACK_ADDRS:
return False
expected = get_or_create_host_api_token()
if not expected:
return False
return hmac.compare_digest(token, expected)
def _inject_host_identity() -> None:
"""注入与 /host-login 等价的 host 会话身份(复用现有上下文装配逻辑)。
session 字段与 server/auth.py host_login 保持一致 login_nonce
保证路由内及下游 is_logged_in()/principal 构造等既有逻辑无差异
"""
from server.auth import _issue_login_nonce
_, host_workspace = resolve_host_workspace()
session["logged_in"] = True
session["username"] = "host"
session["role"] = "admin"
session["host_mode"] = True
if host_workspace:
workspace_id = host_workspace.get("workspace_id") or "default"
session["host_workspace_id"] = workspace_id
session["workspace_id"] = workspace_id
default_thinking = current_app.config.get("DEFAULT_THINKING_MODE", False)
session["thinking_mode"] = default_thinking
session["run_mode"] = current_app.config.get(
"DEFAULT_RUN_MODE", "deep" if default_thinking else "fast"
)
session.permanent = True
_issue_login_nonce("host")
def api_login_or_host_token_required(view_func):
"""双通道认证装饰器host Bearer token 或 Web session 登录。
- 携带 Bearer token 且匹配 host 通道 注入 host 身份并放行
- 否则回退 Web session 检查is_logged_in
Bearer 请求天然跳过 CSRFserver/security.py 已放行 Authorization
"""
@functools.wraps(view_func)
def wrapped(*args, **kwargs):
token = _extract_bearer_token()
if token and _verify_host_bearer(token):
_inject_host_identity()
return view_func(*args, **kwargs)
if not is_logged_in():
return jsonify({"success": False, "error": tr("auth.session_expired")}), 401
return view_func(*args, **kwargs)
return wrapped
__all__ = [
"api_login_or_host_token_required",
"get_or_create_host_api_token",
"host_api_token_path",
]

View File

@ -229,6 +229,46 @@ class RuntimeService:
manager = cm._get_conversation_manager_for_id(conversation_id) manager = cm._get_conversation_manager_for_id(conversation_id)
return manager.load_conversation(conversation_id) return manager.load_conversation(conversation_id)
def create_session(
self,
username: str,
workspace_id: str,
principal: Optional[TrustedPrincipal] = None,
*,
run_mode: Optional[str] = None,
thinking_mode: Optional[bool] = None,
model_key: Optional[str] = None,
multi_agent_mode: bool = False,
) -> Dict[str, Any]:
"""显式创建会话session.create
纯创建对话文件不切换任何 terminal 的当前对话返回
{"conversation_id": ...}客户端随后 run.start 携带该 id 即可在
新会话中执行先建会话再发任务的装配职责收在服务层单点
默认模式解析优先级显式传参 > principal 偏好快照 > 系统默认
"""
terminal, workspace = self._resources_for_query(username, workspace_id, principal)
cm = getattr(getattr(terminal, "context_manager", None), "conversation_manager", None)
if cm is None or workspace is None:
raise RuntimeError(tr("tasks.system_not_initialized"))
resolved_run_mode = run_mode or (principal.preferred_run_mode if principal else None) or "fast"
if resolved_run_mode not in {"fast", "thinking", "deep"}:
resolved_run_mode = "fast"
resolved_thinking = thinking_mode
if resolved_thinking is None and principal is not None:
resolved_thinking = principal.preferred_thinking_mode
resolved_thinking = (
bool(resolved_thinking) if resolved_thinking is not None else (resolved_run_mode != "fast")
)
conversation_id = cm.create_conversation(
project_path=str(getattr(workspace, "project_path", "") or "."),
run_mode=resolved_run_mode,
thinking_mode=resolved_thinking,
model_key=model_key or (principal.preferred_model_key if principal else None),
metadata_overrides={"multi_agent_mode": True} if multi_agent_mode else None,
)
return {"conversation_id": conversation_id}
@staticmethod @staticmethod
def _resources_for_query(username: str, workspace_id: str, principal: Optional[TrustedPrincipal]): def _resources_for_query(username: str, workspace_id: str, principal: Optional[TrustedPrincipal]):
"""查询类调用的资源装配(工作区级 terminal 即可,不加载会话到内存)。""" """查询类调用的资源装配(工作区级 terminal 即可,不加载会话到内存)。"""

View File

@ -14,7 +14,8 @@ from typing import Dict, Any, Optional, List
from flask import Blueprint, request, jsonify from flask import Blueprint, request, jsonify
from flask import current_app, session from flask import current_app, session
from server.auth_helpers import api_login_required, get_current_username from server.auth_helpers import get_current_username
from server.gateway_auth import api_login_or_host_token_required
from server.context import get_user_resources, ensure_conversation_loaded from server.context import get_user_resources, ensure_conversation_loaded
from server.security import rate_limited from server.security import rate_limited
from server.state import stop_flags from server.state import stop_flags
@ -31,7 +32,7 @@ from modules.i18n import tr
@tasks_bp.route("/api/tasks", methods=["GET"]) @tasks_bp.route("/api/tasks", methods=["GET"])
@api_login_required @api_login_or_host_token_required
def list_tasks_api(): def list_tasks_api():
username = get_current_username() username = get_current_username()
workspace_id = (request.args.get("workspace_id") or "").strip() or None workspace_id = (request.args.get("workspace_id") or "").strip() or None
@ -43,7 +44,7 @@ def list_tasks_api():
}) })
@tasks_bp.route("/api/conversations/<conversation_id>/running-status", methods=["GET"]) @tasks_bp.route("/api/conversations/<conversation_id>/running-status", methods=["GET"])
@api_login_required @api_login_or_host_token_required
def get_conversation_running_status_api(conversation_id: str): def get_conversation_running_status_api(conversation_id: str):
"""REST 对账接口:聚合某对话的完整运行状态。 """REST 对账接口:聚合某对话的完整运行状态。
@ -93,7 +94,7 @@ def get_conversation_running_status_api(conversation_id: str):
}) })
@tasks_bp.route("/api/tasks", methods=["POST"]) @tasks_bp.route("/api/tasks", methods=["POST"])
@api_login_required @api_login_or_host_token_required
@rate_limited("chat_task_create", 30, 60, scope="user") @rate_limited("chat_task_create", 30, 60, scope="user")
def create_task_api(): def create_task_api():
username = get_current_username() username = get_current_username()
@ -201,7 +202,7 @@ def create_task_api():
}), 202 }), 202
@tasks_bp.route("/api/tasks/<task_id>", methods=["GET"]) @tasks_bp.route("/api/tasks/<task_id>", methods=["GET"])
@api_login_required @api_login_or_host_token_required
def get_task_api(task_id: str): def get_task_api(task_id: str):
started_at = time.time() started_at = time.time()
username = get_current_username() username = get_current_username()
@ -259,7 +260,7 @@ def get_task_api(task_id: str):
}) })
@tasks_bp.route("/api/tasks/<task_id>/cancel", methods=["POST"]) @tasks_bp.route("/api/tasks/<task_id>/cancel", methods=["POST"])
@api_login_required @api_login_or_host_token_required
def cancel_task_api(task_id: str): def cancel_task_api(task_id: str):
username = get_current_username() username = get_current_username()
rec = runtime_service.get_task(username, task_id) rec = runtime_service.get_task(username, task_id)
@ -280,7 +281,7 @@ def cancel_task_api(task_id: str):
return jsonify({"success": True}) return jsonify({"success": True})
@tasks_bp.route("/api/tasks/<task_id>/runtime_guidance", methods=["POST"]) @tasks_bp.route("/api/tasks/<task_id>/runtime_guidance", methods=["POST"])
@api_login_required @api_login_or_host_token_required
def enqueue_runtime_guidance_api(task_id: str): def enqueue_runtime_guidance_api(task_id: str):
username = get_current_username() username = get_current_username()
payload = request.get_json() or {} payload = request.get_json() or {}
@ -308,7 +309,7 @@ def enqueue_runtime_guidance_api(task_id: str):
) )
@tasks_bp.route("/api/tasks/<task_id>/runtime_queue", methods=["POST"]) @tasks_bp.route("/api/tasks/<task_id>/runtime_queue", methods=["POST"])
@api_login_required @api_login_or_host_token_required
def enqueue_runtime_queue_message_api(task_id: str): def enqueue_runtime_queue_message_api(task_id: str):
username = get_current_username() username = get_current_username()
payload = request.get_json() or {} payload = request.get_json() or {}
@ -336,7 +337,7 @@ def enqueue_runtime_queue_message_api(task_id: str):
) )
@tasks_bp.route("/api/tasks/<task_id>/runtime_queue/<message_id>", methods=["DELETE"]) @tasks_bp.route("/api/tasks/<task_id>/runtime_queue/<message_id>", methods=["DELETE"])
@api_login_required @api_login_or_host_token_required
def delete_runtime_queue_message_api(task_id: str, message_id: str): def delete_runtime_queue_message_api(task_id: str, message_id: str):
username = get_current_username() username = get_current_username()
result = runtime_service.remove_runtime_pending_message(username, task_id, message_id) result = runtime_service.remove_runtime_pending_message(username, task_id, message_id)
@ -358,7 +359,7 @@ def delete_runtime_queue_message_api(task_id: str, message_id: str):
) )
@tasks_bp.route("/api/tasks/<task_id>/runtime_queue/<message_id>/guide", methods=["POST"]) @tasks_bp.route("/api/tasks/<task_id>/runtime_queue/<message_id>/guide", methods=["POST"])
@api_login_required @api_login_or_host_token_required
def guide_runtime_queue_message_api(task_id: str, message_id: str): def guide_runtime_queue_message_api(task_id: str, message_id: str):
username = get_current_username() username = get_current_username()
result = runtime_service.promote_runtime_pending_to_guidance(username, task_id, message_id) result = runtime_service.promote_runtime_pending_to_guidance(username, task_id, message_id)

View File

@ -59,7 +59,10 @@ class TestApplyWorkspacePersonalizationPreferences(unittest.TestCase):
session = FakeSession() session = FakeSession()
session["model_key"] = "session-model" session["model_key"] = "session-model"
with patch(f"{_PERSONALIZATION_NS}.session", session): # flask bridge 化后2026-09-08session 读写经 session_get/session_set
# patch 点随之指向 bridge 函数而非模块级 session 符号。
with patch(f"{_PERSONALIZATION_NS}.session_get", side_effect=lambda key, default=None: session.get(key, default)), \
patch(f"{_PERSONALIZATION_NS}.session_set", side_effect=lambda key, value: session.__setitem__(key, value)):
_apply_workspace_personalization_preferences(terminal, workspace) _apply_workspace_personalization_preferences(terminal, workspace)
terminal.set_model.assert_called_once_with("session-model") terminal.set_model.assert_called_once_with("session-model")
@ -74,7 +77,8 @@ class TestApplyWorkspacePersonalizationPreferences(unittest.TestCase):
workspace = self._make_workspace() workspace = self._make_workspace()
session = FakeSession() session = FakeSession()
with patch(f"{_PERSONALIZATION_NS}.session", session): with patch(f"{_PERSONALIZATION_NS}.session_get", side_effect=lambda key, default=None: session.get(key, default)), \
patch(f"{_PERSONALIZATION_NS}.session_set", side_effect=lambda key, value: session.__setitem__(key, value)):
_apply_workspace_personalization_preferences(terminal, workspace) _apply_workspace_personalization_preferences(terminal, workspace)
terminal.set_model.assert_not_called() terminal.set_model.assert_not_called()

View File

@ -153,8 +153,8 @@ class GetUserResourcesHostPolicyTest(unittest.TestCase):
try: try:
with patch("server.context.resources.TERMINAL_SANDBOX_MODE", "host"), \ with patch("server.context.resources.TERMINAL_SANDBOX_MODE", "host"), \
patch("server.context.resources.resolve_host_workspace", return_value=(None, host_ws)), \ patch("server.context.resources.resolve_host_workspace", return_value=(None, host_ws)), \
patch("server.context.resources.get_current_user_record", return_value=record), \ patch("server.context.resources._get_current_user_record", return_value=record), \
patch("server.context.resources.get_current_user_role", return_value="admin") as get_role, \ patch("server.context.resources._get_current_user_role", return_value="admin") as get_role, \
patch("modules.admin_policy_manager.get_effective_policy", return_value=policy) as get_policy: patch("modules.admin_policy_manager.get_effective_policy", return_value=policy) as get_policy:
from flask import Flask from flask import Flask
app = Flask("host_policy_test") app = Flask("host_policy_test")

View File

@ -241,7 +241,9 @@ class CrudMixin:
"total_messages": metadata.total_messages, "total_messages": metadata.total_messages,
"total_tools": metadata.total_tools, "total_tools": metadata.total_tools,
"status": metadata.status, "status": metadata.status,
"multi_agent_mode": bool(conversation_data["metadata"].get("multi_agent_mode", False)) "multi_agent_mode": bool(conversation_data["metadata"].get("multi_agent_mode", False)),
"custom_prompt_name": conversation_data["metadata"].get("custom_prompt_name"),
"personalization_name": conversation_data["metadata"].get("personalization_name"),
} }
self._save_index(index) self._save_index(index)

View File

@ -182,6 +182,8 @@ class IndexMixin:
"total_tools": metadata.get("total_tools", 0), "total_tools": metadata.get("total_tools", 0),
"status": metadata.get("status", "active"), "status": metadata.get("status", "active"),
"multi_agent_mode": bool(metadata.get("multi_agent_mode", False)), "multi_agent_mode": bool(metadata.get("multi_agent_mode", False)),
"custom_prompt_name": metadata.get("custom_prompt_name"),
"personalization_name": metadata.get("personalization_name"),
} }
elapsed_ms = (time.perf_counter() - t0) * 1000 elapsed_ms = (time.perf_counter() - t0) * 1000
if perf_log: if perf_log:

View File

@ -124,6 +124,10 @@ class ListSearchMixin:
"project_path": metadata.get("project_path"), "project_path": metadata.get("project_path"),
"project_relative_path": metadata.get("project_relative_path"), "project_relative_path": metadata.get("project_relative_path"),
"thinking_mode": metadata.get("thinking_mode", False), "thinking_mode": metadata.get("thinking_mode", False),
"run_mode": metadata.get("run_mode"),
"model_key": metadata.get("model_key"),
"custom_prompt_name": metadata.get("custom_prompt_name"),
"personalization_name": metadata.get("personalization_name"),
"total_messages": metadata.get("total_messages", 0), "total_messages": metadata.get("total_messages", 0),
"total_tools": metadata.get("total_tools", 0), "total_tools": metadata.get("total_tools", 0),
"status": metadata.get("status", "active"), "status": metadata.get("status", "active"),