diff --git a/cache_research/gateway/audit_client_layer_20260908/client_gateway_audit.md b/cache_research/gateway/audit_client_layer_20260908/client_gateway_audit.md new file mode 100644 index 00000000..18a09f48 --- /dev/null +++ b/cache_research/gateway/audit_client_layer_20260908/client_gateway_audit.md @@ -0,0 +1,129 @@ +# Client ↔ Gateway 链路审核报告(前端/CLI 消费面) + +**审核日期**:2026-09-08 +**审核对象**:``(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 派生的后台工作者」一致,不算漏网。 + +### 声明 B:server/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.* 已供 HTTP,session.list/history 无传输暴露)。** + +**证据:** +- `run.list`:HTTP 暴露 `/api/tasks` GET(`server/tasks/api.py:33-44` → `runtime_service.list_runs`)。 +- `run.get`:HTTP 暴露 `/api/tasks/` GET(`server/tasks/api.py:205` → `runtime_service.get_task`)。 +- `run.events`:HTTP 暴露 `/api/tasks/?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//conversations` 直接 `conv_dir.glob("conv_*.json")`;`:385` `GET .../` 直接 `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/?from=`(`: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 })` + toast(i18n `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)。** + +### 声明 E:CLI 当前是「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//conversations`、`GET .../` 用 `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//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//decision` | approval.resolve 的 plan(submit_plan)/question(ask_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.list(Run 发现)消费** | `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)的注册细节未逐一核对,仅核对路由定义文件内部映射。 \ No newline at end of file diff --git a/cache_research/gateway/audit_runtime_exec_20260908/runtime_execution_audit.md b/cache_research/gateway/audit_runtime_exec_20260908/runtime_execution_audit.md new file mode 100644 index 00000000..049c2c1a --- /dev/null +++ b/cache_research/gateway/audit_runtime_exec_20260908/runtime_execution_audit.md @@ -0,0 +1,335 @@ +# Runtime ↔ Execution Plane 链路审核报告 + +- 审核日期:2026-09-08 +- 审核方式:只读(grep/符号定位 + 精确行段阅读 + 隔离 import 实验 + 子进程验收测试运行观察) +- 审核对象:Gateway(server/runtime/)→ Agent Runtime(server/chat_flow*.py、core/main_terminal.py)→ Execution Plane(core/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` docstring:L8-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` dataclass(host_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)**:四次检查全部 PASSED(lifecycle 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-1683):backend → `backend.edit_file(path, replacements)`;else → `_track_shallow_versioning` + `self.file_manager.replace_many_in_file`。 + - **run_command 后台**(L1923-1932):backend → `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-1966):backend → `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_command(terminal_ops/run.py)、E2 run_command_background(background_command_manager.py)、E3 write_file(file_manager/crud_mixin.py)、E4 edit_file(file_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 处各有一份复制**(契约 §5),E5-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` 顶层 import(L3-72):全为 modules/*、utils/*、config/model_profiles、core.main_terminal_parts、modules.i18n——**无 flask/socketio**。 +- `core/main_terminal_parts/tools_execution.py`:顶层无 flask/socketio(grep 零命中);`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 实际分层图(按代码事实) + +``` +┌──────────────────────────────────────────────────────────────────────┐ +│ ① Client(Web 前端 / CLI / 未来定时触发器) │ +└───────────────┬──────────────────────────────────────────────────────┘ + │ HTTP / 轮询事件流(idx/offset 协议) +┌───────────────▼──────────────────────────────────────────────────────┐ +│ ② Gateway(server/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 Runtime(server/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_backend:None → 原路径; + │ 注入 → 替身/未来远端后端);权限裁决【编排层】完成, + │ 传裁决结论(sandbox_write_access/network_permission) +┌───────────────▼──────────────────────────────────────────────────────┐ +│ ④ Execution Plane(modules/execution_plane/) │ +│ ExecutionBackend 协议 E1-E4(Protocol, 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()` 仍 False,socketio.server=None 静默跳过) | models.py:958-959;chat_flow.py:19,73;extensions.py:4-7;venv import 实验3 | +| F3 | 低 | TrustedPrincipal「只能由适配层构造」 | **约定 + 校验侧纵深防御,非结构强制**:dataclass 无构造限制、validate() 不校验 role 白名单;任何代码可直接构造 admin principal | context.py:20-41 validate L30-34;service.py `_resources_for_query` L269-283 仅约束查询侧 | +| F4 | 低 | 「拆除了 session_data 兼容桥」的表述 | 属实 ✅;仅 i18n key `tasks.missing_session_data` 命名保留旧词(错误文案已更新为「缺少任务运行上下文,请通过公共任务入口提交」),语义无残留 | models.py:148;i18n api_tasks.py:105 | +| F5 | 低 | execution_contract.md §4「权限裁决在 Runtime 编排层」 | 属实但要精确:**编排层做①permission mode 裁决②审批一次性授权③网络权限解析**,但**命令校验(FORBIDDEN_COMMANDS)与路径授权(禁读清单)仍在旧 terminal_ops/file_manager 链路**,经本接口注入的后端分支不会自动获得这些校验(契约已加注记,与代码一致) | base.py docstring L9-11;execution_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_manager,Host/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) | OK;flask=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 | 执行中审批等待 → 公共入口批准 → 替身收到命令 → 循环退出 | \ No newline at end of file diff --git a/cache_research/gateway/gateway_current_state.md b/cache_research/gateway/gateway_current_state.md index 2725d666..571820ec 100644 --- a/cache_research/gateway/gateway_current_state.md +++ b/cache_research/gateway/gateway_current_state.md @@ -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-08:Web 检测后发 `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 / history(principal 用户+工作区双校验,F3) | ✅ | ⬜(Web 会话路由未动——属 conversation 域存量链路) | ✅ 含越权/跨工作区拒绝断言 | +> | session.list / history(principal 用户+工作区双校验,F3) | ✅ | ✅(2026-09-08:新增 server/gateway_api.py `GET/POST /api/runtime/sessions` + `GET .../history` 传输暴露;api_v1 两会话路由转调公共入口消双轨,载荷字段经索引补字段保持兼容) | ✅ 含越权/跨工作区拒绝断言 | +> | 通道认证(host Bearer token) | ✅(2026-09-08:server/gateway_auth.py;host 模式+回环限定,token 存 `/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_set,flask 缺失按无上下文处理);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_flow(venv 验证)。**剩余形态**:任务执行阶段(_run_chat_task 线程内延迟导入 chat_flow → security/extensions)仍拉起 flask+flask_socketio(SocketIO 空壳实例不 init_app),执行层彻底脱 flask 属更大工程,本轮未动。 +> - **审批链路三缺口收口**:三个 manager 新增 `mark_expired`(幂等)+ 终态 TTL 惰性清理(RESOLVED_TTL_SECONDS=3600,pending 永不自动清理);三个 _wait_* 等待函数超时/软停止(stop_check 注入)/协程取消(CancelledError)三路径均回写 expired 终态;软停止打断对齐 REST 硬取消。i18n 新增 approval_stopped/approval_expired/question_stopped/question_expired 四键。 +> - **测试 patch 点随迁**:test_conversation_model_persistence(session→session_get/session_set)、test_runtime_identity_resources(get_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 项存量。 diff --git a/modules/i18n_messages/chat_flow.py b/modules/i18n_messages/chat_flow.py index f29bb407..df20fa15 100644 --- a/modules/i18n_messages/chat_flow.py +++ b/modules/i18n_messages/chat_flow.py @@ -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", diff --git a/modules/plan_approval_manager.py b/modules/plan_approval_manager.py index f49dc952..3f5c1b16 100644 --- a/modules/plan_approval_manager.py +++ b/modules/plan_approval_manager.py @@ -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: diff --git a/modules/tool_approval_manager.py b/modules/tool_approval_manager.py index 88843ef8..39842833 100644 --- a/modules/tool_approval_manager.py +++ b/modules/tool_approval_manager.py @@ -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: diff --git a/modules/user_question_manager.py b/modules/user_question_manager.py index 0cf59939..a340f8ab 100644 --- a/modules/user_question_manager.py +++ b/modules/user_question_manager.py @@ -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: diff --git a/server/api_v1.py b/server/api_v1.py index 9bd68c47..dd616be4 100644 --- a/server/api_v1.py +++ b/server/api_v1.py @@ -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): - 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"), - "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)}) + # 数据通道统一走公共入口(消除直读磁盘双轨);载荷字段保持 api_v1 既有形态 + principal = TrustedPrincipal( + username=str(username or ""), workspace_id=ws.workspace_id, role="api", is_api_user=True + ) + try: + 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": 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//conversations/", 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")) - 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}) + 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}) @api_v1_bp.route("/workspaces//conversations/", methods=["DELETE"]) diff --git a/server/app_legacy.py b/server/app_legacy.py index 19598dd3..691fc51a 100644 --- a/server/app_legacy.py +++ b/server/app_legacy.py @@ -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) diff --git a/server/chat/approval.py b/server/chat/approval.py index 130f38d3..4f9d9f13 100644 --- a/server/chat/approval.py +++ b/server/chat/approval.py @@ -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//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//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//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): diff --git a/server/chat_flow.py b/server/chat_flow.py index 81a855d0..be26cf63 100644 --- a/server/chat_flow.py +++ b/server/chat_flow.py @@ -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, diff --git a/server/chat_flow_tool_loop.py b/server/chat_flow_tool_loop.py index 4735da08..a2393abe 100644 --- a/server/chat_flow_tool_loop.py +++ b/server/chat_flow_tool_loop.py @@ -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,20 +246,35 @@ 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() - while True: - row = tool_approval_manager.get(approval_id) - if not row: - return {"decision": "rejected", "code": "approval_missing", "reason": tr("tool_loop.approval_missing")} - if row.get("username") != username: - return {"decision": "rejected", "code": "approval_user_mismatch", "reason": tr("tool_loop.approval_user_mismatch")} - status = row.get("status") - if status in {"approved", "rejected"}: - return {"decision": status, "item": row} - if (time.time() - started) >= timeout_seconds: - return {"decision": "rejected", "code": "approval_timeout", "reason": tr("tool_loop.approval_timeout")} - await asyncio.sleep(0.2) + try: + while True: + row = tool_approval_manager.get(approval_id) + if not row: + return {"decision": "rejected", "code": "approval_missing", "reason": tr("tool_loop.approval_missing")} + if row.get("username") != username: + return {"decision": "rejected", "code": "approval_user_mismatch", "reason": tr("tool_loop.approval_user_mismatch")} + 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,49 +294,72 @@ 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]] = {} - while pending: - for qid in list(pending): - row = user_question_manager.get(qid) - if not row: - answered[qid] = {"status": "missing", "answer_text": tr("tool_loop.question_missing")} - pending.remove(qid) - continue - if row.get("username") != username: - answered[qid] = {"status": "forbidden", "answer_text": tr("tool_loop.question_user_mismatch")} - pending.remove(qid) - continue - if row.get("status") == "answered": - answered[qid] = {**row, "answer_text": format_user_question_answer(row)} - pending.remove(qid) - if not pending: - break - if (time.time() - started) >= timeout_seconds: + try: + while pending: for qid in list(pending): - answered[qid] = {"status": "timeout", "answer_text": tr("tool_loop.question_timeout")} - pending.remove(qid) - break - await asyncio.sleep(0.2) + row = user_question_manager.get(qid) + if not row: + answered[qid] = {"status": "missing", "answer_text": tr("tool_loop.question_missing")} + pending.remove(qid) + continue + if row.get("username") != username: + answered[qid] = {"status": "forbidden", "answer_text": tr("tool_loop.question_user_mismatch")} + pending.remove(qid) + continue + 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() - 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"}: - return row - if (time.time() - started) >= timeout_seconds: - return {"status": "timeout"} - await asyncio.sleep(0.3) + 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", "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"), diff --git a/server/context/__init__.py b/server/context/__init__.py index c2f95abb..5216cb1c 100644 --- a/server/context/__init__.py +++ b/server/context/__init__.py @@ -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-08(G4 拆解):改为 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))) diff --git a/server/context/_flask_bridge.py b/server/context/_flask_bridge.py new file mode 100644 index 00000000..7b8b69a7 --- /dev/null +++ b/server/context/_flask_bridge.py @@ -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 diff --git a/server/context/conversation.py b/server/context/conversation.py index 5748df29..40014ce7 100644 --- a/server/context/conversation.py +++ b/server/context/conversation.py @@ -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: diff --git a/server/context/identity.py b/server/context/identity.py index 027df14e..431767f0 100644 --- a/server/context/identity.py +++ b/server/context/identity.py @@ -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) diff --git a/server/context/personalization.py b/server/context/personalization.py index 193ce90b..404f6803 100644 --- a/server/context/personalization.py +++ b/server/context/personalization.py @@ -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}") diff --git a/server/context/resources.py b/server/context/resources.py index 870a85aa..36aa3d2a 100644 --- a/server/context/resources.py +++ b/server/context/resources.py @@ -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 diff --git a/server/gateway_api.py b/server/gateway_api.py new file mode 100644 index 00000000..cee88bec --- /dev/null +++ b/server/gateway_api.py @@ -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//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//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"] diff --git a/server/gateway_auth.py b/server/gateway_auth.py new file mode 100644 index 00000000..38d81a03 --- /dev/null +++ b/server/gateway_auth.py @@ -0,0 +1,142 @@ +"""Gateway 本机客户端通道认证(host Bearer token,2026-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 为本机生成的随机串,存于运行态数据目录 + (/host_api_token,权限 0600),本机客户端读取后经 + `Authorization: Bearer ` 携带。 +""" +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 token:host 模式 + 回环地址 + 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 请求天然跳过 CSRF(server/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", +] diff --git a/server/runtime/service.py b/server/runtime/service.py index 24bff74a..86b8759b 100644 --- a/server/runtime/service.py +++ b/server/runtime/service.py @@ -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 即可,不加载会话到内存)。""" diff --git a/server/tasks/api.py b/server/tasks/api.py index 4493029b..8d6ced67 100644 --- a/server/tasks/api.py +++ b/server/tasks/api.py @@ -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//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/", 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//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//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//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//runtime_queue/", 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//runtime_queue//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) diff --git a/test/test_conversation_model_persistence.py b/test/test_conversation_model_persistence.py index 8411077b..7586bfd9 100644 --- a/test/test_conversation_model_persistence.py +++ b/test/test_conversation_model_persistence.py @@ -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-08):session 读写经 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() diff --git a/test/test_runtime_identity_resources.py b/test/test_runtime_identity_resources.py index f3699b7f..5c7a3c02 100644 --- a/test/test_runtime_identity_resources.py +++ b/test/test_runtime_identity_resources.py @@ -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") diff --git a/utils/conversation_manager/crud_mixin.py b/utils/conversation_manager/crud_mixin.py index afeeeb07..30a5df32 100644 --- a/utils/conversation_manager/crud_mixin.py +++ b/utils/conversation_manager/crud_mixin.py @@ -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) diff --git a/utils/conversation_manager/index_mixin.py b/utils/conversation_manager/index_mixin.py index 115146e5..fc980703 100644 --- a/utils/conversation_manager/index_mixin.py +++ b/utils/conversation_manager/index_mixin.py @@ -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: diff --git a/utils/conversation_manager/list_search_mixin.py b/utils/conversation_manager/list_search_mixin.py index 7fa3cde3..dcbdac72 100644 --- a/utils/conversation_manager/list_search_mixin.py +++ b/utils/conversation_manager/list_search_mixin.py @@ -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"),