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.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 并取消 |
> | 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 |
> | ExecutionBackend 替身E1-E4 | ✅ | —(默认 None生产路径不变 | ✅ fake_exec |
> | ③↔④ 全量贯通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命令校验/路径授权仍在旧链路,真实后端接入时必须保留)。
>
> **第一档+第二档收尾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-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": "审批超时",
"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": {
"zh-CN": "等待用户审批",
"en-US": "Waiting for user approval",

View File

@ -10,6 +10,10 @@ from modules.i18n import tr
# 计划文档内容送进弹窗/记录的最大字符数(超出截断,完整内容始终在计划文件里)
PLAN_CONTENT_MAX_CHARS = 20000
# 终态条目保留时长与任务记录终态清理3600s对齐。
# pending 条目永不自动清理;等待方退出时经 mark_expired 转为终态后惰性回收。
RESOLVED_TTL_SECONDS = 3600.0
class PlanApprovalManager:
"""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._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(
self,
*,
@ -60,11 +87,13 @@ class PlanApprovalManager:
def get(self, approval_id: str) -> Optional[Dict[str, Any]]:
with self._lock:
self._prune_resolved()
item = self._items.get(approval_id)
return dict(item) if item else None
def list_pending(self, username: str, conversation_id: Optional[str] = None) -> List[Dict[str, Any]]:
with self._lock:
self._prune_resolved()
rows = []
for item in self._items.values():
if item.get("username") != username:

View File

@ -7,12 +7,29 @@ from typing import Any, Dict, List, Optional
from modules.i18n import tr
# 终态条目保留时长与任务记录终态清理3600s对齐。
# pending 条目永不自动清理——仍属合法等待;等待方退出(超时/停止/取消)
# 时会经 mark_expired 转为 expired 终态,再由本 TTL 惰性回收。
RESOLVED_TTL_SECONDS = 3600.0
class ToolApprovalManager:
def __init__(self):
self._items: Dict[str, Dict[str, Any]] = {}
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(
self,
*,
@ -45,11 +62,28 @@ class ToolApprovalManager:
def get(self, approval_id: str) -> Optional[Dict[str, Any]]:
with self._lock:
self._prune_resolved()
item = self._items.get(approval_id)
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]]:
with self._lock:
self._prune_resolved()
rows = []
for item in self._items.values():
if item.get("username") != username:

View File

@ -7,6 +7,10 @@ from typing import Any, Dict, List, Optional
from modules.i18n import tr
# 终态条目保留时长与任务记录终态清理3600s对齐。
# pending 条目永不自动清理;等待方退出时经 mark_expired 转为终态后惰性回收。
RESOLVED_TTL_SECONDS = 3600.0
class UserQuestionManager:
"""In-memory manager for blocking model-to-user questions."""
@ -15,6 +19,29 @@ class UserQuestionManager:
self._items: Dict[str, Dict[str, Any]] = {}
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
def _normalize_options(options: Any) -> List[Dict[str, str]]:
if not isinstance(options, list):
@ -83,11 +110,13 @@ class UserQuestionManager:
def get(self, question_id: str) -> Optional[Dict[str, Any]]:
with self._lock:
self._prune_resolved()
item = self._items.get(question_id)
return dict(item) if item else None
def list_pending(self, username: str, conversation_id: Optional[str] = None) -> List[Dict[str, Any]]:
with self._lock:
self._prune_resolved()
rows = []
for item in self._items.values():
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 .security import rate_limited
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 .utils_common import sanitize_filename_preserve_unicode
from .utils_common import debug_log
@ -353,33 +353,34 @@ def send_message_api(workspace_id: str):
def list_conversations_api(workspace_id: str):
username = session.get("username")
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))
offset = max(0, int(request.args.get("offset", 0)))
conv_dir = Path(workspace.data_dir) / "conversations"
items = []
for p in sorted(conv_dir.glob("conv_*.json"), key=lambda x: x.stat().st_mtime, reverse=True):
# 数据通道统一走公共入口(消除直读磁盘双轨);载荷字段保持 api_v1 既有形态
principal = TrustedPrincipal(
username=str(username or ""), workspace_id=ws.workspace_id, role="api", is_api_user=True
)
try:
data = json.loads(p.read_text(encoding="utf-8"))
meta = data.get("metadata") or {}
items.append({
"id": data.get("id"),
"title": data.get("title"),
"created_at": data.get("created_at"),
"updated_at": data.get("updated_at"),
"run_mode": meta.get("run_mode"),
"model_key": meta.get("model_key"),
"custom_prompt_name": meta.get("custom_prompt_name"),
"personalization_name": meta.get("personalization_name"),
result = runtime_service.list_sessions(
str(username or ""), ws.workspace_id, principal, limit=limit, offset=offset
)
except RuntimeError as exc:
return jsonify({"success": False, "error": str(exc)}), 503
items = [
{
"id": it.get("id"),
"title": it.get("title"),
"created_at": it.get("created_at"),
"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,
"messages_count": len(data.get("messages", [])),
})
except Exception:
continue
sliced = items[offset:offset + limit]
return jsonify({"success": True, "data": sliced, "total": len(items)})
"messages_count": it.get("total_messages", 0),
}
for it in (result.get("conversations") or [])
]
return jsonify({"success": True, "data": items, "total": result.get("total", len(items))})
@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):
username = session.get("username")
ws = _resolve_workspace(username, workspace_id)
_, workspace = get_user_resources(username, workspace_id=ws.workspace_id)
if not workspace:
return jsonify({"success": False, "error": tr("api_v1.system_not_initialized")}), 503
path = _conversation_path(workspace, conv_id)
if not path.exists():
return jsonify({"success": False, "error": tr("api_v1.conversation_not_found")}), 404
# 数据通道统一走公共入口(磁盘权威快照);载荷形态保持 api_v1 既有语义
normalized = conv_id if conv_id.startswith("conv_") else f"conv_{conv_id}"
principal = TrustedPrincipal(
username=str(username or ""), workspace_id=ws.workspace_id, role="api", is_api_user=True
)
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"
if not include_messages:
data["messages"] = None
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"])

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_runtime_api import workflow_runtime_bp
from server.conversation_bootstrap import conversation_bootstrap_bp
from server.gateway_api import gateway_bp
from server.socket_handlers import socketio
from server.security import attach_security_hooks
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_runtime_bp)
app.register_blueprint(conversation_bootstrap_bp)
app.register_blueprint(gateway_bp)
# 安全钩子CSRF 校验 + 响应头)
attach_security_hooks(app)

View File

@ -33,7 +33,8 @@ from modules.user_manager import UserWorkspace
from core.web_terminal import WebTerminal
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.security import rate_limited, prune_socket_tokens
from server.utils_common import debug_log
@ -46,7 +47,7 @@ from modules.i18n import tr
UPLOAD_FOLDER_NAME = ".astrion/user_upload"
@chat_bp.route('/api/user-questions/pending', methods=['GET'])
@api_login_required
@api_login_or_host_token_required
@with_terminal
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'])
@api_login_required
@api_login_or_host_token_required
@with_terminal
@rate_limited("user_question_answer", 120, 60, scope="user")
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'])
@api_login_required
@api_login_or_host_token_required
@with_terminal
def list_pending_plan_approvals(terminal: WebTerminal, workspace: UserWorkspace, username: str):
"""获取当前用户待批准的计划列表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'])
@api_login_required
@api_login_or_host_token_required
@with_terminal
@rate_limited("plan_approval_answer", 120, 60, scope="user")
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'])
@api_login_required
@api_login_or_host_token_required
@with_terminal
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'])
@api_login_required
@api_login_or_host_token_required
@with_terminal
@rate_limited("tool_approval_decision", 60, 60, scope="user")
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 typing import Dict, Any, Optional, List, Tuple
from flask import Blueprint, request, jsonify, session
from werkzeug.utils import secure_filename
from flask import Blueprint
from config import (
OUTPUT_FORMATS,

View File

@ -10,6 +10,7 @@ from typing import Optional, Dict, Any, List
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 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 .security import compact_web_search_result
from .chat_flow_helpers import detect_tool_failure
@ -245,8 +246,14 @@ def _approval_timeout_for(web_terminal) -> Optional[float]:
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()
try:
while True:
row = tool_approval_manager.get(approval_id)
if not row:
@ -256,9 +263,18 @@ async def _wait_for_tool_approval(*, approval_id: str, username: str, timeout_se
status = row.get("status")
if status in {"approved", "rejected"}:
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:
tool_approval_manager.mark_expired(approval_id)
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)
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]]:
@ -278,10 +294,11 @@ def _safe_parse_tool_arguments_for_question(web_terminal, tool_call: Dict[str, A
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()
pending = {str(qid) for qid in question_ids if qid}
answered: Dict[str, Dict[str, Any]] = {}
try:
while pending:
for qid in list(pending):
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":
answered[qid] = {**row, "answer_text": format_user_question_answer(row)}
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:
break
if (time.time() - started) >= timeout_seconds:
for qid in list(pending):
user_question_manager.mark_expired(qid)
answered[qid] = {"status": "timeout", "answer_text": tr("tool_loop.question_timeout")}
pending.remove(qid)
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)
except asyncio.CancelledError:
for qid in list(pending):
user_question_manager.mark_expired(qid)
raise
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 同构)。"""
started = time.time()
try:
while True:
row = plan_approval_manager.get(approval_id)
if not row:
return {"status": "missing"}
if row.get("username") != username:
return {"status": "forbidden"}
if row.get("status") in {"approved", "rejected"}:
if row.get("status") in {"approved", "rejected", "expired"}:
return row
if (time.time() - started) >= timeout_seconds:
plan_approval_manager.mark_expired(approval_id)
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)
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:
@ -447,11 +486,12 @@ async def _handle_submit_plan(*, web_terminal, arguments: Dict[str, Any], sender
'conversation_id': conversation_id,
})
# 4. 阻塞等待用户决定
# 4. 阻塞等待用户决定(软停止/取消时经 stop_check 退出并回写终态)
resolved = await _wait_for_plan_approval(
approval_id=str(approval.get("approval_id") or ""),
username=username,
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 "")
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],
username=username,
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:
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"),
username=username,
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', {
'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"),
username=username,
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', {
'approval_id': approval_item.get("approval_id"),

View File

@ -1,28 +1,46 @@
# server/context/__init__.py - 兼容入口(原 server/context.py 拆分为子包)
# 所有历史导入路径 `from server.context import X` 不变。
from server.context.identity import NoWorkspaceError, RuntimeIdentity, _resolve_user_role
from server.context.broadcast import (
make_terminal_callback,
attach_user_broadcast,
_wrap_callback_with_conversation_id,
)
from server.context.personalization import _apply_workspace_personalization_preferences
from server.context.usage import get_or_create_usage_tracker, emit_user_quota_update
from server.context.upload import get_gui_manager, get_upload_guard, build_upload_error_response
from server.context.conversation import ensure_conversation_loaded, apply_conversation_overrides
from server.context.resources import (
get_user_resources,
_make_terminal_key,
_touch_terminal_activity,
_set_terminal_workspace_label,
_ensure_workspace_skills_synced,
)
from server.context.decorators import with_terminal, get_terminal_for_sid
from server.context.reaper import (
reset_system_state,
reap_idle_conversation_terminals,
start_conversation_terminal_reaper,
)
#
# 2026-09-08G4 拆解):改为 PEP 562 模块级 __getattr__ 懒加载——
# 符号首次被引用时才加载对应子模块避免任务核心层server.tasks
# import 单个符号就连带拉起全部子模块(含 Web 适配层的 flask 依赖链)。
from importlib import import_module as _import_module
_SYMBOL_MODULE = {
# identity
"NoWorkspaceError": "server.context.identity",
"RuntimeIdentity": "server.context.identity",
"_resolve_user_role": "server.context.identity",
# broadcast
"make_terminal_callback": "server.context.broadcast",
"attach_user_broadcast": "server.context.broadcast",
"_wrap_callback_with_conversation_id": "server.context.broadcast",
# personalization
"_apply_workspace_personalization_preferences": "server.context.personalization",
# usage
"get_or_create_usage_tracker": "server.context.usage",
"emit_user_quota_update": "server.context.usage",
# upload
"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__ = [
"NoWorkspaceError",
@ -43,3 +61,16 @@ __all__ = [
"reap_idle_conversation_terminals",
"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 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 modules.i18n import tr
@ -31,8 +31,8 @@ def ensure_conversation_loaded(
raise RuntimeError(result.get("message", tr("context.create_conversation_failed")))
conversation_id = result["conversation_id"]
if update_session and has_request_context():
session['run_mode'] = terminal.run_mode
session['thinking_mode'] = terminal.thinking_mode
session_set('run_mode', terminal.run_mode)
session_set('thinking_mode', terminal.thinking_mode)
created_new = True
else:
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):
pass
if update_session and has_request_context():
session['run_mode'] = terminal.run_mode
session['thinking_mode'] = terminal.thinking_mode
session['model_key'] = getattr(terminal, "model_key", None)
session_set('run_mode', terminal.run_mode)
session_set('thinking_mode', terminal.thinking_mode)
session_set('model_key', getattr(terminal, "model_key", None))
except Exception:
pass
if workspace is not None:

View File

@ -4,7 +4,9 @@ from __future__ import annotations
from dataclasses import dataclass
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):
@ -38,4 +40,7 @@ def _resolve_user_role(identity: Optional[RuntimeIdentity], record, default: str
if identity.role:
return identity.role
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)

View File

@ -3,7 +3,7 @@ from __future__ import annotations
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 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():
resolved_session_model = session_model.strip()
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():
resolved_session_model = raw_session_model.strip()
@ -84,8 +84,8 @@ def _apply_workspace_personalization_preferences(
except Exception:
pass
if allow_session_io and has_request_context() and update_session:
session["run_mode"] = getattr(terminal, "run_mode", session.get("run_mode"))
session["thinking_mode"] = getattr(terminal, "thinking_mode", session.get("thinking_mode"))
session["model_key"] = getattr(terminal, "model_key", session.get("model_key"))
session_set("run_mode", getattr(terminal, "run_mode", session_get("run_mode")))
session_set("thinking_mode", getattr(terminal, "thinking_mode", session_get("thinking_mode")))
session_set("model_key", getattr(terminal, "model_key", session_get("model_key")))
except Exception as exc:
debug_log(f"[Personalization] 应用工作区偏好失败: {exc}")

View File

@ -12,7 +12,7 @@ from typing import Optional, Tuple, TYPE_CHECKING
if TYPE_CHECKING:
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 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 server import state
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
# 兼容模式(未传 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.broadcast import make_terminal_callback, attach_user_broadcast
from server.context.personalization import _apply_workspace_personalization_preferences
@ -133,7 +156,7 @@ def get_user_resources(
读取 session 并回写
"""
from modules.user_manager import UserWorkspace
username = (username or get_current_username())
username = (username or _get_current_username())
if not username:
return None, None
@ -146,7 +169,7 @@ def get_user_resources(
if explicit:
host_mode_session = identity.host_mode
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"
if host_mode_session and sandbox_is_host:
# 宿主机多工作区并行:资源选择必须优先由显式 workspace_id / 当前请求 session 决定,
@ -157,8 +180,8 @@ def get_user_resources(
selected_workspace_id = identity.host_workspace_id
else:
selected_workspace_id = (
(session.get("host_workspace_id") if has_request_context() else None)
or (session.get("workspace_id") if has_request_context() else None)
session_get("host_workspace_id")
or session_get("workspace_id")
)
with state.HOST_ACTIVE_WORKSPACE_LOCK:
active_workspace_id = state.HOST_ACTIVE_WORKSPACE_ID
@ -283,8 +306,8 @@ def get_user_resources(
run_mode = identity.preferred_run_mode
thinking_mode_flag = identity.preferred_thinking_mode
else:
run_mode = session.get('run_mode') if has_request_context() else None
thinking_mode_flag = session.get('thinking_mode') if has_request_context() else None
run_mode = session_get('run_mode')
thinking_mode_flag = session_get('thinking_mode')
if run_mode not in {"fast", "thinking", "deep"}:
run_mode = "fast"
thinking_mode_flag = False
@ -306,18 +329,18 @@ def get_user_resources(
terminal.user_role = "admin"
terminal.quota_update_callback = None
if can_write_session:
session['run_mode'] = terminal.run_mode
session['thinking_mode'] = terminal.thinking_mode
session['workspace_id'] = getattr(workspace, "workspace_id", None)
session['host_workspace_id'] = getattr(workspace, "workspace_id", None)
session_set('run_mode', terminal.run_mode)
session_set('thinking_mode', terminal.thinking_mode)
session_set('workspace_id', getattr(workspace, "workspace_id", None))
session_set('host_workspace_id', getattr(workspace, "workspace_id", None))
else:
terminal.update_container_session(container_handle)
attach_user_broadcast(terminal, "host")
terminal.username = "host"
terminal.user_role = "admin"
if can_write_session:
session['workspace_id'] = getattr(workspace, "workspace_id", None)
session['host_workspace_id'] = getattr(workspace, "workspace_id", None)
session_set('workspace_id', getattr(workspace, "workspace_id", None))
session_set('host_workspace_id', getattr(workspace, "workspace_id", None))
_set_terminal_workspace_label(
terminal,
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 modules import admin_policy_manager
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")
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")
invite_code = getattr(record, "invite_code", None) if record else None
policy = admin_policy_manager.get_effective_policy(
record.username if record else username,
@ -356,7 +379,7 @@ def get_user_resources(
try:
terminal.set_model(candidate)
if can_write_session:
session["model_key"] = terminal.model_key
session_set("model_key", terminal.model_key)
break
except Exception:
continue
@ -383,7 +406,7 @@ def get_user_resources(
)
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
if is_api_user:
record = None
@ -391,13 +414,13 @@ def get_user_resources(
raise RuntimeError(tr("context.missing_workspace_id"))
workspace = state.api_user_manager.ensure_workspace(username, workspace_id)
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:
selected_workspace_id = workspace_id or "default"
else:
selected_workspace_id = (
workspace_id
or (session.get("workspace_id") if has_request_context() else None)
or session_get("workspace_id")
or "default"
)
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
thinking_mode_flag = identity.preferred_thinking_mode
else:
run_mode = session.get('run_mode') if has_request_context() else None
thinking_mode_flag = session.get('thinking_mode') if has_request_context() else None
run_mode = session_get('run_mode')
thinking_mode_flag = session_get('thinking_mode')
if run_mode not in {"fast", "thinking", "deep"}:
preferred_run_mode = None
try:
@ -460,10 +483,10 @@ def get_user_resources(
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
if can_write_session:
session['run_mode'] = terminal.run_mode
session['thinking_mode'] = terminal.thinking_mode
session['model_key'] = getattr(terminal, "model_key", None)
session['workspace_id'] = getattr(workspace, "workspace_id", None)
session_set('run_mode', terminal.run_mode)
session_set('thinking_mode', terminal.thinking_mode)
session_set('model_key', getattr(terminal, "model_key", None))
session_set('workspace_id', getattr(workspace, "workspace_id", None))
else:
terminal.update_container_session(container_handle)
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.quota_update_callback = (lambda metric=None: emit_user_quota_update(username)) if not is_api_user else None
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:
workspace_label = workspace_id_value
@ -516,7 +539,7 @@ def get_user_resources(
try:
terminal.set_model(candidate)
if can_write_session:
session["model_key"] = terminal.model_key
session_set("model_key", terminal.model_key)
break
except Exception:
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)
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
def _resources_for_query(username: str, workspace_id: str, principal: Optional[TrustedPrincipal]):
"""查询类调用的资源装配(工作区级 terminal 即可,不加载会话到内存)。"""

View File

@ -14,7 +14,8 @@ from typing import Dict, Any, Optional, List
from flask import Blueprint, request, jsonify
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.security import rate_limited
from server.state import stop_flags
@ -31,7 +32,7 @@ from modules.i18n import tr
@tasks_bp.route("/api/tasks", methods=["GET"])
@api_login_required
@api_login_or_host_token_required
def list_tasks_api():
username = get_current_username()
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"])
@api_login_required
@api_login_or_host_token_required
def get_conversation_running_status_api(conversation_id: str):
"""REST 对账接口:聚合某对话的完整运行状态。
@ -93,7 +94,7 @@ def get_conversation_running_status_api(conversation_id: str):
})
@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")
def create_task_api():
username = get_current_username()
@ -201,7 +202,7 @@ def create_task_api():
}), 202
@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):
started_at = time.time()
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"])
@api_login_required
@api_login_or_host_token_required
def cancel_task_api(task_id: str):
username = get_current_username()
rec = runtime_service.get_task(username, task_id)
@ -280,7 +281,7 @@ def cancel_task_api(task_id: str):
return jsonify({"success": True})
@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):
username = get_current_username()
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"])
@api_login_required
@api_login_or_host_token_required
def enqueue_runtime_queue_message_api(task_id: str):
username = get_current_username()
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"])
@api_login_required
@api_login_or_host_token_required
def delete_runtime_queue_message_api(task_id: str, message_id: str):
username = get_current_username()
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"])
@api_login_required
@api_login_or_host_token_required
def guide_runtime_queue_message_api(task_id: str, message_id: str):
username = get_current_username()
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["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)
terminal.set_model.assert_called_once_with("session-model")
@ -74,7 +77,8 @@ class TestApplyWorkspacePersonalizationPreferences(unittest.TestCase):
workspace = self._make_workspace()
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)
terminal.set_model.assert_not_called()

View File

@ -153,8 +153,8 @@ class GetUserResourcesHostPolicyTest(unittest.TestCase):
try:
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.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_record", return_value=record), \
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:
from flask import Flask
app = Flask("host_policy_test")

View File

@ -241,7 +241,9 @@ class CrudMixin:
"total_messages": metadata.total_messages,
"total_tools": metadata.total_tools,
"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)

View File

@ -182,6 +182,8 @@ class IndexMixin:
"total_tools": metadata.get("total_tools", 0),
"status": metadata.get("status", "active"),
"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
if perf_log:

View File

@ -124,6 +124,10 @@ class ListSearchMixin:
"project_path": metadata.get("project_path"),
"project_relative_path": metadata.get("project_relative_path"),
"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_tools": metadata.get("total_tools", 0),
"status": metadata.get("status", "active"),