Compare commits

...
109 Commits
Author SHA1 Message Date
公明andGitHub a6b3773f00 Add files via upload 2026-07-28 18:35:32 +08:00
公明andGitHub c2b950ad53 Add files via upload 2026-07-28 18:32:15 +08:00
公明andGitHub 0283fff743 Add files via upload 2026-07-28 18:30:28 +08:00
公明andGitHub 018835d6b8 Add files via upload 2026-07-28 18:28:41 +08:00
公明andGitHub 4b7df4e0f3 Add files via upload 2026-07-28 18:26:33 +08:00
公明andGitHub 0324b41a01 Add files via upload 2026-07-28 18:25:11 +08:00
公明andGitHub 4a19620137 Add files via upload 2026-07-28 18:22:30 +08:00
公明andGitHub f8110413c0 Add files via upload 2026-07-28 18:20:17 +08:00
公明andGitHub 59dc7cf858 Add files via upload 2026-07-28 18:17:47 +08:00
公明andGitHub 52595e07e5 Add files via upload 2026-07-28 18:15:54 +08:00
公明andGitHub e0965594bb Add files via upload 2026-07-28 18:14:06 +08:00
公明andGitHub 9ef8263eaf Update config.example.yaml 2026-07-28 17:50:02 +08:00
3c54a67416 fix(project): 允许 fact_key 驼峰命名并澄清未绑定项目授权报错 (#200) (#217)
- fact_key 校验放开大写字母,支持驼峰命名(如 finding/info-disclosure-messageBundle):
  - internal/database/project.go: 正则 ^[a-z0-9]... 改为 ^[a-zA-Z0-9]...,并更新错误提示
  - internal/project/fact_body_links.go: body 解析正则(依赖事实/相关 fact_key)同步放开大写
- authorizeProjectTool 区分「未绑定项目 / 查询出错 / 真实无权限」三种情况:
  未绑定项目时返回明确可执行提示,替代误导性的 no access to project
  - internal/app/mcp_authorization.go

Co-authored-by: sycmacmini <sycmacmini@sycmacminis-Mac-mini.local>
2026-07-28 11:18:58 +08:00
公明andGitHub 98ca395edd Add files via upload 2026-07-28 11:01:17 +08:00
公明andGitHub c616822cd6 Add files via upload 2026-07-28 10:37:49 +08:00
公明andGitHub ba1796d7ce Add files via upload 2026-07-27 17:27:55 +08:00
公明andGitHub dad14c55c1 Add files via upload 2026-07-24 18:10:52 +08:00
公明andGitHub cc0233d7b4 Add files via upload 2026-07-24 17:05:44 +08:00
公明andGitHub 7b6f56e476 Add files via upload 2026-07-24 16:43:01 +08:00
公明andGitHub 2522fc6ae2 Add files via upload 2026-07-24 16:06:30 +08:00
公明andGitHub 99d7380450 Add files via upload 2026-07-24 16:03:46 +08:00
公明andGitHub 837e41459a Add files via upload 2026-07-24 15:58:55 +08:00
公明andGitHub 5cbd828cad Add files via upload 2026-07-24 15:52:16 +08:00
公明andGitHub 8cb317cbd6 Add files via upload 2026-07-24 15:50:11 +08:00
公明andGitHub 94a2ba0406 Add files via upload 2026-07-24 15:19:01 +08:00
公明andGitHub 4ee7204509 Add files via upload 2026-07-24 14:55:59 +08:00
公明andGitHub c326adbb66 Add files via upload 2026-07-24 14:53:44 +08:00
公明andGitHub 7d1e9bdac4 Add files via upload 2026-07-24 14:08:19 +08:00
公明andGitHub 151b445c74 Add files via upload 2026-07-24 11:44:35 +08:00
公明andGitHub af4b25b84e Update config.example.yaml 2026-07-23 20:20:41 +08:00
公明andGitHub f0b1955059 Add files via upload 2026-07-23 20:18:55 +08:00
公明andGitHub f5d580bbf0 Add files via upload 2026-07-23 20:17:17 +08:00
公明andGitHub 44d069da2b Add files via upload 2026-07-23 20:14:45 +08:00
公明andGitHub 9297e6e6ee Add files via upload 2026-07-23 20:12:31 +08:00
公明andGitHub 9bbc28c14a Add files via upload 2026-07-23 20:10:57 +08:00
公明andGitHub 4943b9419e Add files via upload 2026-07-23 19:52:14 +08:00
公明andGitHub 446ccd3edb Add files via upload 2026-07-23 19:40:24 +08:00
公明andGitHub a00643b9c0 Add files via upload 2026-07-23 19:39:28 +08:00
公明andGitHub 5c13819f66 Add files via upload 2026-07-23 19:37:45 +08:00
公明andGitHub a7190b8399 Add files via upload 2026-07-23 19:21:50 +08:00
公明andGitHub 6f0eaf7667 Update config.example.yaml 2026-07-23 19:14:49 +08:00
公明andGitHub f285d47404 Add files via upload 2026-07-23 19:14:09 +08:00
公明andGitHub 273d63413b Add files via upload 2026-07-23 19:11:29 +08:00
公明andGitHub 5656315b1e Add files via upload 2026-07-23 19:10:10 +08:00
公明andGitHub cee46f40fa Add files via upload 2026-07-23 19:07:27 +08:00
公明andGitHub 4c55784dbc Add files via upload 2026-07-23 19:04:44 +08:00
公明andGitHub d304ff5c99 Add files via upload 2026-07-23 19:02:37 +08:00
公明andGitHub 0cd4df3518 Add files via upload 2026-07-23 19:00:54 +08:00
公明andGitHub 49333b856c Add files via upload 2026-07-23 18:58:53 +08:00
公明andGitHub a83490f29c Add files via upload 2026-07-23 18:56:18 +08:00
公明andGitHub dbc4ead040 Update config.example.yaml 2026-07-22 21:09:53 +08:00
公明andGitHub 4333cf1dd3 Add files via upload 2026-07-22 21:07:21 +08:00
公明andGitHub 577c97aab0 Add files via upload 2026-07-22 21:04:18 +08:00
公明andGitHub 93ab362b6f Add files via upload 2026-07-22 21:01:56 +08:00
公明andGitHub d9cb0b11c5 Add files via upload 2026-07-22 17:34:24 +08:00
公明andGitHub 2d7f5322b3 Add files via upload 2026-07-22 16:29:24 +08:00
公明andGitHub 3763da8773 Add files via upload 2026-07-22 16:23:57 +08:00
公明andGitHub 3f2984b7c6 Add files via upload 2026-07-22 16:18:33 +08:00
公明andGitHub fbef2afd68 Add files via upload 2026-07-22 14:19:38 +08:00
公明andGitHub bccb324d2b Add files via upload 2026-07-22 14:17:23 +08:00
公明andGitHub b28fba3d68 Add files via upload 2026-07-22 14:14:42 +08:00
公明andGitHub 8059b6d5b2 Add files via upload 2026-07-22 14:12:36 +08:00
公明andGitHub 0821bb2911 Add files via upload 2026-07-22 14:10:44 +08:00
公明andGitHub e407f8203c Add files via upload 2026-07-22 13:59:37 +08:00
公明andGitHub a59253e828 Add files via upload 2026-07-22 13:51:36 +08:00
公明andGitHub 4c989fbbe0 Add files via upload 2026-07-22 13:47:24 +08:00
公明andGitHub 91239c9869 Add files via upload 2026-07-22 13:41:43 +08:00
公明andGitHub 6892156b7c Add files via upload 2026-07-22 13:36:39 +08:00
公明andGitHub 75489123f9 Add files via upload 2026-07-22 11:33:54 +08:00
公明andGitHub 631ee6c447 Add files via upload 2026-07-22 11:30:11 +08:00
公明andGitHub 7caf77683f Add files via upload 2026-07-22 11:27:34 +08:00
公明andGitHub fce4ffe3c4 Add files via upload 2026-07-22 11:11:52 +08:00
公明andGitHub 75163f9269 Add files via upload 2026-07-22 10:37:19 +08:00
公明andGitHub 06a9cea97d Add files via upload 2026-07-22 10:34:42 +08:00
公明andGitHub b62ba3b214 Add files via upload 2026-07-22 10:31:20 +08:00
公明andGitHub 0062cebc6b Update config.example.yaml 2026-07-22 00:17:45 +08:00
公明andGitHub 25ceeb2535 Add files via upload 2026-07-21 23:48:53 +08:00
公明andGitHub 0152781598 Add files via upload 2026-07-21 23:47:17 +08:00
公明andGitHub d3176f048d Add files via upload 2026-07-21 23:43:59 +08:00
公明andGitHub 1fc522298a Add files via upload 2026-07-21 23:42:04 +08:00
公明andGitHub 9464ffe105 Add files via upload 2026-07-21 23:40:49 +08:00
公明andGitHub 599de1da0f Add files via upload 2026-07-21 23:37:51 +08:00
公明andGitHub 1cb64ceaaa Add files via upload 2026-07-21 23:35:23 +08:00
公明andGitHub 97d96d4d8b Add files via upload 2026-07-21 23:32:19 +08:00
公明andGitHub 4bb12bbbdc Add files via upload 2026-07-21 23:30:20 +08:00
公明andGitHub c4a56c1ada Add files via upload 2026-07-21 21:04:53 +08:00
公明andGitHub 8bd42a6f7a Add files via upload 2026-07-21 21:02:45 +08:00
公明andGitHub b54867e383 Add files via upload 2026-07-21 21:00:22 +08:00
公明andGitHub 07229f0fde Add files via upload 2026-07-21 20:57:07 +08:00
公明andGitHub f30e8e7ca6 Add files via upload 2026-07-21 20:54:51 +08:00
公明andGitHub bfe1d28650 Add files via upload 2026-07-21 20:52:43 +08:00
公明andGitHub c925c2e74b Add files via upload 2026-07-21 20:49:10 +08:00
公明andGitHub 4bbbe3bcf8 Add files via upload 2026-07-21 20:46:54 +08:00
公明andGitHub 944957d4c5 Add files via upload 2026-07-21 20:44:32 +08:00
公明andGitHub fa9229b435 Add files via upload 2026-07-21 20:42:43 +08:00
公明andGitHub b707ea4e44 Add files via upload 2026-07-21 16:50:51 +08:00
公明andGitHub 18824857be Add files via upload 2026-07-21 16:43:14 +08:00
公明andGitHub 78c572927b Add files via upload 2026-07-21 16:38:37 +08:00
公明andGitHub cea8d4bcc0 Add files via upload 2026-07-21 16:34:42 +08:00
公明andGitHub 25fac216ea Add files via upload 2026-07-21 16:33:00 +08:00
公明andGitHub e5507ed8d0 Add files via upload 2026-07-21 16:30:07 +08:00
公明andGitHub 22cbc82ccc Add files via upload 2026-07-21 16:26:48 +08:00
公明andGitHub 462aa2fb15 Add files via upload 2026-07-21 16:22:36 +08:00
公明andGitHub b1749ace16 Add files via upload 2026-07-21 16:18:20 +08:00
公明andGitHub 5a48e2893d Add files via upload 2026-07-21 15:23:58 +08:00
公明andGitHub d7181cb1cd Add files via upload 2026-07-21 15:07:52 +08:00
公明andGitHub 2b63e3b633 Add files via upload 2026-07-21 14:15:40 +08:00
公明andGitHub 2de6073d34 Delete skills directory 2026-07-21 14:14:48 +08:00
公明andGitHub ec3f881587 Add files via upload 2026-07-21 11:10:20 +08:00
174 changed files with 19406 additions and 8704 deletions
+26 -14
View File
@@ -118,6 +118,7 @@ CyberStrikeAI connects planning, execution, human oversight, evidence, and repla
- 🧰 **Security tools** include 100+ curated YAML recipes with custom extensions and role-scoped access.
- 🔌 **MCP integration** supports HTTP, stdio, SSE, external federation, and dynamic tool discovery.
- ⏱️ **Resilient tool execution** runs blocking MCP/tool calls in workers with bounded agent waits, resumable `execution_id` polling, cancellation, per-server circuit breakers, concurrency limits, and unified output caps.
- 🎯 **Agent Skills** follow the standard Skill layout and support progressive, on-demand loading.
- 📚 **Knowledge base** combines query rewriting, vector retrieval, reranking, and result post-processing.
- 🖼️ **Vision analysis** uses a separate vision model for screenshots, captchas, and UI while retaining text summaries only.
@@ -127,7 +128,7 @@ CyberStrikeAI connects planning, execution, human oversight, evidence, and repla
- 🧑‍⚖️ **Human in the loop** provides approval modes, tool allowlists, audit-agent review, and traceable decisions.
- 🔐 **Platform RBAC** supports multiple users, system and custom roles, scoped permissions, ownership, and explicit assignments.
- 🔒 **Security and audit** provide authenticated access, audit logs, SQLite persistence, and operational evidence retention.
- 📄 **Result governance** supports pagination, compression, archival, and search for large tool outputs.
- 📄 **Result governance** stores the same capped tool result seen by the agent, protects resume paths from oversized historical output, and adds UI safeguards for large detail views. See [Tool Execution Governance](docs/en-US/tool-execution-governance.md).
### Security operations
@@ -217,16 +218,23 @@ The `run.sh` script will automatically:
**Networking defaults:** `run.sh` starts the server with **`--https`** and the repo **`config.yaml`** (local self-signed TLS; better for many concurrent streams). Use **`./run.sh --http`** for plain HTTP. In production, set **`server.tls_cert_path`** / **`server.tls_key_path`** in **`config.yaml`** (see comments there). For manual runs, add **`--https`** or **`CYBERSTRIKE_HTTPS=1`**; if **`-config`** is wrong, the binary prints a short usage hint on stderr.
**First-Time Configuration:**
1. **Configure OpenAI-compatible API** (required before first use)
1. **Configure AI channels** (required before first use)
- After launch, open **`https://127.0.0.1:8080/`** (or **`https://localhost:8080/`**; replace **8080** with `server.port` in `config.yaml`) and accept the self-signed certificate warning once. If you used `./run.sh --http`, use **`http://`** instead.
- Go to `Settings`Fill in your API credentials:
- Go to `System Settings``Basic Settings``AI Channel Configuration`, add or edit a channel, then fill in provider, Base URL, API key, model, and token limits. Click **Save changes**. The left channel list supports setting a default, copy, delete, and bulk probe.
```yaml
openai:
api_key: "${OPENAI_API_KEY}"
base_url: "https://api.openai.com/v1" # or https://api.deepseek.com/v1
model: "gpt-4o" # or deepseek-chat, claude-3-opus, etc.
ai:
default_channel: openai-main
channels:
openai-main:
name: OpenAI Main
provider: openai_compatible
api_key: "${OPENAI_API_KEY}"
base_url: "https://api.openai.com/v1" # or https://api.deepseek.com/v1
model: "gpt-4o" # or deepseek-chat, qwen3-max, etc.
max_total_tokens: 120000
max_completion_tokens: 16384
```
- Or edit `config.yaml` directly before launching
- Or edit `config.yaml` directly before launching. `ai.default_channel` is used for new conversations and tasks that do not explicitly select a channel; the chat page can also select any saved channel per session.
2. **Login** - On first startup the console prints an auto-generated initial `admin` password; create accounts from **Platform permissions → User management**
3. **Install security tools (optional)** - Install tools from `tools/` as needed; missing tools are skipped or substituted at runtime. Common examples:
@@ -280,19 +288,23 @@ Requirements / tips:
## Configuration
Use [`config.example.yaml`](config.example.yaml) as the authoritative configuration template and copy only the values required for your environment. At minimum, configure the server and an OpenAI-compatible model provider:
Use [`config.example.yaml`](config.example.yaml) as the authoritative configuration template and copy only the values required for your environment. At minimum, configure the server and one AI channel:
```yaml
server:
host: "127.0.0.1"
port: 8080
openai:
api_key: "${OPENAI_API_KEY}"
base_url: "https://api.openai.com/v1"
model: "your-model"
ai:
default_channel: openai-main
channels:
openai-main:
provider: openai_compatible
api_key: "${OPENAI_API_KEY}"
base_url: "https://api.openai.com/v1"
model: "your-model"
```
Do not commit real credentials. Review the [configuration reference](docs/en-US/configuration.md), [recommended profiles](docs/en-US/configuration-profiles.md), and [security hardening guide](docs/en-US/security-hardening.md) before exposing the service beyond localhost.
`openai` is a backward-compatible runtime field; maintain new model settings in `ai.channels`. Do not commit real credentials. Review the [configuration reference](docs/en-US/configuration.md), [recommended profiles](docs/en-US/configuration-profiles.md), and [security hardening guide](docs/en-US/security-hardening.md) before exposing the service beyond localhost.
## Related documentation
+26 -14
View File
@@ -117,6 +117,7 @@ CyberStrikeAI 将规划、执行、人工监督、证据与复盘连接在同一
- 🧰 **安全工具**:提供 100+ 精选 YAML 工具配方,支持自定义扩展和按角色控制。
- 🔌 **MCP 集成**:支持 HTTP、stdio、SSE、外部 MCP 联邦和动态工具发现。
- ⏱️ **弹性工具执行**:阻塞型 MCP/工具调用交给 worker 执行,Agent 只有限等待;支持 `execution_id` 多轮等待、主动取消、单 server 熔断、并发限制和统一输出兜底。
- 🎯 **Agent Skills**:遵循标准 Skill 目录结构,支持渐进式按需加载。
- 📚 **知识库**:组合查询改写、向量检索、精排和结果后处理能力。
- 🖼️ **视觉分析**:使用独立视觉模型分析截图、验证码和 UI,对话中仅保留文字摘要。
@@ -126,7 +127,7 @@ CyberStrikeAI 将规划、执行、人工监督、证据与复盘连接在同一
- 🧑‍⚖️ **人机协同**:支持审批模式、工具白名单、审计 Agent 复核和决策追踪。
- 🔐 **平台 RBAC**:支持多用户、系统及自定义角色、权限 Scope、资源归属和显式授权。
- 🔒 **安全与审计**:提供登录保护、审计日志、SQLite 持久化和行动证据留存。
- 📄 **结果治理**支持大结果分页、压缩、归档和检索
- 📄 **结果治理**数据库保存与 Agent 实际看到的同一份兜底后工具结果,恢复路径会再次防御历史超大输出,前端详情也有展示保护。详见[工具执行治理](docs/zh-CN/tool-execution-governance.md)
### 安全运营管理
@@ -216,16 +217,23 @@ chmod +x run.sh && ./run.sh
**网络默认:** `run.sh` 会以 **`--https`** 并传入项目根 **`config.yaml`** 启动(本机自签证书,多路流式场景更稳)。只要明文 HTTP 用 **`./run.sh --http`**。生产环境在 **`config.yaml`** 的 **`server.tls_cert_path` / `server.tls_key_path`** 配正式证书(见文件内注释)。手动启动可加 **`--https`** 或环境变量 **`CYBERSTRIKE_HTTPS=1`**`-config` 写错时程序会在终端提示正确写法。
**首次配置:**
1. **配置 AI 模型 API**(首次使用前必填)
1. **配置 AI 通道**(首次使用前必填)
- 启动后在浏览器打开 **`https://127.0.0.1:8080/`**(或 **`https://localhost:8080/`**;端口以 `config.yaml`**`server.port`** 为准,默认 8080),并按提示信任自签证书。若使用 **`./run.sh --http`**,则改用 **`http://`** 访问。
- 进入 `设置`填写 API 配置信息:
- 进入 `系统设置``基本设置``AI 通道配置`,新增或编辑通道,填写 API 提供商、Base URL、API Key、模型和 Token 上限,点击 **保存更改**。左侧通道列表支持设为默认、复制、删除和批量探活。
```yaml
openai:
api_key: "${OPENAI_API_KEY}"
base_url: "https://api.openai.com/v1" # 或 https://api.deepseek.com/v1
model: "gpt-4o" # 或 deepseek-chat, claude-3-opus 等
ai:
default_channel: openai-main
channels:
openai-main:
name: OpenAI Main
provider: openai_compatible
api_key: "${OPENAI_API_KEY}"
base_url: "https://api.openai.com/v1" # 或 https://api.deepseek.com/v1
model: "gpt-4o" # 或 deepseek-chat, qwen3-max 等
max_total_tokens: 120000
max_completion_tokens: 16384
```
- 或启动前直接编辑 `config.yaml` 文件
- 或启动前直接编辑 `config.yaml` 文件。`ai.default_channel` 会作为新对话和未显式选择通道任务的默认模型;对话页也可以在会话设置里选择某个已保存通道。
2. **登录系统** - 首次启动时控制台会显示自动生成的 `admin` 初始密码;也可在「平台权限 → 用户管理」中创建账号
3. **安装安全工具(可选)** - 按需安装 `tools/` 目录中的工具;未安装的工具在执行时会自动跳过或改用替代方案。常用示例:
@@ -278,19 +286,23 @@ go build -o cyberstrike-ai cmd/server/main.go
## 配置
请以 [`config.example.yaml`](config.example.yaml) 作为权威配置模板,只复制当前环境需要的配置。最少需要配置服务监听地址和一个 OpenAI 兼容模型
请以 [`config.example.yaml`](config.example.yaml) 作为权威配置模板,只复制当前环境需要的配置。最少需要配置服务监听地址和一个 AI 通道
```yaml
server:
host: "127.0.0.1"
port: 8080
openai:
api_key: "${OPENAI_API_KEY}"
base_url: "https://api.openai.com/v1"
model: "your-model"
ai:
default_channel: openai-main
channels:
openai-main:
provider: openai_compatible
api_key: "${OPENAI_API_KEY}"
base_url: "https://api.openai.com/v1"
model: "your-model"
```
不要提交真实凭证。将服务暴露到 localhost 之外前,请阅读[配置参考](docs/zh-CN/configuration.md)、[推荐配置画像](docs/zh-CN/configuration-profiles.md)和[安全加固指南](docs/zh-CN/security-hardening.md)。
`openai` 是兼容旧版本的运行时字段,新配置优先维护 `ai.channels`。不要提交真实凭证。将服务暴露到 localhost 之外前,请阅读[配置参考](docs/zh-CN/configuration.md)、[推荐配置画像](docs/zh-CN/configuration-profiles.md)和[安全加固指南](docs/zh-CN/security-hardening.md)。
## 相关文档
+1
View File
@@ -34,6 +34,7 @@ func main() {
// 注册工具
executor.RegisterTools(mcpServer)
mcp.RegisterExecutionControlTools(mcpServer, nil)
log.Logger.Info("MCP服务器(stdio模式)已启动,等待消息...")
+91 -8
View File
@@ -4,7 +4,9 @@ import (
"context"
"cyberstrike-ai/internal/app"
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/database"
"cyberstrike-ai/internal/logger"
"cyberstrike-ai/internal/security"
"cyberstrike-ai/internal/termout"
"flag"
"fmt"
@@ -12,17 +14,21 @@ import (
"os/signal"
"strings"
"syscall"
"go.uber.org/zap"
"golang.org/x/term"
)
func main() {
var configPath = flag.String("config", "config.yaml", "配置文件路径")
var httpsBootstrap = flag.Bool("https", false, "启用主站 HTTPS:未配置 tls_cert_path/tls_key_path 时使用内存自签证书(本地测试);与 run.sh 默认行为一致")
var httpBootstrap = flag.Bool("http", false, "强制主站使用明文 HTTP:覆盖配置文件中的 tls_enabled/tls_auto_self_sign/tls_cert_path/tls_key_path")
var configPath = flag.String("config", "config.yaml", "Path to the configuration file")
var httpsBootstrap = flag.Bool("https", false, "Enable HTTPS for the main site; uses an in-memory self-signed certificate when no cert/key is configured")
var httpBootstrap = flag.Bool("http", false, "Force plain HTTP for the main site, overriding TLS settings in the configuration file")
var resetAdminPassword = flag.Bool("reset-admin-password", false, "Interactively reset the built-in admin password and exit")
flag.Parse()
// 环境变量兼容(便于 systemd/docker 等不传参场景)
if *httpsBootstrap && *httpBootstrap {
fmt.Fprintln(os.Stderr, "--http --https 不能同时使用")
fmt.Fprintln(os.Stderr, "--http and --https cannot be used together")
os.Exit(2)
}
if !*httpsBootstrap && !*httpBootstrap {
@@ -38,24 +44,32 @@ func main() {
cp = "config.yaml"
}
if strings.HasPrefix(cp, "-") {
fmt.Fprintf(os.Stderr, "无效的 -config 路径 %q\n若同时需要 HTTPS,请写成: ./cyberstrike-ai --https -config config.yaml-config 后必须是 yaml 文件路径)。\n", cp)
fmt.Fprintf(os.Stderr, "Invalid -config path %q.\nIf HTTPS is also needed, use: ./cyberstrike-ai --https -config config.yaml (-config must be followed by a yaml file path).\n", cp)
os.Exit(2)
}
localConfig, err := config.EnsureLocalConfig(cp)
if err != nil {
fmt.Printf("加载配置失败: %v\n", err)
fmt.Printf("Failed to load config: %v\n", err)
return
}
cfg, err := config.Load(cp)
if err != nil {
fmt.Printf("加载配置失败: %v\n", err)
fmt.Printf("Failed to load config: %v\n", err)
return
}
if localConfig.Created {
termout.PrintConfigCreated()
}
if *resetAdminPassword {
if err := runResetAdminPassword(cfg); err != nil {
fmt.Fprintf(os.Stderr, "Failed to reset admin password: %v\n", err)
os.Exit(1)
}
return
}
if *httpBootstrap {
config.ApplyPlainHTTPBootstrap(cfg)
} else if *httpsBootstrap {
@@ -79,7 +93,7 @@ func main() {
// MCP 启用且 auth_header_value 为空时,自动生成随机密钥并写回配置
if err := config.EnsureMCPAuth(cp, cfg); err != nil {
fmt.Printf("MCP 鉴权配置失败: %v\n", err)
fmt.Printf("Failed to configure MCP authentication: %v\n", err)
return
}
if cfg.MCP.Enabled {
@@ -121,3 +135,72 @@ func main() {
}
}
}
func runResetAdminPassword(cfg *config.Config) error {
dbPath := strings.TrimSpace(cfg.Database.Path)
if dbPath == "" {
dbPath = "data/conversations.db"
}
if _, err := os.Stat(dbPath); err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("database does not exist: %s; start the service once to initialize it first", dbPath)
}
return err
}
fmt.Println("Reset built-in admin password")
fmt.Println()
password, err := readHiddenPassword("New admin password: ")
if err != nil {
return err
}
password = strings.TrimSpace(password)
if len(password) < 8 {
return fmt.Errorf("new password must be at least 8 characters")
}
confirm, err := readHiddenPassword("Confirm new password: ")
if err != nil {
return err
}
if password != strings.TrimSpace(confirm) {
return fmt.Errorf("passwords do not match")
}
hash, err := security.HashPassword(password)
if err != nil {
return err
}
db, err := database.NewDB(dbPath, zap.NewNop())
if err != nil {
return err
}
defer func() { _ = db.Close() }()
admin, err := db.GetRBACUserByUsername("admin")
if err != nil {
return fmt.Errorf("built-in admin account was not found; start the service once to initialize it first: %w", err)
}
if !admin.IsBuiltin {
return fmt.Errorf("admin account is not built in; refusing to reset it")
}
if err := db.UpdateRBACAdminPassword(hash); err != nil {
return err
}
fmt.Println()
fmt.Println("Admin password has been reset.")
fmt.Println("If the service is running, existing login sessions remain valid until the service restarts or the sessions expire.")
return nil
}
func readHiddenPassword(prompt string) (string, error) {
fmt.Fprint(os.Stderr, prompt)
password, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Fprintln(os.Stderr)
if err != nil {
return "", err
}
return string(password), nil
}
+35 -25
View File
@@ -10,7 +10,7 @@
# ============================================
# 前端显示的版本号(可选,不填则显示默认版本)
version: "v1.7.5"
version: "v1.7.9"
# 服务器配置
server:
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
@@ -49,35 +49,40 @@ monitor:
# 对话相关配置
# ============================================
# AI 模型配置(支持 OpenAI 兼容 API
# 必填项:api_key, base_url, model 必须填写才能正常运行
# AI 通道配置(支持保存多个 OpenAI 兼容 / Claude 通道
# default_channel 指定新对话与未显式选择通道的任务使用哪个通道。
# 每个 channels.<id> 必填:api_key, base_url, model。
# 支持的 API 服务商:
# - OpenAI: https://api.openai.com/v1
# - DeepSeek: https://api.deepseek.com/v1
# - 其他兼容 OpenAI 协议的 API
# 常用模型: gpt-4, gpt-3.5-turbo, deepseek-chat, claude-3-opus 等
# provider: 可选值 openai(默认) | claude(自动桥接到 Anthropic Claude Messages API)
openai:
provider: openai # API 提供商: openai(默认,兼容OpenAI协议) | claude(自动桥接到Anthropic Claude Messages API)
base_url: https://dashscope.aliyuncs.com/compatible-mode/v1 # API 基础 URL(必填)
api_key: sk-xxxxxxx # API 密钥(必填)
model: qwen3-max # 模型名称(必填)
max_total_tokens: 120000 # LLM 相关上下文的最大 Token 数限制(内存压缩和攻击链构建会共用此配置)
max_completion_tokens: 16384 # 单次生成上限(含 reasoning 与可见输出),防止依赖网关隐式默认值
# Eino 路径模型推理:DeepSeek/OpenAI 为 thinking / reasoning_effortClaude 4.6+ 为 adaptive + output_config.effort(仅显式配置 effort 时下发);3.7 为 enabled+budget_tokens:10000(文档示例),effort 不映射,自定义预算用 extra_request_fields
reasoning:
mode: on # auto | on | offoffOpenAI/Claude 不附加推理字段,DeepSeek 发送 thinking.type=disabled(其默认开启思考)
effort: high # low | medium | high | max | xhigh(最高档:OpenAI 常用 xhigh,部分网关用 max,原样下发);空表示不指定
allow_client_reasoning: true # false 时忽略对话请求体 reasoning,仅以下方为准
profile: openai_compat # auto | deepseek_compat | openai_compat | output_config_effort
# extra_request_fields: {} # 可选:管理员自定义根级 JSON 片段(高级
# provider: 可选值 openai_compatible(默认) | claude(自动桥接到 Anthropic Claude Messages API)
ai:
default_channel: qwen-max
channels:
qwen-max:
name: Qwen Max
provider: openai_compatible
base_url: https://dashscope.aliyuncs.com/compatible-mode/v1
api_key: sk-xxxxxxx
model: qwen3-max
max_total_tokens: 120000
max_completion_tokens: 32768
# Eino 路径模型推理:DeepSeek/OpenAI 为 thinking / reasoning_effortClaude 4.6+ 为 adaptive + output_config.effort(仅显式配置 effort 时下发);3.7 为 enabled+budget_tokens:10000(文档示例),effort 不映射,自定义预算用 extra_request_fields
reasoning:
mode: on # auto | on | offoffOpenAI/Claude 不附加推理字段,DeepSeek 发送 thinking.type=disabled(其默认开启思考
effort: high # low | medium | high | max | xhigh(最高档:OpenAI 常用 xhigh,部分网关用 max,原样下发);空表示不指定
allow_client_reasoning: true # false 时忽略对话请求体 reasoning,仅以下方为准
profile: openai_compat # auto | deepseek_compat | openai_compat | output_config_effort
# extra_request_fields: {} # 可选:管理员自定义根级 JSON 片段(高级)
# 视觉分析(analyze_image MCP 工具;图片仅在单次 VL 调用中出现,Agent 上下文只保留文字摘要)
vision:
enabled: false # true 且 model 非空时注册 analyze_image
model: qwen-vl # VL 模型名(enabled 时必填)
api_key: "" # 留空则复用 openai.api_key
base_url: "" # 留空则复用 openai.base_url
provider: # 留空则复用 openai.provideropenai | claude
api_key: "" # 留空则复用默认 AI 通道 api_key
base_url: "" # 留空则复用默认 AI 通道 base_url
provider: # 留空则复用默认 AI 通道 provideropenai_compatible | claude
max_image_bytes: 5242880 # 原始文件上限(字节),默认 5MB
max_dimension: 2048 # 长边缩放像素
jpeg_quality: 82
@@ -107,6 +112,11 @@ shodan:
agent:
max_iterations: 12000 # 全局最大迭代次数(单代理 / Deep / Supervisor / Plan-Execute 主执行器 / 子代理均沿用;agents/*.md 中 max_iterations>0 可单独覆盖)
tool_timeout_minutes: 60 # 单次工具执行最大时长(分钟),超时自动终止;0 表示不限制(不推荐,易出现长时间挂起)
tool_wait_timeout_seconds: 300 # 工具本轮最多等待(秒);到时返回 execution_idworker 继续后台执行,可用 wait_tool_execution 继续等待;0=等到完成
external_mcp_max_concurrent_per_server: 5 # 单个外部 MCP server 同时运行的工具数;0=默认2;负数=不限制
external_mcp_max_concurrent_total: 16 # 所有外部 MCP 工具全局并发上限;0=默认16;负数=不限制
external_mcp_circuit_failure_threshold: 15 # 单个外部 MCP server 连续失败多少次后熔断;0=默认3;负数=关闭熔断
external_mcp_circuit_cooldown_seconds: 60 # 熔断冷却秒数;0=默认60
shell_no_output_timeout_seconds: 1200 # execute/exec 连续无新输出则终止(秒);通用防挂死;0=默认300;-1=关闭
workspace_root_dir: "" # 会话工作目录根路径(curl/wget 下载、read_file/glob/grep 本地分析);空=tmp/workspace,其下按 projects/{id} 或 conversations/{id} 隔离;勿用系统 /tmp
# system_prompt_path: prompts/single-agent.md # 可选:单代理系统提示文件(相对本配置文件所在目录);非空且可读时替换内置提示
@@ -214,13 +224,13 @@ multi_agent:
tool_search_enable: true # true:工具数 ≥ min 时启用 tool_search,仅前 N 个工具常驻,其余按正则按需解锁,省 token、减误选;false:全量工具进上下文
tool_search_min_tools: 20 # 达到该数量才启用 tool_search(避免工具很少时多此一举);与 always_visible 配合使用
tool_search_always_visible: 12 # 始终直接暴露给模型的工具个数(顺序与角色工具列表一致);其余工具进入动态池,需 tool_search 解锁
tool_search_always_visible_tools: [read_file, glob, grep, analyze_image, write_file, edit_file, execute, task, transfer_to_agent, exit, write_todos, skill, tool_search, TaskCreate, TaskGet, TaskUpdate, TaskList, record_vulnerability, list_vulnerabilities, get_vulnerability, list_knowledge_risk_types, search_knowledge_base, webshell_exec, webshell_file_list, webshell_file_read, webshell_file_write, manage_webshell_list, manage_webshell_add, manage_webshell_update, manage_webshell_delete, manage_webshell_test, batch_task_list, batch_task_get, batch_task_start, batch_task_rerun, batch_task_pause, batch_task_update_metadata, batch_task_update_schedule, batch_task_schedule_enabled, batch_task_update_task, batch_task_remove_task, batch_task_delete, batch_task_create, batch_task_add_task, http-framework-test, exec] # 后端内置常驻工具白名单(优先于 always_visible 数量策略)
tool_search_always_visible_tools: [read_file, glob, grep, analyze_image, write_file, edit_file, execute, task, transfer_to_agent, exit, write_todos, skill, tool_search, TaskCreate, TaskGet, TaskUpdate, TaskList, record_vulnerability, list_vulnerabilities, get_vulnerability, get_tool_execution, wait_tool_execution, cancel_tool_execution, list_knowledge_risk_types, search_knowledge_base, webshell_exec, webshell_file_list, webshell_file_read, webshell_file_write, manage_webshell_list, manage_webshell_add, manage_webshell_update, manage_webshell_delete, manage_webshell_test, batch_task_list, batch_task_get, batch_task_start, batch_task_rerun, batch_task_pause, batch_task_update_metadata, batch_task_update_schedule, batch_task_schedule_enabled, batch_task_update_task, batch_task_remove_task, batch_task_delete, batch_task_create, batch_task_add_task, http-framework-test, exec] # 后端内置常驻工具白名单(优先于 always_visible 数量策略)
plantask_enable: true # P0:主代理挂载 TaskCreate/Get/Update/List 结构化任务板;需 eino_skills 可用且 skills_dir 存在
plantask_rel_dir: .eino/plantask # 任务文件相对 skills_dir,按会话分子目录:skills/.eino/plantask/<conversationId>/
reduction_enable: true # true:大工具输出截断/落盘以控上下文;后端会独立创建,不依赖 eino_skills 是否启用
reduction_max_length_for_trunc: 50000 # 单条工具结果超过该字符数(bytes)时截断并落盘(由 reduction 中间件处理
reduction_enable: true # true:大工具输出截断落盘到本地文件(tmp/reduction/.../trunc/<id>),上下文只留 <persisted-output> 预览;不依赖 eino_skills
reduction_max_length_for_trunc: 100000 # 单条工具结果超过该字节数时:全文写入本地 trunc 文件,返回带路径的截断预览(MCP/exec 与 reduction 共用此阈值
reduction_max_tokens_for_clear: 60000 # 历史工具结果清理阈值(tokens),应低于 max_total_tokens * summarization_trigger_ratio
reduction_root_dir: "" # 非空:截断/清理内容落盘根路径;空:使用系统临时目录下按会话隔离的默认路径
reduction_root_dir: "" # 非空:截断/清理内容落盘根路径;空:项目目录下 tmp/reduction(按会话/项目隔离)
reduction_clear_exclude: [] # 不参与「清理阶段」的工具名额外列表(会与 task/transfer/exit 等内置排除项合并);需要时用 YAML 列表填写
reduction_sub_agents: true # true:子代理也挂 reductionfalse:仅编排主代理使用 reduction
summarization_trigger_ratio: 0.8 # summarization 触发比例(max_total_tokens * ratio),建议 0.75~0.85
+2
View File
@@ -21,6 +21,7 @@ CyberStrikeAI documentation is organized by user journey. Start with deployment,
- [Skills 指南](zh-CN/skills-guide.md)
- [Eino 多代理](zh-CN/MULTI_AGENT_EINO.md)
- [工作流](zh-CN/workflow-graph.md)
- [工具执行治理](zh-CN/tool-execution-governance.md)
- [人机协同最佳实践](zh-CN/hitl-best-practices.md)
### 功能指南
@@ -58,6 +59,7 @@ CyberStrikeAI documentation is organized by user journey. Start with deployment,
- [Skills](en-US/skills-guide.md)
- [Eino Multi-Agent](en-US/MULTI_AGENT_EINO.md)
- [Workflows](en-US/workflow-graph.md)
- [Tool Execution Governance](en-US/tool-execution-governance.md)
- [HITL Best Practices](en-US/hitl-best-practices.md)
### Feature guides
+1 -1
View File
@@ -13,7 +13,7 @@
- [Architecture](architecture.md) · [Security Model](security-model.md) · [RBAC](rbac.md)
- [Agents and Roles](agent-and-role-guide.md) · [Skills](skills-guide.md) · [Eino Multi-Agent](MULTI_AGENT_EINO.md)
- [Workflows](workflow-graph.md) · [HITL Best Practices](hitl-best-practices.md)
- [Workflows](workflow-graph.md) · [Tool Execution Governance](tool-execution-governance.md) · [HITL Best Practices](hitl-best-practices.md)
## Feature guides
+1 -1
View File
@@ -21,7 +21,7 @@ vision:
timeout_seconds: 60
```
Empty `api_key`, `base_url`, or `provider` inherits from `openai`.
Empty `api_key`, `base_url`, or `provider` inherits from the resolved default AI channel.
## Data Handling
+31
View File
@@ -45,6 +45,18 @@ Multi-agent:
`orchestration` may be `deep`, `plan_execute`, or `supervisor`.
Common request body fields:
| Field | Meaning |
| --- | --- |
| `message` | User message, required. |
| `conversationId` | Continue an existing conversation; empty creates a new one. |
| `projectId` | Project for a new conversation; empty may follow `config.project.default_project_id`. |
| `role` | Use a named role. |
| `aiChannelId` | Select a channel from `ai.channels`; empty follows `ai.default_channel`. |
| `reasoning` | Per-session reasoning override, controlled by the channel's `reasoning.allow_client_reasoning`. |
| `hitl` | Per-session human-in-the-loop settings. |
## SSE Notes
Streaming endpoints are long-lived. Clients should:
@@ -55,6 +67,25 @@ Streaming endpoints are long-lived. Clients should:
- disable proxy buffering;
- pass `conversationId` when continuing a conversation.
## File Management Sources
The file management page and `GET /api/chat-uploads` group conversation-related files by source. Directory names still use project IDs or conversation IDs for stability, while the UI prefers project names or conversation titles and keeps the full ID available in tooltips or copied paths.
| Source | `source` | Typical directory | Meaning | Mutability |
| --- | --- | --- | --- | --- |
| Workspace files | `workspace` | `tmp/workspace/projects/<projectId>/...`, `tmp/workspace/conversations/<conversationId>/...` | The Agent workspace for downloaded files, analysis scripts, intermediate results, and generated CSV/XLSX/Markdown files. If an AI-generated file is missing from the UI, check this source first. | Read-only listing; supports copy path, download, and export. |
| Conversation artifacts | `conversation_artifact` | `data/conversation_artifacts/<conversationId>/...` | Conversation-scoped deliverables or archived artifacts such as summaries, reports, or middleware-generated artifacts. | Read-only listing; supports copy path, download, and export. |
| Tool outputs | `reduction` | `tmp/reduction/projects/<projectId>/...`, `tmp/reduction/conversations/<conversationId>/...` | Persisted full tool outputs, scan raw data, or outputs saved before truncation. Useful for reviewing long command or scan results. | Read-only listing; supports copy path, download, and export. |
| Chat uploads | `upload` | `chat_uploads/<date>/<conversationId>/...` | Files manually uploaded in chat or from the file management page. Copy the server absolute path into chat when the AI should reference a file. | Supports upload, mkdir, text edit, rename, delete, copy path, download, and export. |
Related endpoints:
- `GET /api/chat-uploads`: list files filtered by source, project, conversation, or filename.
- `GET /api/chat-uploads/path`: resolve a file-management relative path or internal virtual path to a server absolute path for copy actions.
- `GET /api/chat-uploads/download`: download a file.
- `GET /api/chat-uploads/export`: export the current filtered result as a ZIP.
- `POST /api/chat-uploads`: upload into the chat uploads directory.
## Asset Management and Bulk Import
Asset endpoints:
+63 -8
View File
@@ -16,11 +16,15 @@ server:
# - https://trusted-integration.example
auth:
session_duration_hours: 12
openai:
provider: openai
base_url: https://api.openai.com/v1
api_key: sk-...
model: gpt-4.1
ai:
default_channel: openai-main
channels:
openai-main:
name: OpenAI Main
provider: openai_compatible
base_url: https://api.openai.com/v1
api_key: sk-...
model: gpt-4.1
agent:
max_iterations: 12000
tool_timeout_minutes: 60
@@ -30,13 +34,64 @@ Change the initial `admin` password from the Web UI after first login. Use HTTPS
Valid Chromium `chrome-extension://<32-character-extension-id>` origins are recognized automatically. The extension must still obtain host permission and authenticate with a password and Bearer token. `server.cors_allowed_origins` remains available as an exact allowlist for other trusted Web integrations; wildcards are not accepted, and changing it requires a restart.
## AI Channels
`ai` is the recommended model configuration entry. In the Web UI, use **System Settings → Basic Settings → AI Channel Configuration**. Saving that form writes `ai.default_channel` and `ai.channels`. The legacy `openai` field remains as a backward-compatible runtime field; on load, CyberStrikeAI ensures a default channel exists and synchronizes the resolved `ai.default_channel` into runtime `openai`.
```yaml
ai:
default_channel: openai-main
channels:
openai-main:
name: OpenAI Main
provider: openai_compatible
base_url: https://api.openai.com/v1
api_key: sk-...
model: gpt-4.1
max_total_tokens: 120000
max_completion_tokens: 16384
reasoning:
mode: on
effort: high
allow_client_reasoning: true
profile: openai_compat
claude-main:
name: Claude Main
provider: claude
base_url: https://api.anthropic.com/v1
api_key: sk-ant-...
model: claude-sonnet-4-5
```
| Field | Meaning |
| --- | --- |
| `ai.default_channel` | Default channel ID for new conversations and requests without an explicit channel. |
| `ai.channels.<id>` | Channel config. IDs are normalized to lowercase letters, digits, and hyphens. |
| `name` | Display name in the Web UI; falls back to the ID. |
| `provider` | `openai_compatible` or `claude`. OpenAI-compatible channels map to runtime `openai`; Claude channels bridge to Anthropic Messages API. |
| `base_url/api_key/model` | Required. Base URL usually includes a version path such as `/v1`. |
| `max_total_tokens` | Shared context budget for compression, attack-chain generation, multi-agent summaries, and similar paths. |
| `max_completion_tokens` | Per-response output cap; default is used when empty. |
| `reasoning` | Default reasoning fields for the channel. Gateway support varies; try `mode: off` first when a provider rejects requests. |
The chat page reads saved channels into the “AI Channel” selector. A non-empty request `aiChannelId` selects a channel for that run/session without sending API credentials through the prompt path. Empty `aiChannelId` follows `ai.default_channel`.
Common Web UI operations:
- Add: click `+`, fill required fields, then save.
- Set default: select a channel, click **Set as default**, then save/apply.
- Copy: duplicate the current form, useful for the same provider with a different model.
- Delete: keep at least one channel; the default channel is protected from bulk delete.
- Probe: use **Test connection** or **Bulk probe** to validate API key, Base URL, and model.
## Hot-Apply Boundaries
`POST /api/config/apply` coordinates model config, tool description mode, MCP tool registration, knowledge components, robot restarts, and C2 runtime reconciliation. It does not make every field instantly effective.
| Section | Usually hot-applies | Extra action |
| --- | --- | --- |
| `openai` | new requests use new model settings | running streams keep their current state |
| `ai.default_channel` / `ai.channels` | new requests use the resolved default or selected channel | running streams keep their current state; reload config for the frontend channel list |
| `openai` | compatibility field, usually synchronized from the default AI channel | prefer maintaining new config in `ai.channels` |
| `agent.max_iterations` | new tasks | existing tasks continue |
| `hitl.tool_whitelist` | new approval checks | pending approvals are not re-decided |
| `knowledge.enabled` | initializes/updates components | scan and index are still required |
@@ -47,8 +102,8 @@ Valid Chromium `chrome-extension://<32-character-extension-id>` origins are reco
## Fallback Relationships
- `vision.api_key/base_url/provider` can inherit from `openai`.
- `hitl.audit_model` can inherit from `openai`.
- `vision.api_key/base_url/provider` can inherit from the resolved default AI channel.
- `hitl.audit_model` can inherit from the resolved default AI channel.
- `knowledge.embedding.base_url/api_key` can inherit from model settings.
- rerank config can inherit from embedding/openai.
- `database.knowledge_db_path` can be separate or reuse the main DB.
+1 -1
View File
@@ -10,7 +10,7 @@ CyberStrikeAI can run as a local testing tool, an internal team service, or a pr
- Python for some MCP servers and tool scripts.
- SQLite files under `data/`; no external DB is required by default.
- Actual security tools installed in PATH. YAML files under `tools/` only describe commands.
- An OpenAI-compatible model endpoint, or `openai.provider: claude` for the Claude bridge.
- At least one `ai.channels` entry. Use `provider: openai_compatible` for OpenAI-compatible endpoints, or `provider: claude` for the Claude bridge.
Important persistent paths:
+2 -2
View File
@@ -23,12 +23,12 @@ hitl:
provider: ""
base_url: ""
api_key: ""
model: "" # set a small model here; blank reuses openai.model
model: "" # set a small model here; blank reuses the default AI channel model
retention_days: 90
tool_whitelist: [read_file, list_dir, glob, grep, tool_search]
```
`audit_model` supports partial configuration. Empty fields inherit from the main `openai` config, so the common setup is to fill only `model` and run approvals on a cheaper small model.
`audit_model` supports partial configuration. Empty fields inherit from the resolved default AI channel, so the common setup is to fill only `model` and run approvals on a cheaper small model.
## Recommended Approval Strategy
+221
View File
@@ -0,0 +1,221 @@
# Tool Execution Governance
[Back to English documentation](README.md)
This document describes how CyberStrikeAI governs long-running tools, blocking MCP calls, oversized outputs, cancellation, and context restore. The goal is to preserve standard Agent/Eino tool semantics while preventing tool hangs, context blowups, oversized database records, and unsafe resume behavior.
## Goals
- **Keep the agent runner responsive**: tools may run for a long time, but the current runner waits only for a bounded interval.
- **Allow long tasks to continue**: timeout returns an `execution_id`; later turns can call `wait_tool_execution`.
- **Support cancellation**: users and agents can cancel a running execution.
- **Keep DB and agent views identical**: the database stores the same canonical capped result returned to the agent.
- **Protect resume paths**: resume uses model-facing traces and caps historical oversized tool traces.
- **Isolate external MCP failures**: external MCP servers are protected by per-server concurrency limits, global concurrency limits, and circuit breakers.
## Execution Model
Tool calls still appear to Eino/Agent as standard tool invocations, but the blocking work runs in a worker:
```text
Agent calls tool
-> ExecutionService creates execution
-> worker runs the real MCP/tool call
-> Agent bounded wait
-> completed: return tool result
-> still running: return execution_id, worker continues in background
```
This prevents MCP servers, `exec`, `sqlmap`, `nmap`, `nuclei`, and similar long-running tools from binding the current runner indefinitely.
## Execution Statuses
| Status | Meaning |
|---|---|
| `queued` | Execution exists and is waiting for a worker or concurrency slot |
| `running` | Worker is executing |
| `background_running` | UI display state: agent stopped waiting, background worker continues |
| `completed` | This tool call completed |
| `failed` | The tool actually failed |
| `cancelled` | User, agent, or session cleanup cancelled the execution |
| `hard_timeout` | The tool exceeded its hard timeout |
| `orphaned` | A persisted running execution no longer has a runtime worker |
Important: when `wait_tool_execution` reaches `timeout_seconds` and the target execution is still running, the wait call itself is a completed observation, not a failed tool execution.
## Control Tools
| Tool | Purpose |
|---|---|
| `get_tool_execution` | Read current execution state |
| `wait_tool_execution` | Wait for a selected execution for a bounded interval |
| `cancel_tool_execution` | Cancel a selected execution |
`get_tool_execution` and `wait_tool_execution` can include a live output preview:
- `include_partial_output`: whether to return partial output, default `true`.
- `partial_output_max_bytes`: tail preview limit for this call, default `4096`, maximum `65536`.
Partial output is a bounded preview of output produced so far, not the final `result`. The canonical `result` is still written only when the tool finishes. Tools that do not support streaming output simply omit partial fields.
Typical flow:
```text
1. Call a long-running tool such as exec/sqlmap/nmap
2. After tool_wait_timeout_seconds, receive execution_id
3. Agent can continue reasoning, use other tools, or call wait_tool_execution
4. If still incomplete, continue waiting or call cancel_tool_execution
```
`tool_wait_timeout_seconds` applies to internal MCP tools, external MCP tools, and Eino filesystem's streaming `execute`. Eino's non-streaming filesystem tools such as `ls/read_file/write_file/edit_file/glob/grep` are recorded in execution monitoring, but they are not converted into resumable background workers.
## Cancellation and Session Cleanup
- User stop cancels running tools for the current conversation.
- Normal session end cancels remaining running tools for the current conversation.
- Interrupt-and-continue style flows do not mass-cancel tools.
- Conversation-scoped cancellation avoids killing tools from other conversations.
## External MCP Isolation
External MCP servers can hang, disconnect, or return failures. CyberStrikeAI uses three protections:
| Capability | Config | Description |
|---|---|---|
| Per-server concurrency | `external_mcp_max_concurrent_per_server` | Max simultaneous calls for one external MCP server |
| Global concurrency | `external_mcp_max_concurrent_total` | Max simultaneous external MCP calls across all servers |
| Circuit breaker | `external_mcp_circuit_failure_threshold` / `external_mcp_circuit_cooldown_seconds` | Temporarily fast-fails a server after repeated failures |
Recommended defaults:
```yaml
agent:
external_mcp_max_concurrent_per_server: 2
external_mcp_max_concurrent_total: 16
external_mcp_circuit_failure_threshold: 3
external_mcp_circuit_cooldown_seconds: 60
```
## Output Governance
CyberStrikeAI uses `multi_agent.eino_middleware.reduction_max_length_for_trunc` as the unified tool result cap. The example configuration uses 50000 bytes.
```yaml
multi_agent:
eino_middleware:
reduction_enable: true
reduction_max_length_for_trunc: 50000
```
Coverage:
| Channel | Behavior |
|---|---|
| Agent-facing tool result | Uses the canonical capped result |
| DB / monitor storage | Stores the same canonical result |
| `get_tool_execution` / `wait_tool_execution` | Reads the same canonical result |
| Eino `execute` / filesystem monitor records | Capped before completion is persisted |
| Non-streaming `exec` stdout/stderr | Source-side bounded buffer |
| Streaming `exec` stdout/stderr | Streamed UI output is also bounded |
| PTY execution path | Uses the same output cap |
| Frontend detail modal | Has an additional UI display cap |
When the cap is reached, the full output is written to a local trunc file and the agent-facing payload becomes a `<persisted-output>` notice (with absolute path) that fits inside the configured budget.
Example:
```text
<persisted-output>
Output too large (200000). Full output saved to: /path/to/tmp/reduction/conversations/<id>/trunc/<execution_id>
Use read_file with offset/limit to read parts of the file.
Preview (first …):
Preview (last …):
</persisted-output>
```
The current strategy is “spill full text to disk + bounded preview in context.” Agents can recover the original via `read_file`.
## Database and Resume Context
New results are written through this path:
```text
tool completes
-> NormalizeToolResultForStorage
-> update in-memory execution
-> persist to DB
-> return to Agent
```
So, under normal operation, the DB stores exactly the result returned to the agent.
Resume uses `LastAgentTraceInput`, which is the model-facing trace that actually reached ChatModel, not raw event accumulation. The restore path also caps historical tool content to prevent context blowups from:
- pre-upgrade DB records that contain raw large output,
- manual imports or migrations,
- lowering the configured cap from a larger value,
- future bypasses that accidentally skip canonicalization.
## Recommended Configuration
For long-running security tasks:
```yaml
agent:
max_iterations: 800
tool_timeout_minutes: 60
tool_wait_timeout_seconds: 30
external_mcp_max_concurrent_per_server: 2
external_mcp_max_concurrent_total: 16
external_mcp_circuit_failure_threshold: 3
external_mcp_circuit_cooldown_seconds: 60
shell_no_output_timeout_seconds: 1200
multi_agent:
eino_middleware:
reduction_enable: true
reduction_max_length_for_trunc: 50000
```
| Parameter | Recommended | Notes |
|---|---:|---|
| `max_iterations` | `300-1000` | Very large values weaken loop protection |
| `tool_timeout_minutes` | `60` | Hard timeout for tools such as sqlmap |
| `tool_wait_timeout_seconds` | `30-60` | Agent wait bound before returning `execution_id` |
| `shell_no_output_timeout_seconds` | `600-1200` | Kills silent hangs |
| `reduction_max_length_for_trunc` | `50000` | Unified tool result cap |
Do not make `tool_wait_timeout_seconds` very large by default. Long tasks should continue in workers and be observed by `execution_id`, rather than blocking one turn for several minutes.
## Testing
Long-task test prompt:
```text
Call exec to run sleep 120. If it is not done after 10 seconds, do not keep waiting; report execution_id, call wait_tool_execution for 5 seconds, then cancel_tool_execution if still incomplete and report final status.
```
Large-output test prompt:
```text
Call exec to run: python3 - <<'PY'
print("A" * 200000)
PY
Then show the tool result length and whether it contains the truncation marker.
```
Expected behavior:
- The initial long task returns an `execution_id` and status `running` or UI `background_running`.
- `wait_tool_execution` timing out while the target is still running is not displayed as a tool failure.
- Large output never exceeds `reduction_max_length_for_trunc`.
- DB, monitor details, and agent continuation use the same capped result.
## Boundaries
- CyberStrikeAI cannot control how a remote external MCP server collects output internally; it caps results after they enter CyberStrikeAI and protects calls with concurrency limits and circuit breakers.
- Oversized tool output is spilled to local `tmp/reduction/.../trunc/<id>` (or `reduction_root_dir`) before truncation; the bounded result includes an absolute path for `read_file`.
+13 -4
View File
@@ -46,20 +46,29 @@ Login fails:
If another administrator with `rbac:write` is available, reset the password under **Platform permissions → User management**.
If no administrator session is available, the built-in `admin` account can be recovered on the server. Stop CyberStrikeAI, back up the database, change to the project root, and run the command below. Enter and confirm the new password when prompted:
If no administrator session is available, the built-in `admin` account can be recovered on the server. Change to the project root and run:
```bash
./run.sh --reset-admin-password
```
Enter and confirm the new password when prompted. The script hides input and stores a bcrypt hash. If the service is running, restart it afterward to invalidate existing login sessions.
If `run.sh` is not available, run the command below manually. Enter and confirm the new password when prompted:
```bash
HASH=$(htpasswd -nBC 10 '' | cut -d: -f2 | tr -d '\n') && sqlite3 data/conversations.db "UPDATE rbac_users SET password_hash='$HASH', updated_at=CURRENT_TIMESTAMP WHERE id='admin' AND username='admin' AND is_builtin=1; SELECT changes();"
```
Output `1` means that the row was updated. The command requires `sqlite3` and `htpasswd`. If `database.path` in `config.yaml` is not the default, replace `data/conversations.db`. Password input is hidden, is not written to shell history, and is stored as a bcrypt hash. Restart the service afterward to invalidate existing login sessions.
Output `1` means that the row was updated. The command requires `sqlite3` and `htpasswd`. If `database.path` in `config.yaml` is not the default, replace `data/conversations.db`. Password input is hidden and is not written to shell history.
Model fails:
- wrong `base_url` path;
- selected AI channel does not exist; empty selection follows `ai.default_channel`;
- wrong `ai.channels.<id>.base_url` path;
- invalid API key;
- model unavailable;
- reasoning fields unsupported by gateway. Try `openai.reasoning.mode: off`.
- reasoning fields unsupported by gateway. Try `ai.channels.<id>.reasoning.mode: off`.
Streaming stalls:
+2 -2
View File
@@ -12,8 +12,8 @@
## 核心概念与编排
- [架构说明](architecture.md) · [安全模型](security-model.md) · [RBAC](rbac.md)
- [Agent 与角色](agent-and-role-guide.md) · [Skills](skills-guide.md) · [Eino 多代理](MULTI_AGENT_EINO.md)
- [工作流](workflow-graph.md) · [人机协同最佳实践](hitl-best-practices.md)
- [Agent 与角色](agent-and-role-guide.md) · [Skills](skills-guide.md) · [Eino 多代理](MULTI_AGENT_EINO.md) · [Agent 最终回复治理](agent-finalization-best-practices.md)
- [工作流](workflow-graph.md) · [工具执行治理](tool-execution-governance.md) · [人机协同最佳实践](hitl-best-practices.md)
## 功能指南
+4 -4
View File
@@ -12,9 +12,9 @@
vision:
enabled: true
model: qwen-vl-max # 必填
api_key: # 留空 → openai.api_key
base_url: # 留空 → openai.base_url
provider: # 留空 → openai.provider
api_key: # 留空 → 默认 AI 通道 api_key
base_url: # 留空 → 默认 AI 通道 base_url
provider: # 留空 → 默认 AI 通道 provider
max_image_bytes: 5242880
max_dimension: 2048
jpeg_quality: 82
@@ -28,7 +28,7 @@ vision:
## Web 设置
**系统设置 → 基本设置 → 视觉分析(analyze_image** 可配置启用开关、视觉模型、API Key/Base URL(留空复用 OpenAI)、预处理参数;**保存并应用** 后写入 `config.yaml` 并重新注册 MCP 工具。
**系统设置 → 基本设置 → 视觉分析(analyze_image** 可配置启用开关、视觉模型、API Key/Base URL(留空复用默认 AI 通道)、预处理参数;**保存并应用** 后写入 `config.yaml` 并重新注册 MCP 工具。
## 路径
@@ -0,0 +1,333 @@
# Agent 最终回复治理最佳实践
[返回中文文档](README.md)
调研日期:2026-07-28
本文聚焦一个具体问题:Agent 在工具调用、推理、计划或子代理协作尚未真正完成时,输出了一段“像结论”的自然语言,前端或编排层把它当作最终回复展示。结论先说清楚:成熟 Agent 系统不会用“最近一段 assistant 文本”判断任务完成,而是用运行时状态、工具状态、验证结果和显式终态事件共同决定是否 final。
## 一、核心结论
1. **最终回复是运行时事件,不是自然语言内容。**
“已拿到”“下一步”“Huge breakthrough”这类文本只能作为候选观察或进展,不能作为完成信号。
2. **过程面和交付面必须隔离。**
`thinking``reasoning_chain``planning``response_delta`、子代理回复、工具输出都属于过程面;只有通过 final gate 的 `response` / `final` 事件才能写入主消息气泡和 `messages.content`
3. **复杂任务需要 verifier,而不是更长 prompt。**
Prompt 可以提醒模型谨慎,但最终完成必须由代码层判断:是否仍有待执行工具、后台 execution、未完成计划步骤、未验证证据、未记录事实/漏洞、未清理或未说明不可清理。
4. **不同 agent 模式不同,但 final 治理原则一致。**
单代理、Deep、Plan-Execute、Supervisor 都需要 final gate。区别只是 gate 的证据来源不同:单代理看工具轨迹,Deep 还要看子代理结果,Plan-Execute 要看 Replanner 的终止判断,Supervisor 要看 `exit` 与 supervisor 汇总。
## 二、成熟 Agent 的公开做法
| 系统 | 公开做法 | 对 final 治理的启发 |
|---|---|---|
| Codex | OpenAI 的 Codex prompting guide 建议不要在 prompt 中强行要求 upfront plan、preamble 或 status updates,因为这可能导致 rollout 未完成就停止。 | 不要把“模型自己说的阶段性计划/状态”当完成依据;agent harness 应负责执行循环和收尾。 |
| Claude Code | Claude Code 提供 `PreToolUse``PostToolUse``Stop` 等 hooks`PostToolUse` 明确发生在工具成功执行之后。 | 生命周期事件比自然语言可靠。验证、审计、阻断应挂在确定的阶段边界上。 |
| Claude Code Subagents | 子代理有独立上下文、自定义系统提示、特定工具权限和独立权限;子代理适合隔离大量检索/日志/文件读取。 | 子代理输出是证据材料,不是主任务最终结论;主代理必须汇总、验收、再 final。 |
| Claude Code Plan Mode | Plan mode 先读文件并产出计划,获得批准前不编辑。 | 计划与执行是不同状态;计划完成不等于任务完成。 |
| Cursor Plan Mode | Cursor Plan Mode 会研究代码库、询问澄清问题、生成可审查计划,并等待用户确认后再构建。 | UI 层把 plan/review/build 拆开,用户不会把计划误认为最终交付。 |
| OpenCode | OpenCode 把 Build、Plan、Review、Debug、Docs 等 agent 分成不同工具权限与用途,Plan agent 只分析规划不做修改。 | 用 agent 能力边界降低误触发:能规划的 agent 不等于能执行完成。 |
| Eino ADK | Eino ADK 提供事件驱动输出、Runner 回调、中断、checkpoint,以及 Supervisor、Plan-Execute 等协作原语。Plan-Execute 由 Planner、Executor、Replanner 协作。 | 当前项目选型方向正确;需要把事件驱动能力进一步固化为 finalization contract。 |
主要参考:
- OpenAI Codex Prompting Guide: https://developers.openai.com/cookbook/examples/gpt-5/codex_prompting_guide
- Claude Code Hooks: https://docs.anthropic.com/en/docs/claude-code/hooks
- Claude Code Subagents: https://docs.anthropic.com/en/docs/claude-code/sub-agents
- Claude Code Common Workflows: https://docs.anthropic.com/en/docs/claude-code/common-workflows
- Cursor Agent Best Practices: https://cursor.com/blog/agent-best-practices
- OpenCode Agents: https://opencode.ai/docs/agents/
- CloudWeGo Eino ADK: https://www.cloudwego.io/docs/eino/core_modules/eino_adk/
- CloudWeGo Eino ADK Patterns: https://www.cloudwego.io/docs/eino/overview/eino_adk0_1/
## 三、通用最佳实践
### 1. 建立 Finalization Contract
所有执行入口统一产出一个结构化收尾对象,只有它允许触发最终回复。
```go
type FinalizationDecision struct {
Status string // in_progress | completed | blocked | failed | cancelled
Finalizable bool
CompletionReason string // verified | user_cancelled | timeout | blocked | failed
FinalText string
EvidenceVerified bool
EvidenceRefs []string
PendingToolRuns []string
PendingPlanSteps []string
PendingApprovals []string
MissingChecks []string
}
```
硬规则:
- `Finalizable=false` 时禁止发送 `response` 终态事件。
- `Status=in_progress` 时只能发 `progress``planning``tool_*``reasoning_chain` 等过程事件。
- `FinalText` 不能为空,但非空不代表可以 final。
- `PendingToolRuns``PendingPlanSteps``PendingApprovals` 任一非空时不能 `completed`
- `EvidenceVerified=false` 时不能把候选输出写成已验证结论。
### 2. 固定 SSE 事件语义
推荐事件分层:
| 事件 | 展示位置 | 可否写 `messages.content` | 说明 |
|---|---|---:|---|
| `progress` | 任务状态/时间线 | 否 | 简短进度 |
| `planning` | 执行详情 | 否 | 主代理计划、阶段性判断 |
| `reasoning_chain` / `thinking` | 执行详情 | 否 | 推理/思考摘要 |
| `tool_call` / `tool_result` | 执行详情 | 否 | 工具事件 |
| `eino_agent_reply` | 执行详情 | 否 | 子代理返回材料 |
| `finalization_check` | 执行详情 | 否 | verifier 结果 |
| `finalization_auto_continue` | 执行详情 | 否 | verifier 触发的工程续跑,`contextInjection=false` |
| `response` | 主消息气泡 | 是 | 只能在 `data.finalized=true` 时使用 |
| `done` | 关闭流 | 否 | 仅表示流结束,不表示任务成功 |
| `error` / `cancelled` | 主消息气泡或系统提示 | 是,终态失败类 | 必须带原因 |
### 3. 把“最终候选”与“最终回复”分开
模型可以输出候选结论,但候选结论必须先进入 `final_candidate``planning`,再由 verifier 决定是否提升:
```text
assistant text
-> candidate
-> finalization gate
-> response(finalized=true)
```
不要这样做:
```text
assistant text
-> response
```
### 4. Stop-time Verification
借鉴 Claude Code hook 思路,在 agent run 停止时做一次确定性检查:
- 所有工具调用都有对应 tool result。
- 后台 execution 都处于 terminal 状态,或被明确登记为仍在运行且任务状态为 `in_progress` / `blocked`
- Plan-Execute 没有未执行的 required step。
- Supervisor 没有未汇总的子代理结果。
- 在 evidence-required 策略下,至少存在可查询到的 completed 工具执行证据。
### 5. 子代理输出只作证据
子代理返回不能直接成为用户最终回复。主代理必须完成:
- 去重和冲突合并。
- 证据强度排序。
- 不确定性标注。
- 范围边界确认。
- 用户可读交付。
### 6. Prompt 只做软约束,代码做硬约束
Prompt 中可以写:
```text
Interim observations must be marked as progress, not final.
Do not produce a final answer until verification is complete.
```
但真正决定 final 的必须是后端字段和状态机。否则模型只要生成一段像最终结论的自然语言,UI 仍可能误判。
## 四、CyberStrikeAI 当前落地状态
当前项目已经具备一套显式 final gate:
- [internal/agentfinalizer/decision.go](../../internal/agentfinalizer/decision.go) 是唯一的最终回复决策契约。
- [internal/handler/finalization_helpers.go](../../internal/handler/finalization_helpers.go) 负责把决策结果写入 `process_details`,并且只有 `Finalizable=true` 时才调用 `UpdateAssistantMessageFinalize`
- [internal/handler/eino_single_agent.go](../../internal/handler/eino_single_agent.go)、[internal/handler/multi_agent.go](../../internal/handler/multi_agent.go)、[internal/handler/workflow_integration.go](../../internal/handler/workflow_integration.go)、[internal/handler/batch_queue_executor.go](../../internal/handler/batch_queue_executor.go) 均已在收尾处接入 finalizer。
- [web/static/js/monitor.js](../../web/static/js/monitor.js) 只把 `data.finalized === true``response` 当最终回复;未最终化文本会显示为最终回复检查未通过。
- [web/static/js/webshell.js](../../web/static/js/webshell.js) 将流式正文标记为候选输出,只有 `response(finalized=true)` 才切换为完成态。
- [internal/agentfinalizer/decision_test.go](../../internal/agentfinalizer/decision_test.go) 覆盖 pending tool、HITL、空输出、证据策略要求但缺执行证据、失败证据不能支撑最终化、完成态证据可 final 等回归场景。
- [internal/handler/finalization_auto_continue.go](../../internal/handler/finalization_auto_continue.go) 在缺 completed 执行证据时最多自动续跑 2 段;续跑只恢复已有模型轨迹,不向 agent 注入新的 user/system 文案。
当前契约的核心规则:
1. **模型自然语言只是 candidate。**
`RunResult.Response` 不能直接升级为最终回复,必须经过 `agentfinalizer.Decide`
2. **所有 `response` 事件必须携带终态字段。**
至少包含 `finalized``finalizable``status``completionReason``evidenceVerified``evidenceRefs``pendingExecutionIds``missingChecks`
3. **未完成工具会阻断 final。**
`queued/running` 工具执行仍存在时,决策结果为 `in_progress/pending_tool_executions`
4. **执行证据必须由结构化策略声明。**
后端不从用户自然语言、助手回复或 agent mode 名称中推断执行意图。聊天请求通过 `finalization.requireExecutionEvidence` 显式声明;WebShell、Workflow、批量、机器人等执行入口由调用点显式传入 policy。policy 要求证据时,至少需要一个可查询到的 `completed` 工具执行记录;只有 failed/cancelled 记录不能支撑最终化。
5. **缺执行证据先工程续跑,再阻断。**
Eino 单代理和 Eino 多代理主链路在 `missing_execution_evidence` 时会先通过已有 trace 自动续跑,不注入额外上下文;达到续跑上限后仍缺证据才写入 blocked。
6. **HITL 和空输出不会 final。**
workflow 等待人工确认、空 assistant 文本、Eino 空输出占位均会写入阻断文案,而不是成功总结。
## 五、贴合当前项目的推荐架构
当前采用的链路是:
```text
Agent / Eino ADK events
-> event normalizer
-> process_details
-> finalization verifier
-> response(finalized=true)
-> messages.content
```
### 1. 后端统一 Finalizer
职责:
- 接收 `RunResult` / 候选文本、`mcpExecutionIds`、会话与助手消息 ID、HITL 状态、编排模式。
- 通过数据库查询工具执行状态,识别 pending、completed、failed、cancelled 等证据状态。
- 返回 `FinalizationDecision`
- 不调用高风险工具,只做状态和证据检查。
### 2. RunResult 终态字段
[internal/multiagent/runner.go](../../internal/multiagent/runner.go) 已扩展终态字段:
```go
type RunResult struct {
Response string
MCPExecutionIDs []string
LastAgentTraceInput string
LastAgentTraceOutput string
Finalized bool
Status string
CompletionReason string
EvidenceVerified bool
EvidenceRefs []string
PendingExecutionIDs []string
MissingChecks []string
}
```
### 3. 发送 `response` 的条件
在单代理、多代理、工作流、批处理收尾处统一执行:
```go
decision := h.finalizeAgentRunForDelivery(...)
if !decision.Finalizable {
sendEvent("finalization_check", "任务尚未达到最终回复条件", decision)
sendEvent("response", finalizationBlockedMessage(decision), finalizationResponsePayload(decision, extra))
return
}
sendEvent("response", decision.FinalText, finalizationResponsePayload(decision, extra))
```
### 4. 前端只信 `finalized=true`
在 [web/static/js/monitor.js](../../web/static/js/monitor.js) 的 `case 'response'` 中执行硬判断:
```js
const responseFinalized = isFinalizedResponseData(responseData);
const bubbleText = responseFinalized
? resolvedResponseText
: (event.message || '任务尚未达到最终回复条件,暂不生成成功结论。');
markAssistantFinalizationState(assistantIdFinal, responseData);
```
WebShell 侧同理:`response_delta` 可以用于实时预览,但 UI 文案应标记为“执行中输出”,只有最终 `response(finalized=true)` 才显示为完成态。
### 5. 各模式 final gate
| 模式 | 谁可以产出最终候选 | 谁决定 final | 必须检查 |
|---|---|---|---|
| Eino 单代理 | 单代理最后助手文本 | Finalizer | 无 pending tool、证据引用完整、任务状态 terminal |
| Deep | 主代理汇总文本 | Finalizer | 子代理结果已汇总;子代理文本不能直接 final;工具状态 terminal |
| Plan-Execute | Replanner 结束后的汇总文本 | Replanner + Finalizer | Executor 单步输出不能 final;计划步骤完成或明确 blocked |
| Supervisor | Supervisor 的 `exit` / 汇总文本 | Supervisor + Finalizer | transfer 已返回;无未处理专家结果;最终由 supervisor 统一口径 |
### 6. 安全测试场景的证据 gate
安全测试、WebShell、批量验证、Workflow 和多代理执行等 evidence-required 场景,最终回复必须至少满足:
- 有明确目标和授权范围标识。
- 有可复核证据引用,例如工具 execution id、请求/响应摘要、截图路径、命令输出摘要、事实/漏洞记录 ID。
- 有身份或影响验证结果,而不是只凭 marker 文本判断。
- 已记录到项目黑板或漏洞库,或明确说明未绑定项目导致无法记录。
- 高风险动作已清理、回滚、取消,或明确说明未执行清理的原因。
- 仍在运行的扫描/命令/WebShell/C2 任务不能被隐式当作完成。
注意:这里的 gate 是治理规则,不要求最终报告暴露敏感利用细节;可以只给证据摘要和内部引用。
## 六、落地状态与后续增强
### P0:先修“误 final”(已落地)
1. 已引入 `FinalizationDecision`
2. 主要 agent SSE `response` 事件已携带 `data.finalized/finalizable/status/completionReason` 等字段。
3. 前端 `monitor.js``webshell.js` 已按 `finalized=true` 区分候选输出和最终回复。
4. `RunResult.Response` 仍保留兼容字段名,但语义已由 finalizer 统一提升;后续可再拆成 `CandidateResponse` / `FinalResponse`,减少误用空间。
5. Plan-Execute / Deep / Supervisor / Eino Single 等模式均通过统一 handler 收尾 gate。
### P1:补证据链(部分落地)
1. 已用 `mcp_execution:<id>` 作为基础 evidence refs。
2. `finalization_check` 事件已展示 pending execution 与 missing checks。
3. 执行入口已启用显式 execution evidence policyEino 主链路在 policy 要求证据且缺少 completed 工具证据时先无注入续跑,达到上限后才阻断 final。
4. 后续建议:为 `record_vulnerability``upsert_project_fact`、项目黑板记录建立更细粒度 evidence refs。
5. 后续建议:最终报告模板固定包含“结论、证据、风险/不确定性、后续动作”。
### P2:体验和观测(后续增强)
1. 在任务卡片展示 `in_progress / verifying / finalizing / completed / blocked`
2. 为 finalizer 加日志和指标:误拦截率、缺失证据类型、pending tool 数量。
3. 支持“继续验证”按钮,从 `FinalizationDecision.MissingChecks` 自动生成下一轮输入。
## 七、验收测试建议
至少加入这些回归测试:
1. **推理文本不 final**
模拟 `reasoning_chain` 里出现看似完成的候选结论,但本轮没有 completed 工具执行证据;预期主消息气泡不显示成功结论,只显示执行中或阻断态。
2. **主代理阶段性输出不 final**
模拟 `response_start/delta` 输出“下一步继续验证”;预期只进入 timeline `planning`
3. **未完成后台工具不 final**
工具返回 `execution_id` 且状态 `running`;即使模型给出总结,也只能 `in_progress`
4. **Plan-Execute Executor 输出不 final**
Executor 输出“突破成功”,但 Replanner 未结束;预期不触发 `messages.content` finalize。
5. **Supervisor 子代理输出不 final**
子代理返回确定结论,Supervisor 未 `exit`;预期只进入 `eino_agent_reply`
6. **最终事件必须带 finalized**
前端收到旧格式 `response``finalized=true`;预期候选内容只进入详情/警告,主消息显示阻断态,不创建成功最终气泡。
7. **失败和取消可终态**
`error` / `cancelled` 仍可更新助手消息,但 `completionReason` 必须是 `failed` / `user_cancelled`,不能伪装为成功完成。
## 八、推荐默认策略
对 CyberStrikeAI,建议默认策略是:
```text
eino_single:轻量任务可用,但 final gate 必须开启
deep:复杂安全测试默认推荐
plan_execute:目标明确、需要严格“规划-执行-重规划”的任务推荐
supervisor:多专家路由任务使用,不作为默认泛化模式
```
最终治理一句话:
```text
messages.content 只能来自 FinalizationDecision.FinalText
process_details 可以展示所有过程;
前端只能把 response(finalized=true) 当最终回复。
```
+31
View File
@@ -52,6 +52,18 @@ Content-Type: application/json
- `plan_execute`
- `supervisor`
常用请求体字段:
| 字段 | 说明 |
| --- | --- |
| `message` | 用户消息,必填。 |
| `conversationId` | 继续已有对话;为空时创建新对话。 |
| `projectId` | 新对话绑定项目;为空时可跟随 `config.project.default_project_id`。 |
| `role` | 使用指定角色。 |
| `aiChannelId` | 选择 `ai.channels` 中的通道 ID;为空时使用 `ai.default_channel`。 |
| `reasoning` | 会话级推理覆盖,受通道 `reasoning.allow_client_reasoning` 控制。 |
| `hitl` | 会话级人机协同配置。 |
对话管理:
- `POST /api/conversations`
@@ -62,6 +74,25 @@ Content-Type: application/json
- `POST /api/conversations/:id/delete-turn`
- `GET /api/messages/:id/process-details`
## 文件管理来源
文件管理页面和 `/api/chat-uploads` 列表接口会把对话相关文件按来源归类。底层目录仍使用项目 ID 或会话 ID 保持稳定,界面会优先显示项目名或对话标题,完整 ID 可在提示或路径中查看。
| 来源 | `source` | 典型目录 | 说明 | 可变更性 |
| --- | --- | --- | --- | --- |
| 工作目录 | `workspace` | `tmp/workspace/projects/<projectId>/...``tmp/workspace/conversations/<conversationId>/...` | Agent 执行任务时保存下载文件、分析脚本、中间结果和生成的 CSV/XLSX/Markdown 等。用户反馈“AI 生成的文件找不到”时,通常先看这里。 | 只读展示;支持复制路径、下载、导出。 |
| 会话产物 | `conversation_artifact` | `data/conversation_artifacts/<conversationId>/...` | 系统按会话归档的交付物或会话级产物,例如总结、报告、模型中间件生成的归档内容。 | 只读展示;支持复制路径、下载、导出。 |
| 工具输出 | `reduction` | `tmp/reduction/projects/<projectId>/...``tmp/reduction/conversations/<conversationId>/...` | 超长工具输出、扫描原文或被截断前落盘的结果缓存。适合回看完整工具输出。 | 只读展示;支持复制路径、下载、导出。 |
| 对话附件 | `upload` | `chat_uploads/<date>/<conversationId>/...` | 用户在对话或文件管理页手动上传的附件。需要让 AI 引用某文件时,可复制服务器绝对路径粘贴到对话中。 | 可上传、新建目录、编辑文本文件、重命名、删除、复制路径、下载、导出。 |
相关接口:
- `GET /api/chat-uploads`:按来源、项目、会话、文件名筛选文件。
- `GET /api/chat-uploads/path`:把文件管理中的相对路径或内部虚拟路径解析为服务器绝对路径,用于复制文件或目录路径。
- `GET /api/chat-uploads/download`:下载指定文件。
- `GET /api/chat-uploads/export`:导出当前筛选结果为 ZIP。
- `POST /api/chat-uploads`:上传到对话附件目录。
## 项目、漏洞、攻击链
项目:
+50 -18
View File
@@ -29,26 +29,57 @@ log:
- `auth.session_duration_hours`:登录会话有效期(小时)。登录密码由 RBAC 用户管理,首次启动时在控制台输出 `admin` 初始密码。
- `log.output`:可以是 `stdout``stderr` 或文件路径。
## 模型配置
## AI 通道与模型配置
```yaml
openai:
provider: openai
base_url: https://api.openai.com/v1
api_key: sk-...
model: gpt-4.1
max_total_tokens: 120000
reasoning:
mode: on
effort: high
allow_client_reasoning: true
profile: openai_compat
ai:
default_channel: openai-main
channels:
openai-main:
name: OpenAI Main
provider: openai_compatible
base_url: https://api.openai.com/v1
api_key: sk-...
model: gpt-4.1
max_total_tokens: 120000
max_completion_tokens: 16384
reasoning:
mode: on
effort: high
allow_client_reasoning: true
profile: openai_compat
claude-main:
name: Claude Main
provider: claude
base_url: https://api.anthropic.com/v1
api_key: sk-ant-...
model: claude-sonnet-4-5
```
- `provider``openai` 表示 OpenAI 兼容接口;`claude` 会桥接到 Anthropic Claude Messages API
- `base_url/api_key/model`:主模型配置。
- `max_total_tokens`:上下文压缩、攻击链构建、多代理摘要等共用的总预算。
- `reasoning`:控制推理扩展字段。不同网关支持差异较大,异常时先尝试 `mode: off`
`ai` 是推荐的模型配置入口。系统设置页对应路径是 **系统设置 → 基本设置 → AI 通道配置**,保存后写入 `ai.default_channel``ai.channels`。旧版 `openai` 字段仍保留为兼容运行时字段;加载配置时会确保至少有一个默认通道,并把 `ai.default_channel` 解析后的配置同步到运行时 `openai`
通道字段:
| 字段 | 说明 |
| --- | --- |
| `ai.default_channel` | 默认通道 ID。新对话、机器人、批量任务和未显式选择通道的请求使用它。 |
| `ai.channels.<id>` | 通道配置。ID 会归一化为小写、数字和短横线,例如 `Qwen_Max` 会变成 `qwen-max`。 |
| `name` | Web UI 展示名。留空时使用通道 ID。 |
| `provider` | `openai_compatible``claude``openai_compatible` 会在运行时映射为 `openai``claude` 会桥接到 Anthropic Messages API。 |
| `base_url/api_key/model` | 必填。Base URL 通常需要包含版本路径,如 OpenAI/兼容网关的 `/v1`。 |
| `max_total_tokens` | 上下文压缩、攻击链构建、多代理摘要等共用的总预算。 |
| `max_completion_tokens` | 单次模型输出上限;未填时使用默认值。 |
| `reasoning` | 该通道的默认推理扩展字段。不同网关支持差异较大,异常时先尝试 `mode: off`。 |
对话页的“AI 通道”下拉框会读取已保存通道。请求体中的 `aiChannelId` 非空时仅对本次/本会话运行配置生效,不会把 API Key 发送给模型;为空时跟随 `ai.default_channel`
常用操作:
- 新增:点击左侧 `+`,填写必填字段后保存。
- 设默认:选中通道后点击“设为默认”,保存并应用后新请求生效。
- 复制:以当前表单内容创建副本,适合为同一服务商配置不同模型。
- 删除:默认通道不能作为批量删除目标;删除后需保留至少一个通道。
- 探活:单通道“测试连接”或左侧“批量探活”会调用模型测试接口,适合验证 Key、Base URL 和模型名。
## Agent
@@ -213,7 +244,8 @@ project:
| 配置段 | 应用后通常立即生效 | 需要额外动作 |
| --- | --- | --- |
| `openai` | 新请求使用新模型配置 | 旧的流式请求不会被强制切换 |
| `ai.default_channel` / `ai.channels` | 新请求使用解析后的默认或选定通道 | 旧的流式请求不会被强制切换;前端通道列表需要重新读取配置 |
| `openai` | 兼容字段;通常由默认 AI 通道同步 | 新配置优先维护 `ai.channels` |
| `agent.max_iterations` | 新 Agent 任务生效 | 已运行任务按启动时状态继续 |
| `security.tool_description_mode` | 工具重新暴露时生效 | 模型已有上下文不会回滚 |
| `hitl.tool_whitelist` | 新工具调用审批判断生效 | 已挂起审批不自动重判 |
@@ -228,7 +260,7 @@ project:
几个字段有“留空复用”的关系:
- `vision.api_key/base_url/provider` 留空时复用 `openai`
- `hitl.audit_model` 留空时复用 `openai`
- `hitl.audit_model` 留空时复用默认 AI 通道解析后的 `openai`
- `knowledge.embedding.base_url/api_key` 留空时复用主模型或 embedding 默认配置。
- `knowledge.retrieval.rerank.base_url/api_key` 留空时复用 embedding/openai。
- `database.knowledge_db_path` 留空时可以复用主会话数据库,但独立文件更利于备份。
+1 -1
View File
@@ -10,7 +10,7 @@
- Python:部分 MCP 服务或工具脚本需要 Python 运行环境。
- SQLite:默认使用文件型数据库,无需单独服务。
- 安全工具:`tools/` 中的 YAML 只是工具定义,实际命令如 `nmap``sqlmap``nuclei` 仍需安装到系统 PATH。
- 模型服务:需要 OpenAI 兼容 API或配置 `openai.provider: claude` 走 Claude 桥接。
- 模型服务:至少配置一个 `ai.channels` 通道;`provider: openai_compatible` 适用于 OpenAI 兼容 API`provider: claude` 走 Claude 桥接。
建议目录:
+2 -2
View File
@@ -23,12 +23,12 @@ hitl:
provider: ""
base_url: ""
api_key: ""
model: "" # 可填小模型;留空复用 openai.model
model: "" # 可填小模型;留空复用默认 AI 通道的模型
retention_days: 90
tool_whitelist: [read_file, list_dir, glob, grep, tool_search]
```
`audit_model` 的字段可以只填一部分。空字段会自动继承`openai` 配置,因此常见做法是只填 `model`,让审计 Agent 使用更便宜的小模型。
`audit_model` 的字段可以只填一部分。空字段会自动继承默认 AI 通道解析后的模型配置,因此常见做法是只填 `model`,让审计 Agent 使用更便宜的小模型。
## 推荐审批策略
+223
View File
@@ -0,0 +1,223 @@
# 工具执行治理
[返回中文文档](README.md)
本文说明 CyberStrikeAI 对长时间工具、MCP 阻塞、大输出、取消和恢复上下文的治理策略。目标是让 Agent 保持标准工具语义,同时避免工具卡死、上下文爆炸、数据库膨胀或恢复时重新注入历史大输出。
## 设计目标
- **Agent 不被工具绑死**:工具调用可以很慢,但当前 runner 只等待有限时间。
- **长任务可继续观察**:超时返回 `execution_id`,后续可用 `wait_tool_execution` 多轮等待。
- **用户和 Agent 都能取消**:当前会话结束或用户停止任务时,会取消仍在运行的工具。
- **数据库与 Agent 视图一致**:DB 保存的是 Agent 实际拿到的兜底后结果,不再保存另一份原始大输出。
- **恢复不会撑爆上下文**:续跑使用 model-facing trace;历史异常大 tool trace 恢复时也会再次裁剪。
- **外部 MCP 有隔离保护**:按 server 限并发、按全局限并发,并对连续失败的 server 熔断。
## 执行模型
普通工具调用仍然对 Eino/Agent 表现为一次标准 tool call,但底层执行分为两段:
```text
Agent 调用工具
-> ExecutionService 创建 execution
-> worker 执行真实 MCP/工具调用
-> Agent bounded wait
-> 完成:返回工具结果
-> 未完成:返回 execution_idworker 继续后台运行
```
这解决了 MCP server、`exec``sqlmap``nmap``nuclei` 等长任务阻塞当前 runner 的问题。
## 工具状态语义
| 状态 | 含义 |
|---|---|
| `queued` | execution 已创建,等待 worker 或并发槽位 |
| `running` | worker 正在执行 |
| `background_running` | 前端展示状态,表示本轮 Agent 已停止等待,但后台仍在跑 |
| `completed` | 本次 tool call 本身已完成 |
| `failed` | 工具真实失败 |
| `cancelled` | 用户、Agent 或会话清理主动取消 |
| `hard_timeout` | 超过硬超时,被系统终止 |
| `orphaned` | 重启/异常后发现 DB 中仍是 running,但运行时已无对应 worker |
注意:`wait_tool_execution` 到达 `timeout_seconds` 时,如果目标 execution 仍在运行,**这次 wait 调用本身是完成的观察动作**,不是工具执行失败。返回体会说明目标仍为 `running`,前端不应显示为红色失败。
## 控制工具
| 工具 | 用途 |
|---|---|
| `get_tool_execution` | 读取 execution 当前状态 |
| `wait_tool_execution` | 等待指定 execution 一段时间 |
| `cancel_tool_execution` | 主动取消指定 execution |
`get_tool_execution``wait_tool_execution` 支持返回运行中输出预览:
- `include_partial_output`:是否返回 partial output,默认 `true`
- `partial_output_max_bytes`:本次返回的尾部预览上限,默认 `4096`,最大 `65536`
partial output 是“已产生输出的有界预览”,不等同于最终 `result`。最终 `result` 仍只在工具结束时写入 canonical execution 记录;不支持流式输出的工具不会返回 partial 字段。
典型流程:
```text
1. 调用 exec/sqlmap/nmap 等长任务
2. 超过 tool_wait_timeout_seconds 后拿到 execution_id
3. Agent 可继续推理、改用其他工具,或调用 wait_tool_execution
4. 仍未完成时可继续等待,或调用 cancel_tool_execution
```
`tool_wait_timeout_seconds` 适用于内部 MCP、外部 MCP,以及 Eino filesystem 的流式 `execute`。Eino 的 `ls/read_file/write_file/edit_file/glob/grep` 等非流式 filesystem 工具会写入 execution 监控记录,但不作为后台 worker 做软等待续跑。
## 取消和会话清理
- 用户点击“停止任务”时,会取消当前会话仍在运行的工具。
- 会话正常结束后,会批量取消当前会话仍 `running` 的工具。
- “中断并继续”类流程不会做会话级批量取消,以免误杀后续需要等待的 worker。
- 取消只针对当前 conversation 绑定的 execution,不会误杀其他会话的工具。
## 外部 MCP 隔离
外部 MCP 可能因为远端 server 卡住、断连或返回异常而拖慢 Agent。系统提供三层保护:
| 能力 | 配置 | 说明 |
|---|---|---|
| 单 server 并发限制 | `external_mcp_max_concurrent_per_server` | 同一个外部 MCP server 同时运行的工具数 |
| 全局并发限制 | `external_mcp_max_concurrent_total` | 所有外部 MCP 工具总并发 |
| 熔断 | `external_mcp_circuit_failure_threshold` / `external_mcp_circuit_cooldown_seconds` | 单 server 连续失败后短期快速失败,避免反复打坏 server |
推荐默认:
```yaml
agent:
external_mcp_max_concurrent_per_server: 2
external_mcp_max_concurrent_total: 16
external_mcp_circuit_failure_threshold: 3
external_mcp_circuit_cooldown_seconds: 60
```
## 输出兜底
系统使用 `multi_agent.eino_middleware.reduction_max_length_for_trunc` 作为统一工具结果上限。当前示例配置为 50000 bytes。
```yaml
multi_agent:
eino_middleware:
reduction_enable: true
reduction_max_length_for_trunc: 50000
```
兜底覆盖:
| 渠道 | 行为 |
|---|---|
| Agent 实际拿到的工具结果 | 使用兜底后的 canonical result |
| DB/监控存储 | 保存同一份 canonical result |
| `get_tool_execution` / `wait_tool_execution` | 读取同一份 canonical result |
| Eino `execute` / filesystem 监控记录 | 完成记录前统一兜底 |
| 非流式 `exec` stdout/stderr | 源头 bounded buffer |
| 流式 `exec` stdout/stderr | 推送给前端的累计输出也受上限控制 |
| PTY 执行路径 | 同样受上限控制 |
| 前端详情弹窗 | 额外有 UI 展示截断保护 |
触发上限后,完整输出先写入本地 trunc 文件,Agent 侧只保留计入预算的 `<persisted-output>` 预览(含绝对路径)。因此阈值为 50000 时,上下文文本不会超过该上限。
示例:
```text
<persisted-output>
Output too large (200000). Full output saved to: /path/to/tmp/reduction/conversations/<id>/trunc/<execution_id>
Use read_file with offset/limit to read parts of the file.
Preview (first …):
Preview (last …):
</persisted-output>
```
当前策略是「全文落盘 + 上下文预览」:超过 `reduction_max_length_for_trunc` 时,完整输出写入本地文件(默认 `tmp/reduction/conversations/<会话ID>/trunc/<execution_id>`),Agent/DB/监控拿到的是带绝对路径的 `<persisted-output>` 预览;可用 `read_file` 按 offset/limit 回读全文。
## DB 与恢复上下文
新执行结果的写入路径如下:
```text
工具完成
-> NormalizeToolResultForStorage
-> 写入内存 execution
-> 写入 DB
-> 返回给 Agent
```
因此正常情况下,DB 中保存的就是 Agent 拿到的结果。
续跑恢复时,系统使用 `LastAgentTraceInput` 中的 model-facing trace,也就是实际送入 ChatModel 的消息快照,而不是原始事件流累计。恢复入口还会对历史 tool 内容再次应用上限,防止以下情况撑爆上下文:
- 升级前 DB 已经存过原始大输出。
- 手工迁移或导入的数据绕过了当前写入路径。
- 配置从更大阈值改成 50000。
- 未来某条旁路写入漏掉 canonicalize。
## 关键配置建议
长任务场景推荐:
```yaml
agent:
max_iterations: 800
tool_timeout_minutes: 60
tool_wait_timeout_seconds: 30
external_mcp_max_concurrent_per_server: 2
external_mcp_max_concurrent_total: 16
external_mcp_circuit_failure_threshold: 3
external_mcp_circuit_cooldown_seconds: 60
shell_no_output_timeout_seconds: 1200
multi_agent:
eino_middleware:
reduction_enable: true
reduction_max_length_for_trunc: 50000
```
参数说明:
| 参数 | 建议 | 说明 |
|---|---:|---|
| `max_iterations` | `300-1000` | 太大等于放弃循环保护 |
| `tool_timeout_minutes` | `60` | 单次工具硬超时,适合 sqlmap 等长任务 |
| `tool_wait_timeout_seconds` | `30-60` | Agent 本轮等待上限,到时返回 `execution_id` |
| `shell_no_output_timeout_seconds` | `600-1200` | 连续无输出时终止,防止静默挂死 |
| `reduction_max_length_for_trunc` | `50000` | 工具结果统一上限 |
不建议把 `tool_wait_timeout_seconds` 设置得很大。长任务应由 worker 后台跑,Agent 通过 `execution_id` 继续观察,而不是一轮等待数分钟。
## 测试建议
可以用以下对话测试长任务语义:
```text
调用 exec 执行 sleep 120;如果超过 10 秒还没完成,不要一直等,告诉我 execution_id,然后调用 wait_tool_execution 等 5 秒;如果仍未完成,再调用 cancel_tool_execution,最后说明状态。
```
可以用以下命令测试大输出兜底:
```text
调用 exec 执行:python3 - <<'PY'
print("A" * 200000)
PY
然后展示工具结果长度和是否包含截断提示。
```
预期:
- 初始长任务会返回 `execution_id`,状态为 `running` 或前端展示 `background_running`
- `wait_tool_execution` 等待到上限但目标未完成时,本次 wait 调用不应显示为执行失败。
- 大输出结果不会超过 `reduction_max_length_for_trunc`
- DB、监控详情、Agent 继续推理看到的是同一份兜底结果。
## 当前边界
- 外部 MCP 的远端 server 内部如何采集输出不由 CyberStrikeAI 控制;CyberStrikeAI 会在结果进入本系统后统一兜底、限并发和熔断。
- 超长工具输出会在截断前写入本地 `tmp/reduction/.../trunc/<id>`(或 `reduction_root_dir`),bounded result 中包含可 `read_file` 的绝对路径。
+20 -9
View File
@@ -32,29 +32,40 @@ https://127.0.0.1:8080/
如果仍有其他具备 `rbac:write` 权限的管理员账号,优先在 **平台权限 → 用户管理** 中重置密码。
如果没有可用的管理员会话,可在服务器上紧急重置内置 `admin` 账号。先停止 CyberStrikeAI 服务并备份数据库,然后在项目根目录执行以下命令,按提示输入并确认新密码
如果没有可用的管理员会话,可在服务器上紧急重置内置 `admin` 账号。在项目根目录执行
```bash
./run.sh --reset-admin-password
```
按提示输入并确认新密码。脚本会隐藏输入并写入 bcrypt 哈希。如果服务正在运行,完成后重新启动服务,使原有登录会话失效。
如果无法使用 `run.sh`,也可以手动执行以下命令,按提示输入并确认新密码:
```bash
HASH=$(htpasswd -nBC 10 '' | cut -d: -f2 | tr -d '\n') && sqlite3 data/conversations.db "UPDATE rbac_users SET password_hash='$HASH', updated_at=CURRENT_TIMESTAMP WHERE id='admin' AND username='admin' AND is_builtin=1; SELECT changes();"
```
输出 `1` 表示修改成功。该命令需要 `sqlite3``htpasswd`;如果 `config.yaml` 中的 `database.path` 不是默认值,请替换 `data/conversations.db`。密码输入不会显示,也不会写入 Shell 历史,并以 bcrypt 哈希保存。完成后重新启动服务,使原有登录会话失效
输出 `1` 表示修改成功。该命令需要 `sqlite3``htpasswd`;如果 `config.yaml` 中的 `database.path` 不是默认值,请替换 `data/conversations.db`。密码输入不会显示,也不会写入 Shell 历史。
## 模型无响应
检查:
- `openai.base_url` 是否包含正确路径,如 `/v1`
- `openai.api_key` 是否有效
- `openai.model` 是否存在
- 服务商是否支持当前 `reasoning` 字段
- 当前对话选择的 AI 通道是否存在;为空时会使用 `ai.default_channel`
- `ai.channels.<id>.base_url` 是否包含正确路径,如 `/v1`
- `ai.channels.<id>.api_key` 是否有效
- `ai.channels.<id>.model` 是否存在
- 服务商是否支持当前通道的 `reasoning` 字段。
可在系统设置中使用模型测试。若网关报 400,先尝试:
```yaml
openai:
reasoning:
mode: off
ai:
channels:
your-channel:
reasoning:
mode: off
```
## 流式输出中断
+1
View File
@@ -37,6 +37,7 @@ require (
go.opentelemetry.io/otel/trace v1.34.0
go.uber.org/zap v1.26.0
golang.org/x/net v0.35.0
golang.org/x/term v0.32.0
golang.org/x/text v0.26.0
golang.org/x/time v0.14.0
gopkg.in/yaml.v3 v3.0.1
+88 -49
View File
@@ -24,18 +24,17 @@ import (
// Agent AI代理
type Agent struct {
openAIClient *openai.Client
config *config.OpenAIConfig
agentConfig *config.AgentConfig
mcpServer *mcp.Server
externalMCPMgr *mcp.ExternalMCPManager // 外部MCP管理器
logger *zap.Logger
maxIterations int
mu sync.RWMutex // 添加互斥锁以支持并发更新
toolNameMapping map[string]string // 工具名称映射:OpenAI格式 -> 原始格式(用于外部MCP工具)
currentConversationID string // 当前对话ID(用于自动传递给工具
promptBaseDir string // 解析 system_prompt_path 时相对路径的基准目录(通常为 config.yaml 所在目录)
toolDescriptionMode string // 工具描述模式: "short" | "full",默认 short
openAIClient *openai.Client
config *config.OpenAIConfig
agentConfig *config.AgentConfig
mcpServer *mcp.Server
externalMCPMgr *mcp.ExternalMCPManager // 外部MCP管理器
logger *zap.Logger
maxIterations int
mu sync.RWMutex // 添加互斥锁以支持并发更新
toolNameMapping map[string]string // 工具名称映射:OpenAI格式 -> 原始格式(用于外部MCP工具)
promptBaseDir string // 解析 system_prompt_path 时相对路径的基准目录(通常为 config.yaml 所在目录
toolDescriptionMode string // 工具描述模式: "short" | "full",默认 short
}
type agentConversationIDKey struct{}
@@ -515,6 +514,14 @@ type ToolExecutionResult struct {
IsError bool
}
func buildToolFailureMessage(toolName, detail string, err error) string {
var b strings.Builder
fmt.Fprintf(&b, "工具调用失败\n\n")
fmt.Fprintf(&b, "工具名称: %s\n", toolName)
fmt.Fprintf(&b, "错误详情: %s", detail)
return strings.TrimRight(b.String(), "\n")
}
// executeToolViaMCP 通过MCP执行工具
// 即使工具执行失败,也返回结果而不是错误,让AI能够处理错误情况
func (a *Agent) executeToolViaMCP(ctx context.Context, toolName string, args map[string]interface{}) (*ToolExecutionResult, error) {
@@ -526,12 +533,6 @@ func (a *Agent) executeToolViaMCP(ctx context.Context, toolName string, args map
// 如果是record_vulnerability工具,自动添加conversation_id
if toolName == builtin.ToolRecordVulnerability {
conversationID := agentConversationIDFromContext(ctx)
if conversationID == "" {
a.mu.RLock()
conversationID = a.currentConversationID
a.mu.RUnlock()
}
if conversationID != "" {
args["conversation_id"] = conversationID
a.logger.Debug("自动添加conversation_id到record_vulnerability工具",
@@ -580,32 +581,16 @@ func (a *Agent) executeToolViaMCP(ctx context.Context, toolName string, args map
// 如果调用失败(如工具不存在、超时),返回友好的错误信息而不是抛出异常
if err != nil {
detail := err.Error()
timeoutMinutes := 10
if a.agentConfig != nil && a.agentConfig.ToolTimeoutMinutes > 0 {
timeoutMinutes = a.agentConfig.ToolTimeoutMinutes
}
if errors.Is(err, context.Canceled) {
detail = "工具调用已被手动终止(MCP 监控页)。智能体将携带此结果继续后续步骤,整条任务不会因此被停止。"
} else if errors.Is(err, context.DeadlineExceeded) {
min := 10
if a.agentConfig != nil && a.agentConfig.ToolTimeoutMinutes > 0 {
min = a.agentConfig.ToolTimeoutMinutes
}
detail = fmt.Sprintf("工具执行超过 %d 分钟被自动终止(可在 config.yaml 的 agent.tool_timeout_minutes 中调整)", min)
detail = fmt.Sprintf("工具执行超过 %d 分钟被自动终止(可在 config.yaml 的 agent.tool_timeout_minutes 中调整)", timeoutMinutes)
}
errorMsg := fmt.Sprintf(`工具调用失败
工具名称: %s
错误类型: 系统错误
错误详情: %s
可能的原因:
- 工具 "%s" 不存在或未启用
- 单次执行超时(agent.tool_timeout_minutes
- 系统配置问题
- 网络或权限问题
建议:
- 检查工具名称是否正确
- 若需更长执行时间,可适当增大 agent.tool_timeout_minutes
- 尝试使用其他替代工具
- 如果这是必需的工具,请向用户说明情况`, toolName, detail, toolName)
errorMsg := buildToolFailureMessage(toolName, detail, err)
return &ToolExecutionResult{
Result: errorMsg,
@@ -769,16 +754,8 @@ func (a *Agent) ToolsForRole(roleTools []string) []Tool {
// ExecuteMCPToolForConversation 在指定会话上下文中执行 MCP 工具(行为与主 Agent 循环中的工具调用一致,如自动注入 conversation_id)。
func (a *Agent) ExecuteMCPToolForConversation(ctx context.Context, conversationID, toolName string, args map[string]interface{}) (*ToolExecutionResult, error) {
a.mu.Lock()
prev := a.currentConversationID
a.currentConversationID = conversationID
a.mu.Unlock()
defer func() {
a.mu.Lock()
a.currentConversationID = prev
a.mu.Unlock()
}()
ctx = withAgentConversationID(ctx, conversationID)
ctx = mcp.WithMCPConversationID(ctx, conversationID)
return a.executeToolViaMCP(ctx, toolName, args)
}
@@ -798,6 +775,28 @@ func (a *Agent) FinishLocalToolExecution(ctx context.Context, executionID, toolN
return a.mcpServer.FinishToolExecution(ctx, executionID, toolName, args, resultText, invokeErr)
}
// AppendLocalToolExecutionPartialOutput records a bounded live-output preview for a running local tool.
func (a *Agent) AppendLocalToolExecutionPartialOutput(executionID, chunk string) {
if a == nil || a.mcpServer == nil {
return
}
a.mcpServer.AppendToolExecutionPartialOutput(executionID, chunk)
}
func (a *Agent) RegisterLocalToolExecutionCancel(executionID string, cancel context.CancelFunc) {
if a == nil || a.mcpServer == nil {
return
}
a.mcpServer.RegisterToolExecutionCancel(executionID, cancel)
}
func (a *Agent) UnregisterLocalToolExecutionCancel(executionID string) {
if a == nil || a.mcpServer == nil {
return
}
a.mcpServer.UnregisterToolExecutionCancel(executionID)
}
// RecordLocalToolExecution 将非 CallTool 路径完成的工具调用写入 MCP 监控库(与 CallTool 落库一致),返回 executionId。
// 用于 Eino filesystem execute 等场景,使助手气泡「渗透测试详情」与常规 MCP 一致可点进监控。
func (a *Agent) RecordLocalToolExecution(ctx context.Context, toolName string, args map[string]interface{}, resultText string, invokeErr error) string {
@@ -837,6 +836,46 @@ func (a *Agent) CancelMCPToolExecutionWithNote(executionID, note string) bool {
return false
}
// CancelRunningMCPToolsForConversation cancels all currently running internal/external MCP executions
// owned by the conversation. It is used when a session ends or the user stops a task.
func (a *Agent) CancelRunningMCPToolsForConversation(conversationID, note string) int {
conversationID = strings.TrimSpace(conversationID)
if a == nil || conversationID == "" {
return 0
}
note = strings.TrimSpace(note)
seen := make(map[string]struct{})
cancelled := 0
cancelIfConversationMatches := func(execID string, get func(string) (*mcp.ToolExecution, bool), cancel func(string, string) bool) {
execID = strings.TrimSpace(execID)
if execID == "" {
return
}
if _, ok := seen[execID]; ok {
return
}
seen[execID] = struct{}{}
exec, ok := get(execID)
if !ok || exec == nil || strings.TrimSpace(exec.ConversationID) != conversationID {
return
}
if cancel(execID, note) {
cancelled++
}
}
if a.mcpServer != nil {
for execID := range a.mcpServer.ActiveRunningExecutionIDs() {
cancelIfConversationMatches(execID, a.mcpServer.GetExecution, a.mcpServer.CancelToolExecutionWithNote)
}
}
if a.externalMCPMgr != nil {
for execID := range a.externalMCPMgr.ActiveRunningExecutionIDs() {
cancelIfConversationMatches(execID, a.externalMCPMgr.GetExecution, a.externalMCPMgr.CancelToolExecutionWithNote)
}
}
return cancelled
}
// extractQuotedToolName 尝试从错误信息中提取被引用的工具名称
func extractQuotedToolName(errMsg string) string {
start := strings.Index(errMsg, "\"")
+218
View File
@@ -1,10 +1,16 @@
package agent
import (
"context"
"errors"
"strings"
"sync"
"testing"
"time"
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/mcp"
"cyberstrike-ai/internal/mcp/builtin"
"go.uber.org/zap"
)
@@ -65,3 +71,215 @@ func TestAgent_NewAgent_CustomConfig(t *testing.T) {
t.Errorf("迭代次数不匹配。期望: 15, 实际: %d", agent.maxIterations)
}
}
func TestBuildToolFailureMessageAuthorizationDenied(t *testing.T) {
msg := buildToolFailureMessage(
"list_project_facts",
"tool authorization denied: no access to project",
errors.New("tool authorization denied: no access to project"),
)
for _, want := range []string{
"工具名称: list_project_facts",
"错误详情: tool authorization denied: no access to project",
} {
if !strings.Contains(msg, want) {
t.Fatalf("message missing %q:\n%s", want, msg)
}
}
for _, notWant := range []string{
"可能的原因",
"建议",
"错误类型",
"工具 \"list_project_facts\" 不存在或未启用",
"单次执行超时",
} {
if strings.Contains(msg, notWant) {
t.Fatalf("message should not include generic hint %q:\n%s", notWant, msg)
}
}
}
func TestBuildToolFailureMessageCanceled(t *testing.T) {
msg := buildToolFailureMessage(
"long_running_tool",
"工具调用已被手动终止(MCP 监控页)。智能体将携带此结果继续后续步骤,整条任务不会因此被停止。",
context.Canceled,
)
for _, want := range []string{
"工具名称: long_running_tool",
"错误详情: 工具调用已被手动终止",
} {
if !strings.Contains(msg, want) {
t.Fatalf("message missing %q:\n%s", want, msg)
}
}
}
func TestBuildToolFailureMessageDeadlineExceeded(t *testing.T) {
msg := buildToolFailureMessage(
"nmap",
"工具执行超过 15 分钟被自动终止(可在 config.yaml 的 agent.tool_timeout_minutes 中调整)",
context.DeadlineExceeded,
)
for _, want := range []string{
"工具名称: nmap",
"错误详情: 工具执行超过 15 分钟被自动终止",
} {
if !strings.Contains(msg, want) {
t.Fatalf("message missing %q:\n%s", want, msg)
}
}
}
func TestBuildToolFailureMessageUnknownKeepsGenericFallback(t *testing.T) {
msg := buildToolFailureMessage("custom_tool", "dial tcp: connection reset by peer", errors.New("dial tcp: connection reset by peer"))
for _, want := range []string{
"工具名称: custom_tool",
"错误详情: dial tcp: connection reset by peer",
} {
if !strings.Contains(msg, want) {
t.Fatalf("message missing %q:\n%s", want, msg)
}
}
}
func TestAgentCancelRunningMCPToolsForConversation(t *testing.T) {
ag := setupTestAgent(t)
ag.mcpServer.ConfigureToolWaitTimeoutSeconds(1)
ag.mcpServer.RegisterTool(mcp.Tool{Name: "block", InputSchema: map[string]interface{}{"type": "object"}}, func(ctx context.Context, args map[string]interface{}) (*mcp.ToolResult, error) {
<-ctx.Done()
return nil, ctx.Err()
})
ctx1 := mcp.WithMCPConversationID(context.Background(), "conv-1")
result1, execID1, err := ag.mcpServer.CallTool(ctx1, "block", nil)
if err != nil {
t.Fatalf("CallTool conv-1: %v", err)
}
if result1 == nil || !result1.IsError || execID1 == "" {
t.Fatalf("expected bounded wait for conv-1, result=%#v id=%q", result1, execID1)
}
ctx2 := mcp.WithMCPConversationID(context.Background(), "conv-2")
result2, execID2, err := ag.mcpServer.CallTool(ctx2, "block", nil)
if err != nil {
t.Fatalf("CallTool conv-2: %v", err)
}
if result2 == nil || !result2.IsError || execID2 == "" {
t.Fatalf("expected bounded wait for conv-2, result=%#v id=%q", result2, execID2)
}
if got := ag.CancelRunningMCPToolsForConversation("conv-1", "session ended"); got != 1 {
t.Fatalf("cancelled count = %d, want 1", got)
}
deadline := time.Now().Add(time.Second)
for time.Now().Before(deadline) {
exec1, _ := ag.mcpServer.GetExecution(execID1)
exec2, _ := ag.mcpServer.GetExecution(execID2)
if exec1 != nil && exec1.Status == mcp.ToolExecutionStatusCancelled {
if exec2 == nil || exec2.Status != mcp.ToolExecutionStatusRunning {
t.Fatalf("conv-2 execution should remain running, got %#v", exec2)
}
if !strings.Contains(exec1.Error, "session ended") && (exec1.Result == nil || !strings.Contains(mcp.ToolResultPlainText(exec1.Result), "session ended")) {
t.Fatalf("cancel note missing from conv-1 execution: %#v", exec1)
}
_ = ag.CancelRunningMCPToolsForConversation("conv-2", "")
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatal("conv-1 execution did not become cancelled")
}
func TestExecuteMCPToolForConversationInjectsConversationID(t *testing.T) {
ag := setupTestAgent(t)
gotArgs := make(chan map[string]interface{}, 1)
ag.mcpServer.RegisterTool(mcp.Tool{Name: builtin.ToolRecordVulnerability, InputSchema: map[string]interface{}{"type": "object"}}, func(ctx context.Context, args map[string]interface{}) (*mcp.ToolResult, error) {
gotArgs <- args
return &mcp.ToolResult{Content: []mcp.Content{{Type: "text", Text: "ok"}}}, nil
})
result, err := ag.ExecuteMCPToolForConversation(context.Background(), "conv-record", builtin.ToolRecordVulnerability, map[string]interface{}{})
if err != nil {
t.Fatalf("ExecuteMCPToolForConversation: %v", err)
}
if result == nil || result.IsError {
t.Fatalf("expected successful result, got %#v", result)
}
select {
case args := <-gotArgs:
if got := args["conversation_id"]; got != "conv-record" {
t.Fatalf("conversation_id = %#v, want conv-record", got)
}
case <-time.After(time.Second):
t.Fatal("tool was not called")
}
}
func TestExecuteMCPToolForConversationBindsExecutionConversation(t *testing.T) {
ag := setupTestAgent(t)
ag.mcpServer.ConfigureToolWaitTimeoutSeconds(1)
release := make(chan struct{})
ag.mcpServer.RegisterTool(mcp.Tool{Name: "slow-bind", InputSchema: map[string]interface{}{"type": "object"}}, func(ctx context.Context, args map[string]interface{}) (*mcp.ToolResult, error) {
select {
case <-release:
return &mcp.ToolResult{Content: []mcp.Content{{Type: "text", Text: "done"}}}, nil
case <-ctx.Done():
return nil, ctx.Err()
}
})
result, err := ag.ExecuteMCPToolForConversation(context.Background(), "conv-bound", "slow-bind", nil)
if err != nil {
t.Fatalf("ExecuteMCPToolForConversation: %v", err)
}
if result == nil || !result.IsError || result.ExecutionID == "" {
t.Fatalf("expected bounded wait result with execution id, result=%#v", result)
}
exec, ok := ag.mcpServer.GetExecution(result.ExecutionID)
if !ok || exec == nil {
t.Fatalf("missing execution %q", result.ExecutionID)
}
if exec.ConversationID != "conv-bound" {
t.Fatalf("execution conversation = %q, want conv-bound", exec.ConversationID)
}
close(release)
}
func TestExecuteMCPToolForConversationConcurrentRecordIsolation(t *testing.T) {
ag := setupTestAgent(t)
seen := make(chan string, 2)
ag.mcpServer.RegisterTool(mcp.Tool{Name: builtin.ToolRecordVulnerability, InputSchema: map[string]interface{}{"type": "object"}}, func(ctx context.Context, args map[string]interface{}) (*mcp.ToolResult, error) {
if conv, _ := args["conversation_id"].(string); conv != "" {
seen <- conv
}
return &mcp.ToolResult{Content: []mcp.Content{{Type: "text", Text: "ok"}}}, nil
})
var wg sync.WaitGroup
for _, conv := range []string{"conv-a", "conv-b"} {
conv := conv
wg.Add(1)
go func() {
defer wg.Done()
if _, err := ag.ExecuteMCPToolForConversation(context.Background(), conv, builtin.ToolRecordVulnerability, map[string]interface{}{}); err != nil {
t.Errorf("ExecuteMCPToolForConversation %s: %v", conv, err)
}
}()
}
wg.Wait()
close(seen)
got := map[string]int{}
for conv := range seen {
got[conv]++
}
if got["conv-a"] != 1 || got["conv-b"] != 1 {
t.Fatalf("conversation ids = %#v, want one call for conv-a and conv-b", got)
}
}
+266
View File
@@ -0,0 +1,266 @@
package agentfinalizer
import (
"strings"
"cyberstrike-ai/internal/database"
"cyberstrike-ai/internal/mcp"
"cyberstrike-ai/internal/multiagent"
)
const (
StatusCompleted = "completed"
StatusInProgress = "in_progress"
StatusBlocked = "blocked"
StatusFailed = "failed"
StatusCancelled = "cancelled"
StatusAwaitingHITL = "awaiting_hitl"
ReasonVerified = "verified"
ReasonPendingTools = "pending_tool_executions"
ReasonEmptyResponse = "empty_response"
ReasonAwaitingHITL = "awaiting_hitl"
ReasonFailed = "failed"
ReasonCancelled = "cancelled"
ReasonMissingEvidence = "missing_execution_evidence"
)
// Decision is the single contract that may promote an agent run to a final
// user-facing answer. Natural-language assistant text is only a candidate until
// this object says Finalizable.
type Decision struct {
Status string `json:"status"`
Finalizable bool `json:"finalizable"`
Finalized bool `json:"finalized"`
CompletionReason string `json:"completionReason"`
FinalText string `json:"finalText,omitempty"`
EvidenceVerified bool `json:"evidenceVerified"`
EvidenceRefs []string `json:"evidenceRefs,omitempty"`
PendingExecutionIDs []string `json:"pendingExecutionIds,omitempty"`
PendingToolRuns []string `json:"pendingToolRuns,omitempty"`
MissingChecks []string `json:"missingChecks,omitempty"`
AgentMode string `json:"agentMode,omitempty"`
ConversationID string `json:"conversationId,omitempty"`
AssistantMessageID string `json:"messageId,omitempty"`
CandidateResponseLen int `json:"candidateResponseLen,omitempty"`
}
type Input struct {
Response string
MCPExecutionIDs []string
ConversationID string
AssistantMessageID string
AgentMode string
Status string
CompletionReason string
AwaitingHITL bool
RequireExecutionEvidence bool
}
func FromRunResult(db *database.DB, result *multiagent.RunResult, in Input) Decision {
if result != nil {
if strings.TrimSpace(in.Response) == "" {
in.Response = result.Response
}
if len(in.MCPExecutionIDs) == 0 {
in.MCPExecutionIDs = result.MCPExecutionIDs
}
if strings.TrimSpace(in.Status) == "" {
in.Status = result.Status
}
if strings.TrimSpace(in.CompletionReason) == "" {
in.CompletionReason = result.CompletionReason
}
}
d := Decide(db, in)
if result != nil {
result.Finalized = d.Finalized
result.Status = d.Status
result.CompletionReason = d.CompletionReason
result.EvidenceVerified = d.EvidenceVerified
result.EvidenceRefs = append([]string(nil), d.EvidenceRefs...)
result.PendingExecutionIDs = append([]string(nil), d.PendingExecutionIDs...)
result.MissingChecks = append([]string(nil), d.MissingChecks...)
}
return d
}
func Decide(db *database.DB, in Input) Decision {
text := strings.TrimSpace(in.Response)
status := strings.TrimSpace(in.Status)
if status == "" {
status = StatusCompleted
}
reason := strings.TrimSpace(in.CompletionReason)
if reason == "" {
reason = ReasonVerified
}
d := Decision{
Status: status,
CompletionReason: reason,
FinalText: text,
EvidenceVerified: true,
EvidenceRefs: evidenceRefs(in.MCPExecutionIDs),
AgentMode: strings.TrimSpace(in.AgentMode),
ConversationID: strings.TrimSpace(in.ConversationID),
AssistantMessageID: strings.TrimSpace(in.AssistantMessageID),
CandidateResponseLen: len([]rune(text)),
}
if in.AwaitingHITL {
d.Status = StatusAwaitingHITL
d.CompletionReason = ReasonAwaitingHITL
d.EvidenceVerified = false
d.MissingChecks = append(d.MissingChecks, "workflow is awaiting HITL approval")
return d
}
if isEmptyCandidate(text) {
d.Status = StatusBlocked
d.CompletionReason = ReasonEmptyResponse
d.EvidenceVerified = false
d.MissingChecks = append(d.MissingChecks, "assistant final text is empty or only an empty-response placeholder")
return d
}
switch status {
case StatusInProgress, StatusBlocked, StatusFailed, StatusCancelled, StatusAwaitingHITL:
d.Status = status
d.EvidenceVerified = false
if d.CompletionReason == ReasonVerified {
d.CompletionReason = status
}
d.MissingChecks = append(d.MissingChecks, "agent run status is "+status)
return d
}
pending := pendingExecutions(db, in.MCPExecutionIDs)
if len(pending) > 0 {
d.Status = StatusInProgress
d.CompletionReason = ReasonPendingTools
d.EvidenceVerified = false
d.PendingExecutionIDs = pending
d.PendingToolRuns = append([]string(nil), pending...)
d.MissingChecks = append(d.MissingChecks, "tool execution still queued or running")
return d
}
if in.RequireExecutionEvidence && !hasCompletedEvidence(db, in.MCPExecutionIDs) {
d.Status = StatusBlocked
d.CompletionReason = ReasonMissingEvidence
d.EvidenceVerified = false
d.MissingChecks = append(d.MissingChecks, "execution evidence is required but no completed tool execution was recorded")
return d
}
d.Finalizable = true
d.Finalized = true
d.Status = StatusCompleted
if d.CompletionReason == "" {
d.CompletionReason = ReasonVerified
}
return d
}
func ResponsePayload(d Decision, extra map[string]interface{}) map[string]interface{} {
out := map[string]interface{}{
"finalized": d.Finalized,
"finalizable": d.Finalizable,
"status": d.Status,
"completionReason": d.CompletionReason,
"evidenceVerified": d.EvidenceVerified,
"evidenceRefs": d.EvidenceRefs,
"pendingExecutionIds": d.PendingExecutionIDs,
"pendingToolRuns": d.PendingToolRuns,
"missingChecks": d.MissingChecks,
}
if d.ConversationID != "" {
out["conversationId"] = d.ConversationID
}
if d.AssistantMessageID != "" {
out["messageId"] = d.AssistantMessageID
}
if d.AgentMode != "" {
out["agentMode"] = d.AgentMode
}
for k, v := range extra {
out[k] = v
}
return out
}
func isEmptyCandidate(s string) bool {
s = strings.TrimSpace(s)
if s == "" {
return true
}
return strings.Contains(s, "no assistant text was captured") ||
strings.Contains(s, "未捕获到助手文本输出")
}
func evidenceRefs(ids []string) []string {
out := make([]string, 0, len(ids))
seen := make(map[string]struct{}, len(ids))
for _, id := range ids {
id = strings.TrimSpace(id)
if id == "" {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
out = append(out, "mcp_execution:"+id)
}
return out
}
func pendingExecutions(db *database.DB, ids []string) []string {
if db == nil || len(ids) == 0 {
return nil
}
out := make([]string, 0)
seen := make(map[string]struct{}, len(ids))
for _, id := range ids {
id = strings.TrimSpace(id)
if id == "" {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
exec, err := db.GetToolExecution(id)
if err != nil || exec == nil {
continue
}
switch strings.TrimSpace(exec.Status) {
case mcp.ToolExecutionStatusQueued, mcp.ToolExecutionStatusRunning:
out = append(out, id)
}
}
return out
}
func hasCompletedEvidence(db *database.DB, ids []string) bool {
if db == nil || len(ids) == 0 {
return false
}
seen := make(map[string]struct{}, len(ids))
for _, id := range ids {
id = strings.TrimSpace(id)
if id == "" {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
exec, err := db.GetToolExecution(id)
if err != nil || exec == nil {
continue
}
if strings.TrimSpace(exec.Status) == mcp.ToolExecutionStatusCompleted {
return true
}
}
return false
}
+132
View File
@@ -0,0 +1,132 @@
package agentfinalizer
import (
"path/filepath"
"testing"
"time"
"cyberstrike-ai/internal/database"
"cyberstrike-ai/internal/mcp"
"go.uber.org/zap"
)
func newDecisionTestDB(t *testing.T) *database.DB {
t.Helper()
db, err := database.NewDB(filepath.Join(t.TempDir(), "finalizer.db"), zap.NewNop())
if err != nil {
t.Fatalf("NewDB: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
return db
}
func saveDecisionTestExecution(t *testing.T, db *database.DB, id, status string) {
t.Helper()
if err := db.SaveToolExecution(&mcp.ToolExecution{
ID: id,
ToolName: "test::tool",
Arguments: map[string]interface{}{"input": id},
Status: status,
StartTime: time.Now(),
}); err != nil {
t.Fatalf("SaveToolExecution(%s): %v", id, err)
}
}
func TestDecideBlocksPendingToolExecutions(t *testing.T) {
db := newDecisionTestDB(t)
saveDecisionTestExecution(t, db, "run-queued", mcp.ToolExecutionStatusQueued)
saveDecisionTestExecution(t, db, "run-running", mcp.ToolExecutionStatusRunning)
saveDecisionTestExecution(t, db, "run-completed", mcp.ToolExecutionStatusCompleted)
d := Decide(db, Input{
Response: "工具还没全部结束时,这只是一段候选输出。",
MCPExecutionIDs: []string{"run-queued", "run-running", "run-completed"},
})
if d.Finalizable || d.Finalized {
t.Fatalf("pending tools should not be finalizable: %+v", d)
}
if d.Status != StatusInProgress || d.CompletionReason != ReasonPendingTools {
t.Fatalf("status/reason = %s/%s, want %s/%s", d.Status, d.CompletionReason, StatusInProgress, ReasonPendingTools)
}
if got, want := len(d.PendingExecutionIDs), 2; got != want {
t.Fatalf("pending execution count = %d, want %d (%v)", got, want, d.PendingExecutionIDs)
}
}
func TestDecideBlocksAwaitingHITLAndEmptyCandidate(t *testing.T) {
hitl := Decide(nil, Input{Response: "等待人工审批", AwaitingHITL: true})
if hitl.Finalizable || hitl.Status != StatusAwaitingHITL || hitl.CompletionReason != ReasonAwaitingHITL {
t.Fatalf("HITL decision mismatch: %+v", hitl)
}
empty := Decide(nil, Input{Response: "⚠️ Eino 执行完成,但未捕获到助手文本输出。"})
if empty.Finalizable || empty.Status != StatusBlocked || empty.CompletionReason != ReasonEmptyResponse {
t.Fatalf("empty candidate decision mismatch: %+v", empty)
}
}
func TestDecideBlocksWhenExecutionEvidenceIsRequiredButMissing(t *testing.T) {
d := Decide(nil, Input{
Response: "任务已处理完成。",
RequireExecutionEvidence: true,
})
if d.Finalizable || d.Finalized {
t.Fatalf("missing required execution evidence should not finalize: %+v", d)
}
if d.Status != StatusBlocked || d.CompletionReason != ReasonMissingEvidence {
t.Fatalf("status/reason = %s/%s, want %s/%s", d.Status, d.CompletionReason, StatusBlocked, ReasonMissingEvidence)
}
if d.EvidenceVerified {
t.Fatalf("missing required execution evidence should be marked unverified: %+v", d)
}
if len(d.MissingChecks) == 0 {
t.Fatalf("missing checks should explain the evidence gap: %+v", d)
}
}
func TestDecideBlocksWhenOnlyFailedEvidenceIsRecorded(t *testing.T) {
db := newDecisionTestDB(t)
saveDecisionTestExecution(t, db, "run-failed", mcp.ToolExecutionStatusFailed)
saveDecisionTestExecution(t, db, "run-cancelled", mcp.ToolExecutionStatusCancelled)
d := Decide(db, Input{
Response: "任务已处理完成。",
MCPExecutionIDs: []string{"run-failed", "run-cancelled"},
RequireExecutionEvidence: true,
})
if d.Finalizable || d.Finalized {
t.Fatalf("failed evidence should not satisfy required execution evidence: %+v", d)
}
if d.Status != StatusBlocked || d.CompletionReason != ReasonMissingEvidence {
t.Fatalf("status/reason = %s/%s, want %s/%s", d.Status, d.CompletionReason, StatusBlocked, ReasonMissingEvidence)
}
}
func TestDecideFinalizesCompletedEvidence(t *testing.T) {
db := newDecisionTestDB(t)
saveDecisionTestExecution(t, db, "run-ok", mcp.ToolExecutionStatusCompleted)
d := Decide(db, Input{
Response: "任务已处理完成,见工具执行记录。",
MCPExecutionIDs: []string{"run-ok"},
RequireExecutionEvidence: true,
})
if !d.Finalizable || !d.Finalized || d.Status != StatusCompleted {
t.Fatalf("completed execution should finalize: %+v", d)
}
if !d.EvidenceVerified || len(d.EvidenceRefs) != 1 {
t.Fatalf("evidence refs mismatch: %+v", d)
}
}
func TestDecideAllowsInformationalAnswerWhenExecutionEvidenceIsNotRequired(t *testing.T) {
d := Decide(nil, Input{Response: "这是一个概念解释,不需要执行工具。"})
if !d.Finalizable || !d.Finalized || d.Status != StatusCompleted {
t.Fatalf("informational response should finalize when execution evidence is not required: %+v", d)
}
}
+100 -9
View File
@@ -148,10 +148,15 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error
mcpServer := mcp.NewServerWithStorage(log.Logger, db)
mcpServer.SetToolAuthorizer(mcpToolAuthorizer(db))
mcpServer.ConfigureHTTPToolCallTimeoutFromAgentMinutes(cfg.Agent.ToolTimeoutMinutes)
mcpServer.ConfigureToolWaitTimeoutSeconds(cfg.Agent.ToolWaitTimeoutSeconds)
mcpServer.ConfigureToolResultMaxBytes(cfg.MultiAgent.EinoMiddleware.ReductionMaxLengthForTruncEffective())
mcpServer.ConfigureToolResultSpillRoot(cfg.MultiAgent.EinoMiddleware.ReductionRootDir)
// 创建安全工具执行器
executor := security.NewExecutor(&cfg.Security, mcpServer, log.Logger)
executor.SetShellNoOutputTimeoutSeconds(cfg.Agent.ShellNoOutputTimeoutSeconds)
executor.SetToolOutputMaxBytes(cfg.MultiAgent.EinoMiddleware.ReductionMaxLengthForTruncEffective())
executor.SetToolOutputSpillRoot(cfg.MultiAgent.EinoMiddleware.ReductionRootDir)
// 注册工具
executor.RegisterTools(mcpServer)
@@ -165,6 +170,16 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error
// 创建外部MCP管理器(使用与内部MCP服务器相同的存储)
externalMCPMgr := mcp.NewExternalMCPManagerWithStorage(log.Logger, db)
externalMCPMgr.SetToolAuthorizer(externalMCPToolAuthorizer())
externalMCPMgr.ConfigureToolWaitTimeoutSeconds(cfg.Agent.ToolWaitTimeoutSeconds)
externalMCPMgr.ConfigureToolResultMaxBytes(cfg.MultiAgent.EinoMiddleware.ReductionMaxLengthForTruncEffective())
externalMCPMgr.ConfigureToolResultSpillRoot(cfg.MultiAgent.EinoMiddleware.ReductionRootDir)
externalMCPMgr.ConfigureResilience(mcp.ExternalMCPResilienceConfig{
MaxConcurrentPerServer: cfg.Agent.ExternalMCPMaxConcurrentPerServer,
MaxConcurrentTotal: cfg.Agent.ExternalMCPMaxConcurrentTotal,
CircuitFailureThreshold: cfg.Agent.ExternalMCPCircuitFailureThreshold,
CircuitCooldown: time.Duration(cfg.Agent.ExternalMCPCircuitCooldownSeconds) * time.Second,
})
mcp.RegisterExecutionControlTools(mcpServer, externalMCPMgr)
if cfg.ExternalMCP.Servers != nil {
externalMCPMgr.LoadConfigs(&cfg.ExternalMCP)
// 启动所有启用的外部MCP客户端
@@ -1298,6 +1313,7 @@ func setupRoutes(
c2Routes.GET("/sessions/:id", c2Handler.GetSession)
c2Routes.DELETE("/sessions/:id", c2Handler.DeleteSession)
c2Routes.PUT("/sessions/:id/sleep", c2Handler.SetSessionSleep)
c2Routes.PUT("/sessions/:id/note", c2Handler.SetSessionNote)
c2Routes.GET("/tasks", c2Handler.ListTasks)
c2Routes.DELETE("/tasks", c2Handler.DeleteTasks)
c2Routes.GET("/tasks/:id", c2Handler.GetTask)
@@ -1322,7 +1338,9 @@ func setupRoutes(
// 对话附件(chat_uploads)管理
protected.GET("/chat-uploads", chatUploadsHandler.List)
protected.GET("/chat-uploads/export", chatUploadsHandler.Export)
protected.GET("/chat-uploads/download", chatUploadsHandler.Download)
protected.GET("/chat-uploads/path", chatUploadsHandler.ResolvePath)
protected.GET("/chat-uploads/content", chatUploadsHandler.GetContent)
protected.POST("/chat-uploads", chatUploadsHandler.Upload)
protected.POST("/chat-uploads/mkdir", chatUploadsHandler.Mkdir)
@@ -1558,22 +1576,62 @@ func registerWebshellManagementTools(mcpServer *mcp.Server, db *database.DB, web
logger.Warn("跳过 WebShell 管理工具注册:db 为空")
return
}
projectIDFromToolArgs := func(ctx context.Context, args map[string]interface{}) string {
projectID, _ := args["project_id"].(string)
projectID = strings.TrimSpace(projectID)
if projectID == "" {
projectID = strings.TrimSpace(mcp.MCPProjectIDFromContext(ctx))
}
return projectID
}
explicitProjectIDFromToolArgs := func(args map[string]interface{}) string {
projectID, _ := args["project_id"].(string)
return strings.TrimSpace(projectID)
}
authorizeWebshellToolProject := func(principal authctx.Principal, permission, projectID string) *mcp.ToolResult {
projectID = strings.TrimSpace(projectID)
if projectID == "" {
return nil
}
if projectID == database.ProjectFilterUnbound {
return nil
}
if !db.UserCanAccessResource(principal.UserID, principal.ScopeFor(permission), "project", projectID) {
return &mcp.ToolResult{
Content: []mcp.Content{{Type: "text", Text: "无权访问项目: " + projectID}},
IsError: true,
}
}
return nil
}
// manage_webshell_list - 列出所有 webshell 连接
listTool := mcp.Tool{
Name: builtin.ToolManageWebshellList,
Description: "列出所有已保存的 WebShell 连接,返回连接ID、URL、类型、备注等信息。",
Description: "列出已保存的 WebShell 连接,返回连接ID、URL、类型、所属项目、备注等信息。默认按当前对话项目边界过滤:项目对话看本项目,未绑定项目的对话看未绑定连接;显式传 project_id 时按指定项目过滤。",
ShortDescription: "列出所有 WebShell 连接",
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{},
"type": "object",
"properties": map[string]interface{}{
"project_id": map[string]interface{}{
"type": "string",
"description": "项目 ID;不填时在项目会话中默认使用当前项目。",
},
},
},
}
listHandler := func(ctx context.Context, args map[string]interface{}) (*mcp.ToolResult, error) {
connections := []database.WebShellConnection{}
var err error
if principal, ok := authctx.PrincipalFromContext(ctx); ok {
connections, err = db.ListWebshellConnectionsForAccess(principal.UserID, principal.ScopeFor("webshell:read"))
projectID := explicitProjectIDFromToolArgs(args)
if projectID == "" {
projectID = mcpEffectiveProjectFilter(ctx, db)
}
if result := authorizeWebshellToolProject(principal, "webshell:read", projectID); result != nil {
return result, nil
}
connections, err = db.ListWebshellConnectionsForAccess(principal.UserID, principal.ScopeFor("webshell:read"), projectID)
} else {
return &mcp.ToolResult{Content: []mcp.Content{{Type: "text", Text: "缺少认证身份"}}, IsError: true}, nil
}
@@ -1597,6 +1655,11 @@ func registerWebshellManagementTools(mcpServer *mcp.Server, db *database.DB, web
sb.WriteString(fmt.Sprintf(" 类型: %s\n", conn.Type))
sb.WriteString(fmt.Sprintf(" 请求方式: %s\n", conn.Method))
sb.WriteString(fmt.Sprintf(" 命令参数: %s\n", conn.CmdParam))
if conn.ProjectID != "" {
sb.WriteString(fmt.Sprintf(" 项目ID: %s\n", conn.ProjectID))
} else {
sb.WriteString(" 项目: 未绑定\n")
}
if conn.Remark != "" {
sb.WriteString(fmt.Sprintf(" 备注: %s\n", conn.Remark))
}
@@ -1671,6 +1734,14 @@ func registerWebshellManagementTools(mcpServer *mcp.Server, db *database.DB, web
cmdParam = "cmd"
}
remark, _ := args["remark"].(string)
principal, ok := authctx.PrincipalFromContext(ctx)
if !ok {
return &mcp.ToolResult{Content: []mcp.Content{{Type: "text", Text: "缺少认证身份"}}, IsError: true}, nil
}
projectID := projectIDFromToolArgs(ctx, args)
if result := authorizeWebshellToolProject(principal, "webshell:write", projectID); result != nil {
return result, nil
}
// 生成连接ID
connID := "ws_" + strings.ReplaceAll(uuid.New().String(), "-", "")[:12]
@@ -1682,6 +1753,7 @@ func registerWebshellManagementTools(mcpServer *mcp.Server, db *database.DB, web
Method: strings.ToLower(method),
CmdParam: cmdParam,
Remark: remark,
ProjectID: projectID,
CreatedAt: time.Now(),
}
@@ -1691,15 +1763,17 @@ func registerWebshellManagementTools(mcpServer *mcp.Server, db *database.DB, web
IsError: true,
}, nil
}
if principal, ok := authctx.PrincipalFromContext(ctx); ok {
_ = db.SetResourceOwner("webshell", conn.ID, principal.UserID)
_ = db.AssignResourceToUser(principal.UserID, "webshell", conn.ID)
_ = db.SetResourceOwner("webshell", conn.ID, principal.UserID)
_ = db.AssignResourceToUser(principal.UserID, "webshell", conn.ID)
projectLine := "项目: 未绑定"
if conn.ProjectID != "" {
projectLine = "项目ID: " + conn.ProjectID
}
return &mcp.ToolResult{
Content: []mcp.Content{{
Type: "text",
Text: fmt.Sprintf("WebShell 连接添加成功!\n\n连接ID: %s\nURL: %s\n类型: %s\n请求方式: %s\n命令参数: %s", conn.ID, conn.URL, conn.Type, conn.Method, conn.CmdParam),
Text: fmt.Sprintf("WebShell 连接添加成功!\n\n连接ID: %s\nURL: %s\n类型: %s\n请求方式: %s\n命令参数: %s\n%s", conn.ID, conn.URL, conn.Type, conn.Method, conn.CmdParam, projectLine),
}},
IsError: false,
}, nil
@@ -1744,6 +1818,10 @@ func registerWebshellManagementTools(mcpServer *mcp.Server, db *database.DB, web
"type": "string",
"description": "新的备注",
},
"project_id": map[string]interface{}{
"type": "string",
"description": "新的所属项目 ID;传空字符串可取消绑定。",
},
},
"required": []string{"connection_id"},
},
@@ -1785,6 +1863,19 @@ func registerWebshellManagementTools(mcpServer *mcp.Server, db *database.DB, web
if remark, ok := args["remark"].(string); ok {
existing.Remark = remark
}
if projectID, ok := args["project_id"].(string); ok {
projectID = strings.TrimSpace(projectID)
if projectID != "" {
principal, ok := authctx.PrincipalFromContext(ctx)
if !ok {
return &mcp.ToolResult{Content: []mcp.Content{{Type: "text", Text: "缺少认证身份"}}, IsError: true}, nil
}
if result := authorizeWebshellToolProject(principal, "webshell:write", projectID); result != nil {
return result, nil
}
}
existing.ProjectID = projectID
}
if err := db.UpdateWebshellConnection(existing); err != nil {
return &mcp.ToolResult{
@@ -1796,7 +1887,7 @@ func registerWebshellManagementTools(mcpServer *mcp.Server, db *database.DB, web
return &mcp.ToolResult{
Content: []mcp.Content{{
Type: "text",
Text: fmt.Sprintf("WebShell 连接更新成功!\n\n连接ID: %s\nURL: %s\n类型: %s\n请求方式: %s\n命令参数: %s\n备注: %s", existing.ID, existing.URL, existing.Type, existing.Method, existing.CmdParam, existing.Remark),
Text: fmt.Sprintf("WebShell 连接更新成功!\n\n连接ID: %s\nURL: %s\n类型: %s\n请求方式: %s\n命令参数: %s\n项目ID: %s\n备注: %s", existing.ID, existing.URL, existing.Type, existing.Method, existing.CmdParam, existing.ProjectID, existing.Remark),
}},
IsError: false,
}, nil
+13 -1
View File
@@ -75,6 +75,7 @@ tcp_reverse 默认仅接受 CSB1 加密 BeaconAES-GCM + ImplantToken)才登
"bind_host": map[string]interface{}{"type": "string", "description": "绑定地址,默认 127.0.0.1;外网监听常用 0.0.0.0"},
"callback_host": map[string]interface{}{"type": "string", "description": "可选:植入端/Payload 回连主机名(公网 IP 或域名)。写入 config_json;生成 oneliner/beacon 时优先于 bind_host。update 时传入空字符串可清除"},
"bind_port": map[string]interface{}{"type": "integer", "description": fmt.Sprintf("绑定端口(create 必填)。须 ≠ %d(当前本服务 Web/API 端口,配置 server.port", webListenPort), "minimum": 1, "maximum": 65535},
"project_id": map[string]interface{}{"type": "string", "description": "所属项目 ID。create 省略时默认使用当前对话绑定项目;未绑定项目的对话则创建未绑定监听器"},
"profile_id": map[string]interface{}{"type": "string", "description": "Malleable Profile ID"},
"remark": map[string]interface{}{"type": "string", "description": "备注"},
"config": map[string]interface{}{"type": "object", "description": "高级配置(beacon 路径/TLS/OPSEC 等),create/update 可用。tcp_reverse 可选 allow_legacy_shell:true 允许未加密经典 shell(默认 false"},
@@ -87,7 +88,7 @@ tcp_reverse 默认仅接受 CSB1 加密 BeaconAES-GCM + ImplantToken)才登
switch action {
case "list":
listeners, err := m.DB().ListC2ListenersForAccess(c2ToolAccess(ctx))
listeners, err := m.DB().ListC2ListenersForAccess(c2ToolAccess(ctx), mcpEffectiveProjectFilter(ctx, m.DB()))
if err != nil {
return makeC2Result(nil, err)
}
@@ -116,6 +117,13 @@ tcp_reverse 默认仅接受 CSB1 加密 BeaconAES-GCM + ImplantToken)才登
cfg = &c2.ListenerConfig{}
_ = json.Unmarshal(cfgBytes, cfg)
}
projectID := strings.TrimSpace(getString(params, "project_id"))
if projectID == "" {
projectID = mcpEffectiveProjectFilter(ctx, m.DB())
if projectID == database.ProjectFilterUnbound {
projectID = ""
}
}
input := c2.CreateListenerInput{
Name: getString(params, "name"),
Type: getString(params, "type"),
@@ -123,6 +131,7 @@ tcp_reverse 默认仅接受 CSB1 加密 BeaconAES-GCM + ImplantToken)才登
BindPort: int(getFloat64(params, "bind_port")),
ProfileID: getString(params, "profile_id"),
Remark: getString(params, "remark"),
ProjectID: projectID,
Config: cfg,
CallbackHost: getString(params, "callback_host"),
}
@@ -260,6 +269,7 @@ func registerC2SessionTool(s *mcp.Server, m *c2.Manager, l *zap.Logger) {
case "list":
filter := database.ListC2SessionsFilter{
ListenerID: getString(params, "listener_id"),
ProjectID: mcpEffectiveProjectFilter(ctx, m.DB()),
Status: getString(params, "status"),
OS: getString(params, "os"),
Search: getString(params, "search"),
@@ -495,6 +505,7 @@ func registerC2TaskManageTool(s *mcp.Server, m *c2.Manager, l *zap.Logger) {
case "list":
filter := database.ListC2TasksFilter{
SessionID: getString(params, "session_id"),
ProjectID: mcpEffectiveProjectFilter(ctx, m.DB()),
Status: getString(params, "status"),
}
if limit := int(getFloat64(params, "limit")); limit > 0 {
@@ -645,6 +656,7 @@ func registerC2EventTool(s *mcp.Server, m *c2.Manager, l *zap.Logger) {
filter := database.ListC2EventsFilter{
Level: getString(params, "level"),
Category: getString(params, "category"),
ProjectID: mcpEffectiveProjectFilter(ctx, m.DB()),
SessionID: getString(params, "session_id"),
TaskID: getString(params, "task_id"),
Limit: int(getFloat64(params, "limit")),
+69
View File
@@ -0,0 +1,69 @@
package app
import (
"context"
"path/filepath"
"testing"
"cyberstrike-ai/internal/authctx"
"cyberstrike-ai/internal/c2"
"cyberstrike-ai/internal/database"
"cyberstrike-ai/internal/mcp"
"cyberstrike-ai/internal/mcp/builtin"
"go.uber.org/zap"
)
func TestC2ListenerCreateInheritsConversationProject(t *testing.T) {
db, err := database.NewDB(filepath.Join(t.TempDir(), "c2-tools.db"), zap.NewNop())
if err != nil {
t.Fatal(err)
}
defer db.Close()
user, err := db.CreateRBACUser("c2-agent", "C2 Agent", "hash", true, nil)
if err != nil {
t.Fatal(err)
}
project, err := db.CreateProject(&database.Project{Name: "engagement"})
if err != nil {
t.Fatal(err)
}
if err := db.AssignResourceToUser(user.ID, "project", project.ID); err != nil {
t.Fatal(err)
}
conversation, err := db.CreateConversation("project chat", database.ConversationCreateMeta{ProjectID: project.ID})
if err != nil {
t.Fatal(err)
}
principal := authctx.NewPrincipal(user.ID, user.Username, database.RBACScopeAssigned, map[string]bool{
"c2:read": true, "c2:write": true,
})
ctx := authctx.WithPrincipal(mcp.WithMCPConversationID(context.Background(), conversation.ID), principal)
server := mcp.NewServer(zap.NewNop())
server.SetToolAuthorizer(mcpToolAuthorizer(db))
registerC2Tools(server, c2.NewManager(db, zap.NewNop(), t.TempDir()), zap.NewNop(), 8080)
result, _, err := server.CallTool(ctx, builtin.ToolC2Listener, map[string]interface{}{
"action": "create",
"name": "tcp-reverse-2222",
"type": "tcp_reverse",
"bind_host": "0.0.0.0",
"bind_port": 2222,
})
if err != nil || result == nil || result.IsError {
t.Fatalf("create listener result=%#v err=%v text=%q", result, err, toolResultText(result))
}
listeners, err := db.ListC2Listeners()
if err != nil {
t.Fatal(err)
}
if len(listeners) != 1 {
t.Fatalf("listener count=%d, want 1", len(listeners))
}
if listeners[0].ProjectID != project.ID {
t.Fatalf("listener project_id=%q, want %q", listeners[0].ProjectID, project.ID)
}
}
+136 -7
View File
@@ -32,6 +32,19 @@ func mcpToolAuthorizer(db *database.DB) func(context.Context, string, map[string
if id == "" || db == nil || !db.UserCanAccessResource(principal.UserID, principal.ScopeFor(permission), resourceType, id) {
return fmt.Errorf("no access to %s %s", resourceType, id)
}
if err := authorizeMCPProjectResourceBoundary(ctx, db, resourceType, id); err != nil {
return err
}
return nil
}
toolExecutionResource := func(permission string) error {
if err := require(permission); err != nil {
return err
}
id := mcpAuthorizationString(args, "execution_id")
if id == "" || db == nil || !db.UserCanAccessToolExecution(principal.UserID, principal.ScopeFor(permission), id) {
return fmt.Errorf("no access to tool execution %s", id)
}
return nil
}
@@ -108,6 +121,10 @@ func mcpToolAuthorizer(db *database.DB) func(context.Context, string, map[string
return require("knowledge:read")
case builtin.ToolAnalyzeImage:
return require("agent:execute")
case builtin.ToolGetToolExecution, builtin.ToolWaitToolExecution:
return toolExecutionResource("monitor:read")
case builtin.ToolCancelToolExecution:
return toolExecutionResource("monitor:write")
case builtin.ToolBatchTaskList:
return require("tasks:read")
case builtin.ToolBatchTaskGet:
@@ -127,20 +144,26 @@ func mcpToolAuthorizer(db *database.DB) func(context.Context, string, map[string
builtin.ToolBatchTaskScheduleEnabled, builtin.ToolBatchTaskAdd, builtin.ToolBatchTaskUpdate:
return resource("tasks:write", "batch_task", "queue_id")
case builtin.ToolC2Listener:
return authorizeC2Action(principal, db, args, "c2_listener", "listener_id")
return authorizeC2Action(ctx, principal, db, args, "c2_listener", "listener_id")
case builtin.ToolC2Session, builtin.ToolC2Task, builtin.ToolC2File:
if toolName == builtin.ToolC2File && mcpAuthorizationString(args, "action") == "get_result" {
return authorizeC2Action(principal, db, args, "c2_task", "task_id")
return authorizeC2Action(ctx, principal, db, args, "c2_task", "task_id")
}
return authorizeC2Action(principal, db, args, "c2_session", "session_id")
return authorizeC2Action(ctx, principal, db, args, "c2_session", "session_id")
case builtin.ToolC2TaskManage:
return authorizeC2Action(principal, db, args, "c2_task", "task_id")
return authorizeC2Action(ctx, principal, db, args, "c2_task", "task_id")
case builtin.ToolC2Payload:
return resource("c2:write", "c2_listener", "listener_id")
case builtin.ToolC2Event:
if id := mcpAuthorizationString(args, "session_id"); id != "" {
return resource("c2:read", "c2_session", "session_id")
}
if id := mcpAuthorizationString(args, "task_id"); id != "" {
return resource("c2:read", "c2_task", "task_id")
}
if filter := mcpEffectiveProjectFilter(ctx, db); filter != "" {
return require("c2:read")
}
if principal.ScopeFor("c2:read") != database.RBACScopeAll {
return fmt.Errorf("unfiltered C2 event list requires global scope")
}
@@ -193,7 +216,7 @@ func externalMCPToolAuthorizer() func(context.Context, string, map[string]interf
}
}
func authorizeC2Action(principal authctx.Principal, db *database.DB, args map[string]interface{}, resourceType, argument string) error {
func authorizeC2Action(ctx context.Context, principal authctx.Principal, db *database.DB, args map[string]interface{}, resourceType, argument string) error {
action := mcpAuthorizationString(args, "action")
permission := "c2:write"
if action == "list" || action == "get" || action == "get_result" || action == "wait" {
@@ -214,11 +237,27 @@ func authorizeC2Action(principal authctx.Principal, db *database.DB, args map[st
if db == nil || !db.UserCanAccessResource(principal.UserID, principal.ScopeFor(permission), resourceType, candidate) {
return fmt.Errorf("no access to %s %s", resourceType, candidate)
}
if err := authorizeMCPProjectResourceBoundary(ctx, db, resourceType, candidate); err != nil {
return err
}
}
return nil
}
if id == "" {
if action == "create" || action == "list" {
if action == "create" {
projectID := mcpAuthorizationString(args, "project_id")
if projectID == "" {
projectID = mcpEffectiveProjectFilter(ctx, db)
if projectID == database.ProjectFilterUnbound {
projectID = ""
}
}
if projectID != "" && (db == nil || !db.UserCanAccessResource(principal.UserID, principal.ScopeFor(permission), "project", projectID)) {
return fmt.Errorf("no access to project %s", projectID)
}
return nil
}
if action == "list" {
return nil
}
return fmt.Errorf("missing resource identifier %s", argument)
@@ -226,9 +265,93 @@ func authorizeC2Action(principal authctx.Principal, db *database.DB, args map[st
if db == nil || !db.UserCanAccessResource(principal.UserID, principal.ScopeFor(permission), resourceType, id) {
return fmt.Errorf("no access to %s %s", resourceType, id)
}
if err := authorizeMCPProjectResourceBoundary(ctx, db, resourceType, id); err != nil {
return err
}
return nil
}
func authorizeMCPProjectResourceBoundary(ctx context.Context, db *database.DB, resourceType, resourceID string) error {
filter := mcpEffectiveProjectFilter(ctx, db)
if filter == "" || db == nil {
return nil
}
projectID, ok, err := mcpResourceProjectID(db, resourceType, resourceID)
if err != nil {
return err
}
if !ok {
return nil
}
if filter == database.ProjectFilterUnbound {
if projectID != "" {
return fmt.Errorf("resource %s %s belongs to project %s, current conversation is unbound", resourceType, resourceID, projectID)
}
return nil
}
if projectID != filter {
if projectID == "" {
return fmt.Errorf("resource %s %s is unbound, current conversation project is %s", resourceType, resourceID, filter)
}
return fmt.Errorf("resource %s %s belongs to project %s, current conversation project is %s", resourceType, resourceID, projectID, filter)
}
return nil
}
func mcpResourceProjectID(db *database.DB, resourceType, resourceID string) (string, bool, error) {
switch resourceType {
case "webshell":
conn, err := db.GetWebshellConnection(resourceID)
if err != nil {
return "", true, err
}
if conn == nil {
return "", true, fmt.Errorf("webshell not found")
}
return strings.TrimSpace(conn.ProjectID), true, nil
case "c2_listener":
listener, err := db.GetC2Listener(resourceID)
if err != nil {
return "", true, err
}
if listener == nil {
return "", true, fmt.Errorf("listener not found")
}
return strings.TrimSpace(listener.ProjectID), true, nil
case "c2_session":
session, err := db.GetC2Session(resourceID)
if err != nil {
return "", true, err
}
if session == nil {
return "", true, fmt.Errorf("session not found")
}
return mcpResourceProjectID(db, "c2_listener", session.ListenerID)
case "c2_task":
task, err := db.GetC2Task(resourceID)
if err != nil {
return "", true, err
}
if task == nil {
return "", true, fmt.Errorf("task not found")
}
return mcpResourceProjectIDFromC2Session(db, task.SessionID)
default:
return "", false, nil
}
}
func mcpResourceProjectIDFromC2Session(db *database.DB, sessionID string) (string, bool, error) {
session, err := db.GetC2Session(sessionID)
if err != nil {
return "", true, err
}
if session == nil {
return "", true, fmt.Errorf("session not found")
}
return mcpResourceProjectID(db, "c2_listener", session.ListenerID)
}
func mcpAuthorizationStrings(args map[string]interface{}, key string) []string {
values := []string{}
switch raw := args[key].(type) {
@@ -259,7 +382,13 @@ func authorizeProjectTool(ctx context.Context, principal authctx.Principal, db *
return fmt.Errorf("no access to conversation %s", conversationID)
}
projectID, err := db.GetConversationProjectID(conversationID)
if err != nil || strings.TrimSpace(projectID) == "" || !db.UserCanAccessResource(principal.UserID, principal.ScopeFor(permission), "project", projectID) {
if err != nil {
return fmt.Errorf("no access to project: %w", err)
}
if strings.TrimSpace(projectID) == "" {
return fmt.Errorf("当前对话未绑定项目,无法使用项目黑板工具,请先在对话中选择项目或创建带项目的对话")
}
if !db.UserCanAccessResource(principal.UserID, principal.ScopeFor(permission), "project", projectID) {
return fmt.Errorf("no access to project %s", projectID)
}
return nil
+126 -1
View File
@@ -9,6 +9,7 @@ import (
"cyberstrike-ai/internal/authctx"
"cyberstrike-ai/internal/database"
"cyberstrike-ai/internal/mcp"
"cyberstrike-ai/internal/mcp/builtin"
"cyberstrike-ai/internal/security"
@@ -48,6 +49,91 @@ func TestMCPToolAuthorizerEnforcesPermissionAndResource(t *testing.T) {
}
}
func TestMCPToolAuthorizerEnforcesConversationProjectBoundary(t *testing.T) {
db, err := database.NewDB(filepath.Join(t.TempDir(), "mcp-project-boundary.db"), zap.NewNop())
if err != nil {
t.Fatal(err)
}
defer db.Close()
user, err := db.CreateRBACUser("boundary-user", "Boundary User", "hash", true, nil)
if err != nil {
t.Fatal(err)
}
project, err := db.CreateProject(&database.Project{Name: "Project 123"})
if err != nil {
t.Fatal(err)
}
projectConv, err := db.CreateConversation("project conversation", database.ConversationCreateMeta{ProjectID: project.ID})
if err != nil {
t.Fatal(err)
}
unboundConv, err := db.CreateConversation("unbound conversation", database.ConversationCreateMeta{})
if err != nil {
t.Fatal(err)
}
wsProject := database.WebShellConnection{ID: "ws_project", ProjectID: project.ID, URL: "http://127.0.0.1/project.php", Type: "php", Method: "post", CreatedAt: time.Now()}
wsUnbound := database.WebShellConnection{ID: "ws_unbound", URL: "http://127.0.0.1/unbound.php", Type: "php", Method: "post", CreatedAt: time.Now()}
if err := db.CreateWebshellConnection(&wsProject); err != nil {
t.Fatal(err)
}
if err := db.CreateWebshellConnection(&wsUnbound); err != nil {
t.Fatal(err)
}
for _, id := range []string{wsProject.ID, wsUnbound.ID} {
if err := db.AssignResourceToUser(user.ID, "webshell", id); err != nil {
t.Fatal(err)
}
}
now := time.Now()
listener := &database.C2Listener{ID: "l_project", ProjectID: project.ID, Name: "project listener", Type: "tcp_reverse", BindHost: "127.0.0.1", BindPort: 5555, OwnerUserID: user.ID, CreatedAt: now}
if err := db.CreateC2Listener(listener); err != nil {
t.Fatal(err)
}
if err := db.AssignResourceToUser(user.ID, "c2_listener", listener.ID); err != nil {
t.Fatal(err)
}
session := &database.C2Session{ID: "s_project", ListenerID: listener.ID, ImplantUUID: "implant-project", Status: "active", FirstSeenAt: now, LastCheckIn: now}
if err := db.UpsertC2Session(session); err != nil {
t.Fatal(err)
}
principal := authctx.NewPrincipal(user.ID, user.Username, database.RBACScopeAssigned, map[string]bool{
"webshell:read": true, "webshell:write": true,
"c2:read": true, "c2:write": true,
})
authorize := mcpToolAuthorizer(db)
unboundCtx := authctx.WithPrincipal(mcp.WithMCPConversationID(context.Background(), unboundConv.ID), principal)
projectCtx := authctx.WithPrincipal(mcp.WithMCPProjectID(mcp.WithMCPConversationID(context.Background(), projectConv.ID), project.ID), principal)
projectCtxFromConversationOnly := authctx.WithPrincipal(mcp.WithMCPConversationID(context.Background(), projectConv.ID), principal)
if err := authorize(unboundCtx, builtin.ToolWebshellExec, map[string]interface{}{"connection_id": wsProject.ID}); err == nil {
t.Fatal("unbound conversation was allowed to use project-bound webshell")
}
if err := authorize(unboundCtx, builtin.ToolWebshellExec, map[string]interface{}{"connection_id": wsUnbound.ID}); err != nil {
t.Fatalf("unbound webshell denied in unbound conversation: %v", err)
}
if err := authorize(projectCtx, builtin.ToolWebshellExec, map[string]interface{}{"connection_id": wsProject.ID}); err != nil {
t.Fatalf("project webshell denied in project conversation: %v", err)
}
if err := authorize(projectCtxFromConversationOnly, builtin.ToolWebshellExec, map[string]interface{}{"connection_id": wsProject.ID}); err != nil {
t.Fatalf("project webshell denied when only conversation id is present: %v", err)
}
if err := authorize(projectCtx, builtin.ToolWebshellExec, map[string]interface{}{"connection_id": wsUnbound.ID}); err == nil {
t.Fatal("project conversation was allowed to use unbound webshell by id")
}
if err := authorize(unboundCtx, builtin.ToolC2Session, map[string]interface{}{"action": "get", "session_id": session.ID}); err == nil {
t.Fatal("unbound conversation was allowed to use project-bound c2 session")
}
if err := authorize(projectCtx, builtin.ToolC2Session, map[string]interface{}{"action": "get", "session_id": session.ID}); err != nil {
t.Fatalf("project c2 session denied in project conversation: %v", err)
}
if err := authorize(projectCtxFromConversationOnly, builtin.ToolC2Session, map[string]interface{}{"action": "get", "session_id": session.ID}); err != nil {
t.Fatalf("project c2 session denied when only conversation id is present: %v", err)
}
}
func TestEveryBuiltinMCPToolHasExplicitAuthorizationPolicy(t *testing.T) {
db, err := database.NewDB(filepath.Join(t.TempDir(), "mcp-policy-inventory.db"), zap.NewNop())
if err != nil {
@@ -62,7 +148,7 @@ func TestEveryBuiltinMCPToolHasExplicitAuthorizationPolicy(t *testing.T) {
authorize := mcpToolAuthorizer(db)
args := map[string]interface{}{
"action": "get", "connection_id": "x", "queue_id": "x", "listener_id": "x",
"session_id": "x", "task_id": "x", "id": "x", "conversation_id": "x",
"session_id": "x", "task_id": "x", "id": "x", "conversation_id": "x", "execution_id": "x",
}
for _, toolName := range builtin.GetAllBuiltinTools() {
err := authorize(ctx, toolName, args)
@@ -72,6 +158,45 @@ func TestEveryBuiltinMCPToolHasExplicitAuthorizationPolicy(t *testing.T) {
}
}
func TestMCPExecutionControlAuthorizationUsesExecutionScope(t *testing.T) {
db, err := database.NewDB(filepath.Join(t.TempDir(), "mcp-exec-authz.db"), zap.NewNop())
if err != nil {
t.Fatal(err)
}
defer db.Close()
user, err := db.CreateRBACUser("exec-user", "Exec User", "hash", true, nil)
if err != nil {
t.Fatal(err)
}
if err := db.SaveToolExecution(&mcp.ToolExecution{
ID: "exec-owned",
ToolName: "lab::slow",
Status: "running",
StartTime: time.Now(),
OwnerUserID: user.ID,
}); err != nil {
t.Fatal(err)
}
if err := db.SaveToolExecution(&mcp.ToolExecution{
ID: "exec-hidden",
ToolName: "lab::slow",
Status: "running",
StartTime: time.Now(),
}); err != nil {
t.Fatal(err)
}
principal := authctx.NewPrincipal(user.ID, user.Username, database.RBACScopeAssigned, map[string]bool{"monitor:read": true, "monitor:write": true})
ctx := authctx.WithPrincipal(context.Background(), principal)
authorize := mcpToolAuthorizer(db)
if err := authorize(ctx, builtin.ToolWaitToolExecution, map[string]interface{}{"execution_id": "exec-owned"}); err != nil {
t.Fatalf("owned execution denied: %v", err)
}
if err := authorize(ctx, builtin.ToolCancelToolExecution, map[string]interface{}{"execution_id": "exec-hidden"}); err == nil {
t.Fatal("foreign execution was allowed")
}
}
func TestMCPAssetToolAuthorizationUsesAssetPermissionsAndScope(t *testing.T) {
db, err := database.NewDB(filepath.Join(t.TempDir(), "mcp-asset-authz.db"), zap.NewNop())
if err != nil {
+26
View File
@@ -0,0 +1,26 @@
package app
import (
"context"
"strings"
"cyberstrike-ai/internal/database"
"cyberstrike-ai/internal/mcp"
)
func mcpEffectiveProjectFilter(ctx context.Context, db *database.DB) string {
if projectID := strings.TrimSpace(mcp.MCPProjectIDFromContext(ctx)); projectID != "" {
return projectID
}
if conversationID := mcpAuthorizationConversationID(ctx); conversationID != "" {
if db != nil {
if projectID, err := db.GetConversationProjectID(conversationID); err == nil {
if projectID = strings.TrimSpace(projectID); projectID != "" {
return projectID
}
}
}
return database.ProjectFilterUnbound
}
return ""
}
+5 -3
View File
@@ -34,9 +34,9 @@ type Manager struct {
runningListeners map[string]Listener // listener_id → 已 Start 的 listener 实例
storageDir string // 大结果(截图/下载)落盘根目录
hitlBridge HITLBridge // 危险任务在 EnqueueTask 时调它发起审批(nil 表示不接 HITL)
hitlBridge HITLBridge // 危险任务在 EnqueueTask 时调它发起审批(nil 表示不接 HITL)
hitlDangerousGate func(conversationID, mcpToolName string) bool // 与人机协同一致:为 nil 或返回 false 时不走桥
hooks Hooks // 扩展挂钩:会话上线 / 任务完成 时通知漏洞库与攻击链
hooks Hooks // 扩展挂钩:会话上线 / 任务完成 时通知漏洞库与攻击链
}
// MCPToolC2Task 与 MCP builtin、c2_task 工具名一致,供 HITL 白名单与 Agent 侧对齐。
@@ -63,7 +63,7 @@ type HITLApprovalRequest struct {
// Hooks 给上层(漏洞管理 / 攻击链)注入回调
type Hooks struct {
OnSessionFirstSeen func(session *database.C2Session) // 新会话首次上线
OnSessionFirstSeen func(session *database.C2Session) // 新会话首次上线
OnTaskCompleted func(task *database.C2Task, sessionID string) // 任务完成(success/failed
}
@@ -144,6 +144,7 @@ func (m *Manager) Close() {
// CreateListenerInput Web/MCP 创建监听器的入参(已校验 + 已 trim)
type CreateListenerInput struct {
Name string
ProjectID string
Type string
BindHost string
BindPort int
@@ -195,6 +196,7 @@ func (m *Manager) CreateListener(in CreateListenerInput) (*database.C2Listener,
listener := &database.C2Listener{
ID: "l_" + strings.ReplaceAll(uuid.New().String(), "-", "")[:14],
ProjectID: strings.TrimSpace(in.ProjectID),
Name: strings.TrimSpace(in.Name),
Type: strings.ToLower(strings.TrimSpace(in.Type)),
BindHost: bindHost,
+155 -11
View File
@@ -21,7 +21,8 @@ type Config struct {
Server ServerConfig `yaml:"server"`
Log LogConfig `yaml:"log"`
MCP MCPConfig `yaml:"mcp"`
OpenAI OpenAIConfig `yaml:"openai"`
AI AIConfig `yaml:"ai,omitempty" json:"ai,omitempty"`
OpenAI OpenAIConfig `yaml:"openai,omitempty" json:"openai,omitempty"`
FOFA FofaConfig `yaml:"fofa,omitempty" json:"fofa,omitempty"`
ZoomEye SpaceSearchConfig `yaml:"zoomeye,omitempty" json:"zoomeye,omitempty"`
Quake SpaceSearchConfig `yaml:"quake,omitempty" json:"quake,omitempty"`
@@ -840,6 +841,130 @@ type OpenAIConfig struct {
Reasoning OpenAIReasoningConfig `yaml:"reasoning,omitempty" json:"reasoning,omitempty"`
}
// AIConfig stores first-class model channels. Runtime callers resolve a channel
// into OpenAIConfig at the edge instead of moving API credentials through chat requests.
type AIConfig struct {
DefaultChannel string `yaml:"default_channel,omitempty" json:"default_channel,omitempty"`
Channels map[string]AIChannelConfig `yaml:"channels,omitempty" json:"channels,omitempty"`
}
type AIChannelConfig struct {
Name string `yaml:"name,omitempty" json:"name,omitempty"`
Provider string `yaml:"provider,omitempty" json:"provider,omitempty"`
APIKey string `yaml:"api_key" json:"api_key"`
BaseURL string `yaml:"base_url" json:"base_url"`
Model string `yaml:"model" json:"model"`
MaxTotalTokens int `yaml:"max_total_tokens,omitempty" json:"max_total_tokens,omitempty"`
MaxCompletionTokens int `yaml:"max_completion_tokens,omitempty" json:"max_completion_tokens,omitempty"`
Reasoning OpenAIReasoningConfig `yaml:"reasoning,omitempty" json:"reasoning,omitempty"`
}
func (c AIChannelConfig) ToOpenAIConfig() OpenAIConfig {
provider := strings.TrimSpace(c.Provider)
if provider == "" || provider == "openai_compatible" {
provider = "openai"
}
return OpenAIConfig{
Provider: provider,
APIKey: c.APIKey,
BaseURL: c.BaseURL,
Model: c.Model,
MaxTotalTokens: c.MaxTotalTokens,
MaxCompletionTokens: c.MaxCompletionTokens,
Reasoning: c.Reasoning,
}
}
func AIChannelFromOpenAI(id, name string, oa OpenAIConfig) AIChannelConfig {
if strings.TrimSpace(name) == "" {
name = id
}
return AIChannelConfig{
Name: name,
Provider: oa.Provider,
APIKey: oa.APIKey,
BaseURL: oa.BaseURL,
Model: oa.Model,
MaxTotalTokens: oa.MaxTotalTokens,
MaxCompletionTokens: oa.MaxCompletionTokens,
Reasoning: oa.Reasoning,
}
}
func NormalizeAIChannelID(s string) string {
id := strings.ToLower(strings.TrimSpace(s))
id = strings.ReplaceAll(id, "_", "-")
var b strings.Builder
lastDash := false
for _, r := range id {
ok := (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9')
if ok {
b.WriteRune(r)
lastDash = false
continue
}
if !lastDash {
b.WriteByte('-')
lastDash = true
}
}
out := strings.Trim(b.String(), "-")
if out == "" {
return "default"
}
return out
}
func (c *AIConfig) EnsureDefaultFromOpenAI(openAI OpenAIConfig) {
if c.Channels == nil {
c.Channels = make(map[string]AIChannelConfig)
}
def := NormalizeAIChannelID(c.DefaultChannel)
if def == "default" && strings.TrimSpace(c.DefaultChannel) == "" {
def = "default"
}
c.DefaultChannel = def
if _, ok := c.Channels[def]; !ok {
c.Channels[def] = AIChannelFromOpenAI(def, "Default", openAI)
}
}
func (c AIConfig) ResolveChannel(channelID string) (OpenAIConfig, string, bool) {
id := NormalizeAIChannelID(channelID)
if strings.TrimSpace(channelID) == "" {
id = NormalizeAIChannelID(c.DefaultChannel)
}
if id == "" {
id = "default"
}
if c.Channels != nil {
if ch, ok := c.Channels[id]; ok {
return ch.ToOpenAIConfig(), id, true
}
}
return OpenAIConfig{}, id, false
}
func (c *Config) ResolveAIChannel(channelID string) (OpenAIConfig, string, bool) {
if c == nil {
return OpenAIConfig{}, "", false
}
if oa, id, ok := c.AI.ResolveChannel(channelID); ok {
return oa, id, true
}
return c.OpenAI, NormalizeAIChannelID(channelID), strings.TrimSpace(c.OpenAI.Model) != "" || strings.TrimSpace(c.OpenAI.BaseURL) != ""
}
func (c *Config) ApplyDefaultAIChannel() {
if c == nil {
return
}
c.AI.EnsureDefaultFromOpenAI(c.OpenAI)
if oa, _, ok := c.AI.ResolveChannel(c.AI.DefaultChannel); ok {
c.OpenAI = oa
}
}
func (c OpenAIConfig) MaxCompletionTokensEffective() int {
if c.MaxCompletionTokens > 0 {
return c.MaxCompletionTokens
@@ -912,8 +1037,13 @@ type DatabaseConfig struct {
}
type AgentConfig struct {
MaxIterations int `yaml:"max_iterations" json:"max_iterations"`
ToolTimeoutMinutes int `yaml:"tool_timeout_minutes" json:"tool_timeout_minutes"` // 单次工具执行最大时长(分钟),超时自动终止,防止长时间挂起;0 表示不限制(不推荐)
MaxIterations int `yaml:"max_iterations" json:"max_iterations"`
ToolTimeoutMinutes int `yaml:"tool_timeout_minutes" json:"tool_timeout_minutes"` // 单次工具执行最大时长(分钟),超时自动终止,防止长时间挂起;0 表示不限制(不推荐)
ToolWaitTimeoutSeconds int `yaml:"tool_wait_timeout_seconds" json:"tool_wait_timeout_seconds"` // 工具本轮等待秒数;到时返回 execution_idworker 继续后台执行;0 表示等到完成
ExternalMCPMaxConcurrentPerServer int `yaml:"external_mcp_max_concurrent_per_server" json:"external_mcp_max_concurrent_per_server"` // 单个外部 MCP server 同时运行的工具数;0 表示默认 2
ExternalMCPMaxConcurrentTotal int `yaml:"external_mcp_max_concurrent_total" json:"external_mcp_max_concurrent_total"` // 所有外部 MCP 工具全局并发;0 表示默认 16
ExternalMCPCircuitFailureThreshold int `yaml:"external_mcp_circuit_failure_threshold" json:"external_mcp_circuit_failure_threshold"` // 单个 MCP server 连续失败多少次后打开熔断;0 表示默认 3;负数关闭
ExternalMCPCircuitCooldownSeconds int `yaml:"external_mcp_circuit_cooldown_seconds" json:"external_mcp_circuit_cooldown_seconds"` // 熔断后冷却秒数;0 表示默认 60
// ShellNoOutputTimeoutSeconds execute/exec 无任何 stdout/stderr 时的空闲终止秒数(通用防挂死,不维护命令黑名单);0=默认 300(5 分钟);-1=关闭。
ShellNoOutputTimeoutSeconds int `yaml:"shell_no_output_timeout_seconds" json:"shell_no_output_timeout_seconds"`
// WorkspaceRootDir 会话工作目录根路径(curl/wget 下载、read_file/glob/grep 本地分析);空=tmp/workspace,其下按 projects/{id} 或 conversations/{id} 隔离。
@@ -1219,6 +1349,7 @@ func Load(path string) (*Config, error) {
if cfg.Audit.MaxDetailBytes <= 0 {
cfg.Audit.MaxDetailBytes = 8192
}
cfg.ApplyDefaultAIChannel()
if err := validateModelOutputLimits(cfg.OpenAI, cfg.MultiAgent.EinoMiddleware); err != nil {
return nil, err
}
@@ -1715,16 +1846,29 @@ func Default() *Config {
Host: "127.0.0.1",
Port: 8081,
},
OpenAI: OpenAIConfig{
BaseURL: "https://api.openai.com/v1",
Model: "gpt-4",
MaxTotalTokens: 120000,
MaxCompletionTokens: DefaultMaxCompletionTokens,
AI: AIConfig{
DefaultChannel: "default",
Channels: map[string]AIChannelConfig{
"default": {
Name: "Default",
Provider: "openai_compatible",
BaseURL: "https://api.openai.com/v1",
Model: "gpt-4",
MaxTotalTokens: 120000,
MaxCompletionTokens: DefaultMaxCompletionTokens,
},
},
},
OpenAI: OpenAIConfig{},
Agent: AgentConfig{
MaxIterations: 30, // 默认最大迭代次数
ToolTimeoutMinutes: 10, // 单次工具执行默认最多 10 分钟,避免异常长时间占用
ShellNoOutputTimeoutSeconds: 300, // execute/exec 无新输出空闲终止(秒);-1 关闭
MaxIterations: 30, // 默认最大迭代次数
ToolTimeoutMinutes: 10, // 单次工具执行默认最多 10 分钟,避免异常长时间占用
ToolWaitTimeoutSeconds: 60, // 外部 MCP 工具单轮最多等待 60 秒,超时后返回 execution_id 可继续等待
ExternalMCPMaxConcurrentPerServer: 2, // 单个外部 MCP server 默认最多 2 个工具同时执行
ExternalMCPMaxConcurrentTotal: 16, // 外部 MCP 工具全局默认最多 16 个同时执行
ExternalMCPCircuitFailureThreshold: 3, // 单个 server 连续 3 次失败后临时熔断
ExternalMCPCircuitCooldownSeconds: 60, // 熔断默认冷却 60 秒
ShellNoOutputTimeoutSeconds: 300, // execute/exec 无新输出空闲终止(秒);-1 关闭
},
Security: SecurityConfig{
Tools: []ToolConfig{}, // 工具配置应该从 config.yaml 或 tools/ 目录加载
+42
View File
@@ -95,6 +95,48 @@ func TestHitlAuditModelEffectiveFallsBackToMainConfig(t *testing.T) {
}
}
func TestLoadUsesAIDefaultChannelAsRuntimeOpenAI(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
initial := strings.Join([]string{
"ai:",
" default_channel: deepseek",
" channels:",
" qwen:",
" name: Qwen",
" provider: openai_compatible",
" base_url: https://dashscope.example/v1",
" api_key: qwen-key",
" model: qwen-max",
" deepseek:",
" name: DeepSeek",
" provider: openai_compatible",
" base_url: https://deepseek.example/v1",
" api_key: deepseek-key",
" model: deepseek-chat",
" max_total_tokens: 64000",
"server:",
" host: 127.0.0.1",
" port: 8080",
"",
}, "\n")
if err := os.WriteFile(path, []byte(initial), 0644); err != nil {
t.Fatalf("write config: %v", err)
}
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.OpenAI.Model != "deepseek-chat" || cfg.OpenAI.APIKey != "deepseek-key" || cfg.OpenAI.MaxTotalTokens != 64000 {
t.Fatalf("runtime OpenAI config did not follow ai.default_channel: %+v", cfg.OpenAI)
}
oa, id, ok := cfg.ResolveAIChannel("qwen")
if !ok || id != "qwen" || oa.Model != "qwen-max" || oa.APIKey != "qwen-key" {
t.Fatalf("ResolveAIChannel(qwen) = (%+v, %q, %v)", oa, id, ok)
}
}
func TestSummarizationUserIntentLedgerRunesEffective(t *testing.T) {
var zero MultiAgentEinoMiddlewareConfig
if got := zero.SummarizationUserIntentLedgerMaxRunesEffective(); got != DefaultSummarizationUserIntentLedgerMaxRunes {
+185 -18
View File
@@ -46,6 +46,7 @@ func validC2TextIDForDelete(id string) bool {
// C2Listener 监听器实体
type C2Listener struct {
ID string `json:"id"`
ProjectID string `json:"project_id,omitempty"`
Name string `json:"name"`
Type string `json:"type"` // tcp_reverse|http_beacon|https_beacon|websocket|dns
BindHost string `json:"bindHost"` // 默认 127.0.0.1
@@ -165,12 +166,12 @@ func (db *DB) CreateC2Listener(l *C2Listener) error {
l.ConfigJSON = "{}"
}
query := `
INSERT INTO c2_listeners (id, name, type, bind_host, bind_port, profile_id, encryption_key,
INSERT INTO c2_listeners (id, project_id, name, type, bind_host, bind_port, profile_id, encryption_key,
implant_token, status, config_json, remark, owner_user_id, created_at, started_at, last_error)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`
_, err := db.Exec(query,
l.ID, l.Name, l.Type, l.BindHost, l.BindPort, l.ProfileID, l.EncryptionKey,
l.ID, strings.TrimSpace(l.ProjectID), l.Name, l.Type, l.BindHost, l.BindPort, l.ProfileID, l.EncryptionKey,
l.ImplantToken, l.Status, l.ConfigJSON, l.Remark, l.OwnerUserID, l.CreatedAt, l.StartedAt, l.LastError,
)
if err != nil {
@@ -190,12 +191,12 @@ func (db *DB) UpdateC2Listener(l *C2Listener) error {
}
query := `
UPDATE c2_listeners SET
name = ?, type = ?, bind_host = ?, bind_port = ?, profile_id = ?, encryption_key = ?,
project_id = ?, name = ?, type = ?, bind_host = ?, bind_port = ?, profile_id = ?, encryption_key = ?,
implant_token = ?, status = ?, config_json = ?, remark = ?, owner_user_id = ?, started_at = ?, last_error = ?
WHERE id = ?
`
res, err := db.Exec(query,
l.Name, l.Type, l.BindHost, l.BindPort, l.ProfileID, l.EncryptionKey,
strings.TrimSpace(l.ProjectID), l.Name, l.Type, l.BindHost, l.BindPort, l.ProfileID, l.EncryptionKey,
l.ImplantToken, l.Status, l.ConfigJSON, l.Remark, l.OwnerUserID, l.StartedAt, l.LastError, l.ID,
)
if err != nil {
@@ -229,7 +230,7 @@ func (db *DB) SetC2ListenerStatus(id, status, lastError string, startedAt *time.
// GetC2Listener 单条查询
func (db *DB) GetC2Listener(id string) (*C2Listener, error) {
query := `
SELECT id, name, type, bind_host, bind_port, COALESCE(profile_id, ''),
SELECT id, COALESCE(project_id, ''), name, type, bind_host, bind_port, COALESCE(profile_id, ''),
COALESCE(encryption_key, ''), COALESCE(implant_token, ''), status,
COALESCE(config_json, '{}'), COALESCE(remark, ''),
COALESCE(owner_user_id, ''), created_at, started_at, COALESCE(last_error, '')
@@ -238,7 +239,7 @@ func (db *DB) GetC2Listener(id string) (*C2Listener, error) {
var l C2Listener
var startedAt sql.NullTime
err := db.QueryRow(query, id).Scan(
&l.ID, &l.Name, &l.Type, &l.BindHost, &l.BindPort, &l.ProfileID,
&l.ID, &l.ProjectID, &l.Name, &l.Type, &l.BindHost, &l.BindPort, &l.ProfileID,
&l.EncryptionKey, &l.ImplantToken, &l.Status,
&l.ConfigJSON, &l.Remark,
&l.OwnerUserID, &l.CreatedAt, &startedAt, &l.LastError,
@@ -259,7 +260,7 @@ func (db *DB) GetC2Listener(id string) (*C2Listener, error) {
// ListC2Listeners 全量列表,按创建时间倒序
func (db *DB) ListC2Listeners() ([]*C2Listener, error) {
query := `
SELECT id, name, type, bind_host, bind_port, COALESCE(profile_id, ''),
SELECT id, COALESCE(project_id, ''), name, type, bind_host, bind_port, COALESCE(profile_id, ''),
COALESCE(encryption_key, ''), COALESCE(implant_token, ''), status,
COALESCE(config_json, '{}'), COALESCE(remark, ''),
COALESCE(owner_user_id, ''), created_at, started_at, COALESCE(last_error, '')
@@ -275,7 +276,7 @@ func (db *DB) ListC2Listeners() ([]*C2Listener, error) {
var l C2Listener
var startedAt sql.NullTime
if err := rows.Scan(
&l.ID, &l.Name, &l.Type, &l.BindHost, &l.BindPort, &l.ProfileID,
&l.ID, &l.ProjectID, &l.Name, &l.Type, &l.BindHost, &l.BindPort, &l.ProfileID,
&l.EncryptionKey, &l.ImplantToken, &l.Status,
&l.ConfigJSON, &l.Remark,
&l.OwnerUserID, &l.CreatedAt, &startedAt, &l.LastError,
@@ -293,12 +294,18 @@ func (db *DB) ListC2Listeners() ([]*C2Listener, error) {
}
// ListC2ListenersForAccess lists listeners visible to the resolved RBAC scope.
func (db *DB) ListC2ListenersForAccess(access RBACListAccess) ([]*C2Listener, error) {
func (db *DB) ListC2ListenersForAccess(access RBACListAccess, projectID string) ([]*C2Listener, error) {
conditions := []string{"1=1"}
args := []interface{}{}
if projectID = strings.TrimSpace(projectID); projectID == ProjectFilterUnbound {
conditions = append(conditions, "COALESCE(project_id, '') = ''")
} else if projectID != "" {
conditions = append(conditions, "COALESCE(project_id, '') = ?")
args = append(args, projectID)
}
appendC2ListenerAccessFilter(&conditions, &args, access)
query := `
SELECT id, name, type, bind_host, bind_port, COALESCE(profile_id, ''),
SELECT id, COALESCE(project_id, ''), name, type, bind_host, bind_port, COALESCE(profile_id, ''),
COALESCE(encryption_key, ''), COALESCE(implant_token, ''), status,
COALESCE(config_json, '{}'), COALESCE(remark, ''),
COALESCE(owner_user_id, ''), created_at, started_at, COALESCE(last_error, '')
@@ -316,7 +323,7 @@ func (db *DB) ListC2ListenersForAccess(access RBACListAccess) ([]*C2Listener, er
var l C2Listener
var startedAt sql.NullTime
if err := rows.Scan(
&l.ID, &l.Name, &l.Type, &l.BindHost, &l.BindPort, &l.ProfileID,
&l.ID, &l.ProjectID, &l.Name, &l.Type, &l.BindHost, &l.BindPort, &l.ProfileID,
&l.EncryptionKey, &l.ImplantToken, &l.Status,
&l.ConfigJSON, &l.Remark, &l.OwnerUserID,
&l.CreatedAt, &startedAt, &l.LastError,
@@ -535,6 +542,7 @@ func (db *DB) queryC2SessionWhere(whereClause string, args ...interface{}) (*C2S
// ListC2SessionsFilter 列表过滤参数
type ListC2SessionsFilter struct {
ListenerID string
ProjectID string
Status string // active|sleeping|dead|killed;空表示全部
OS string
Search string // 模糊匹配 hostname/username/internal_ip
@@ -550,6 +558,18 @@ func (db *DB) ListC2Sessions(filter ListC2SessionsFilter) ([]*C2Session, error)
conditions = append(conditions, "listener_id = ?")
args = append(args, filter.ListenerID)
}
if strings.TrimSpace(filter.ProjectID) == ProjectFilterUnbound {
conditions = append(conditions, `EXISTS (
SELECT 1 FROM c2_listeners l
WHERE l.id = c2_sessions.listener_id AND COALESCE(l.project_id, '') = ''
)`)
} else if strings.TrimSpace(filter.ProjectID) != "" {
conditions = append(conditions, `EXISTS (
SELECT 1 FROM c2_listeners l
WHERE l.id = c2_sessions.listener_id AND COALESCE(l.project_id, '') = ?
)`)
args = append(args, strings.TrimSpace(filter.ProjectID))
}
if filter.Status != "" {
conditions = append(conditions, "status = ?")
args = append(args, filter.Status)
@@ -645,6 +665,18 @@ func buildC2SessionsWhere(filter ListC2SessionsFilter) ([]string, []interface{})
conditions = append(conditions, "listener_id = ?")
args = append(args, filter.ListenerID)
}
if strings.TrimSpace(filter.ProjectID) == ProjectFilterUnbound {
conditions = append(conditions, `EXISTS (
SELECT 1 FROM c2_listeners l
WHERE l.id = c2_sessions.listener_id AND COALESCE(l.project_id, '') = ''
)`)
} else if strings.TrimSpace(filter.ProjectID) != "" {
conditions = append(conditions, `EXISTS (
SELECT 1 FROM c2_listeners l
WHERE l.id = c2_sessions.listener_id AND COALESCE(l.project_id, '') = ?
)`)
args = append(args, strings.TrimSpace(filter.ProjectID))
}
if filter.Status != "" {
conditions = append(conditions, "status = ?")
args = append(args, filter.Status)
@@ -947,7 +979,10 @@ func (db *DB) GetC2Task(id string) (*C2Task, error) {
// ListC2TasksFilter 任务过滤
type ListC2TasksFilter struct {
SessionID string
ProjectID string
Status string
TaskType string
Since *time.Time
Limit int
Offset int
}
@@ -959,10 +994,32 @@ func buildC2TasksWhere(filter ListC2TasksFilter) (where string, args []interface
conditions = append(conditions, "session_id = ?")
args = append(args, filter.SessionID)
}
if strings.TrimSpace(filter.ProjectID) == ProjectFilterUnbound {
conditions = append(conditions, `EXISTS (
SELECT 1 FROM c2_sessions s
JOIN c2_listeners l ON l.id = s.listener_id
WHERE s.id = c2_tasks.session_id AND COALESCE(l.project_id, '') = ''
)`)
} else if strings.TrimSpace(filter.ProjectID) != "" {
conditions = append(conditions, `EXISTS (
SELECT 1 FROM c2_sessions s
JOIN c2_listeners l ON l.id = s.listener_id
WHERE s.id = c2_tasks.session_id AND COALESCE(l.project_id, '') = ?
)`)
args = append(args, strings.TrimSpace(filter.ProjectID))
}
if filter.Status != "" {
conditions = append(conditions, "status = ?")
args = append(args, filter.Status)
}
if strings.TrimSpace(filter.TaskType) != "" {
conditions = append(conditions, "task_type = ?")
args = append(args, strings.TrimSpace(filter.TaskType))
}
if filter.Since != nil {
conditions = append(conditions, sqliteEpochGE("created_at", ">="))
args = append(args, formatSQLiteUTC(*filter.Since))
}
return strings.Join(conditions, " AND "), args
}
@@ -1016,6 +1073,43 @@ func (db *DB) CountC2TasksForAccess(filter ListC2TasksFilter, access RBACListAcc
return n, err
}
// CountC2TasksByStatusForAccess 与 ListC2Tasks 相同过滤条件下按状态统计
func (db *DB) CountC2TasksByStatusForAccess(filter ListC2TasksFilter, access RBACListAccess) (map[string]int64, error) {
where, args := buildC2TasksWhereForAccess(filter, access)
query := `SELECT status, COUNT(*) FROM c2_tasks WHERE ` + where + ` GROUP BY status`
rows, err := db.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
counts := map[string]int64{
"queued": 0,
"sent": 0,
"running": 0,
"success": 0,
"failed": 0,
"cancelled": 0,
"pending": 0,
}
var legacyPending int64
for rows.Next() {
var status string
var n int64
if err := rows.Scan(&status, &n); err != nil {
continue
}
if status == "pending" {
legacyPending = n
continue
}
if _, ok := counts[status]; ok {
counts[status] = n
}
}
counts["pending"] = counts["queued"] + counts["sent"] + counts["running"] + legacyPending
return counts, rows.Err()
}
// CountC2TasksQueuedOrPending 统计 queued/pending 状态任务数(仪表盘「待审任务」)
func (db *DB) CountC2TasksQueuedOrPending(sessionID string) (int64, error) {
conditions := []string{"status IN ('queued', 'pending')"}
@@ -1030,8 +1124,8 @@ func (db *DB) CountC2TasksQueuedOrPending(sessionID string) (int64, error) {
return n, err
}
func (db *DB) CountC2TasksQueuedOrPendingForAccess(sessionID string, access RBACListAccess) (int64, error) {
filter := ListC2TasksFilter{SessionID: sessionID}
func (db *DB) CountC2TasksQueuedOrPendingForAccess(sessionID, projectID string, access RBACListAccess) (int64, error) {
filter := ListC2TasksFilter{SessionID: sessionID, ProjectID: projectID}
where, args := buildC2TasksWhereForAccess(filter, access)
query := `SELECT COUNT(*) FROM c2_tasks WHERE status IN ('queued', 'pending') AND ` + where
var n int64
@@ -1387,7 +1481,9 @@ func (db *DB) AppendC2Event(e *C2Event) error {
return errors.New("event id is required")
}
if e.CreatedAt.IsZero() {
e.CreatedAt = time.Now()
e.CreatedAt = time.Now().UTC()
} else {
e.CreatedAt = e.CreatedAt.UTC()
}
if strings.TrimSpace(e.Level) == "" {
e.Level = "info"
@@ -1402,7 +1498,7 @@ func (db *DB) AppendC2Event(e *C2Event) error {
INSERT INTO c2_events (id, level, category, session_id, task_id, message, data_json, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`
_, err := db.Exec(query, e.ID, e.Level, e.Category, e.SessionID, e.TaskID, e.Message, dataJSON, e.CreatedAt)
_, err := db.Exec(query, e.ID, e.Level, e.Category, e.SessionID, e.TaskID, e.Message, dataJSON, formatSQLiteUTC(e.CreatedAt))
return err
}
@@ -1410,6 +1506,7 @@ func (db *DB) AppendC2Event(e *C2Event) error {
type ListC2EventsFilter struct {
Level string
Category string
ProjectID string
SessionID string
TaskID string
Since *time.Time
@@ -1428,6 +1525,49 @@ func buildC2EventsWhere(filter ListC2EventsFilter) (where string, args []interfa
conditions = append(conditions, "category = ?")
args = append(args, filter.Category)
}
if strings.TrimSpace(filter.ProjectID) == ProjectFilterUnbound {
conditions = append(conditions, `(
EXISTS (
SELECT 1 FROM c2_sessions s
JOIN c2_listeners l ON l.id = s.listener_id
WHERE s.id = c2_events.session_id AND COALESCE(l.project_id, '') = ''
)
OR EXISTS (
SELECT 1 FROM c2_tasks t
JOIN c2_sessions s ON s.id = t.session_id
JOIN c2_listeners l ON l.id = s.listener_id
WHERE t.id = c2_events.task_id AND COALESCE(l.project_id, '') = ''
)
OR EXISTS (
SELECT 1 FROM c2_listeners l
WHERE json_valid(c2_events.data_json)
AND l.id = json_extract(c2_events.data_json, '$.listener_id')
AND COALESCE(l.project_id, '') = ''
)
)`)
} else if strings.TrimSpace(filter.ProjectID) != "" {
conditions = append(conditions, `(
EXISTS (
SELECT 1 FROM c2_sessions s
JOIN c2_listeners l ON l.id = s.listener_id
WHERE s.id = c2_events.session_id AND COALESCE(l.project_id, '') = ?
)
OR EXISTS (
SELECT 1 FROM c2_tasks t
JOIN c2_sessions s ON s.id = t.session_id
JOIN c2_listeners l ON l.id = s.listener_id
WHERE t.id = c2_events.task_id AND COALESCE(l.project_id, '') = ?
)
OR EXISTS (
SELECT 1 FROM c2_listeners l
WHERE json_valid(c2_events.data_json)
AND l.id = json_extract(c2_events.data_json, '$.listener_id')
AND COALESCE(l.project_id, '') = ?
)
)`)
pid := strings.TrimSpace(filter.ProjectID)
args = append(args, pid, pid, pid)
}
if filter.SessionID != "" {
conditions = append(conditions, "session_id = ?")
args = append(args, filter.SessionID)
@@ -1437,8 +1577,8 @@ func buildC2EventsWhere(filter ListC2EventsFilter) (where string, args []interfa
args = append(args, filter.TaskID)
}
if filter.Since != nil {
conditions = append(conditions, "created_at >= ?")
args = append(args, *filter.Since)
conditions = append(conditions, sqliteEpochGE("created_at", ">="))
args = append(args, formatSQLiteUTC(*filter.Since))
}
return strings.Join(conditions, " AND "), args
}
@@ -1510,6 +1650,33 @@ func (db *DB) CountC2EventsForAccess(filter ListC2EventsFilter, access RBACListA
return n, err
}
// CountC2EventsByLevelForAccess 与 ListC2Events 相同过滤条件下按级别统计
func (db *DB) CountC2EventsByLevelForAccess(filter ListC2EventsFilter, access RBACListAccess) (map[string]int64, error) {
where, args := buildC2EventsWhereForAccess(filter, access)
query := `SELECT level, COUNT(*) FROM c2_events WHERE ` + where + ` GROUP BY level`
rows, err := db.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
counts := map[string]int64{
"info": 0,
"warn": 0,
"critical": 0,
}
for rows.Next() {
var level string
var n int64
if err := rows.Scan(&level, &n); err != nil {
continue
}
if _, ok := counts[level]; ok {
counts[level] = n
}
}
return counts, rows.Err()
}
// ListC2Events 事件查询,按创建时间倒序
func (db *DB) ListC2Events(filter ListC2EventsFilter) ([]*C2Event, error) {
where, args := buildC2EventsWhere(filter)
+115 -97
View File
@@ -22,6 +22,8 @@ type Conversation struct {
ID string `json:"id"`
Title string `json:"title"`
ProjectID string `json:"projectId,omitempty"`
RoleName string `json:"roleName,omitempty"`
AgentMode string `json:"agentMode,omitempty"`
Pinned bool `json:"pinned"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
@@ -57,29 +59,31 @@ func (db *DB) CreateConversationWithWebshell(webshellConnectionID, title string,
return nil, err
}
}
roleName := normalizeConversationRoleName(meta.RoleName)
agentMode := normalizeConversationAgentMode(meta.AgentMode)
var err error
wsID := strings.TrimSpace(webshellConnectionID)
switch {
case wsID != "" && projectID != "":
_, err = db.Exec(
"INSERT INTO conversations (id, title, created_at, updated_at, webshell_connection_id, project_id) VALUES (?, ?, ?, ?, ?, ?)",
id, title, now, now, wsID, projectID,
"INSERT INTO conversations (id, title, created_at, updated_at, webshell_connection_id, project_id, role_name, agent_mode) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
id, title, now, now, wsID, projectID, roleName, agentMode,
)
case wsID != "":
_, err = db.Exec(
"INSERT INTO conversations (id, title, created_at, updated_at, webshell_connection_id) VALUES (?, ?, ?, ?, ?)",
id, title, now, now, wsID,
"INSERT INTO conversations (id, title, created_at, updated_at, webshell_connection_id, role_name, agent_mode) VALUES (?, ?, ?, ?, ?, ?, ?)",
id, title, now, now, wsID, roleName, agentMode,
)
case projectID != "":
_, err = db.Exec(
"INSERT INTO conversations (id, title, created_at, updated_at, project_id) VALUES (?, ?, ?, ?, ?)",
id, title, now, now, projectID,
"INSERT INTO conversations (id, title, created_at, updated_at, project_id, role_name, agent_mode) VALUES (?, ?, ?, ?, ?, ?, ?)",
id, title, now, now, projectID, roleName, agentMode,
)
default:
_, err = db.Exec(
"INSERT INTO conversations (id, title, created_at, updated_at) VALUES (?, ?, ?, ?)",
id, title, now, now,
"INSERT INTO conversations (id, title, created_at, updated_at, role_name, agent_mode) VALUES (?, ?, ?, ?, ?, ?)",
id, title, now, now, roleName, agentMode,
)
}
if err != nil {
@@ -90,6 +94,8 @@ func (db *DB) CreateConversationWithWebshell(webshellConnectionID, title string,
ID: id,
Title: title,
ProjectID: projectID,
RoleName: roleName,
AgentMode: agentMode,
CreatedAt: now,
UpdatedAt: now,
}
@@ -236,10 +242,12 @@ func (db *DB) GetConversation(id string) (*Conversation, error) {
var pinned int
var projectID sql.NullString
var roleName sql.NullString
var agentMode sql.NullString
err := db.QueryRow(
"SELECT id, title, pinned, created_at, updated_at, project_id FROM conversations WHERE id = ?",
"SELECT id, title, pinned, created_at, updated_at, project_id, role_name, agent_mode FROM conversations WHERE id = ?",
id,
).Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt, &projectID)
).Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt, &projectID, &roleName, &agentMode)
if err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("对话不存在")
@@ -249,6 +257,12 @@ func (db *DB) GetConversation(id string) (*Conversation, error) {
if projectID.Valid {
conv.ProjectID = strings.TrimSpace(projectID.String)
}
if roleName.Valid {
conv.RoleName = normalizeConversationRoleName(roleName.String)
}
if agentMode.Valid {
conv.AgentMode = normalizeConversationAgentMode(agentMode.String)
}
// 尝试多种时间格式解析
var err1, err2 error
@@ -322,10 +336,12 @@ func (db *DB) GetConversationLite(id string) (*Conversation, error) {
var pinned int
var projectID sql.NullString
var roleName sql.NullString
var agentMode sql.NullString
err := db.QueryRow(
"SELECT id, title, pinned, created_at, updated_at, project_id FROM conversations WHERE id = ?",
"SELECT id, title, pinned, created_at, updated_at, project_id, role_name, agent_mode FROM conversations WHERE id = ?",
id,
).Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt, &projectID)
).Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt, &projectID, &roleName, &agentMode)
if err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("对话不存在")
@@ -335,6 +351,12 @@ func (db *DB) GetConversationLite(id string) (*Conversation, error) {
if projectID.Valid {
conv.ProjectID = strings.TrimSpace(projectID.String)
}
if roleName.Valid {
conv.RoleName = normalizeConversationRoleName(roleName.String)
}
if agentMode.Valid {
conv.AgentMode = normalizeConversationAgentMode(agentMode.String)
}
// 尝试多种时间格式解析
var err1, err2 error
@@ -365,6 +387,49 @@ func (db *DB) GetConversationLite(id string) (*Conversation, error) {
return &conv, nil
}
func normalizeConversationRoleName(roleName string) string {
roleName = strings.TrimSpace(roleName)
if roleName == "" {
return "默认"
}
return roleName
}
func normalizeConversationAgentMode(agentMode string) string {
agentMode = strings.ToLower(strings.TrimSpace(agentMode))
agentMode = strings.ReplaceAll(agentMode, "-", "_")
switch agentMode {
case "deep", "plan_execute", "supervisor":
return agentMode
default:
return "eino_single"
}
}
func (db *DB) SetConversationRoleName(id, roleName string) error {
roleName = normalizeConversationRoleName(roleName)
_, err := db.Exec(
"UPDATE conversations SET role_name = ?, updated_at = ? WHERE id = ?",
roleName, time.Now(), id,
)
if err != nil {
return fmt.Errorf("更新对话角色失败: %w", err)
}
return nil
}
func (db *DB) SetConversationAgentMode(id, agentMode string) error {
agentMode = normalizeConversationAgentMode(agentMode)
_, err := db.Exec(
"UPDATE conversations SET agent_mode = ? WHERE id = ?",
agentMode, id,
)
if err != nil {
return fmt.Errorf("更新对话模式失败: %w", err)
}
return nil
}
func conversationProjectIDColumn(alias string) string {
if alias != "" {
return alias + ".project_id"
@@ -489,7 +554,7 @@ func (db *DB) ListConversations(limit, offset int, search, sortBy, projectID str
where, args = appendConversationProjectFilter(where, args, projectID, "c")
args = append(args, limit, offset)
rows, err = db.Query(
`SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id
`SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id, c.role_name, c.agent_mode
FROM conversations c`+where+`
`+orderClause+`
LIMIT ? OFFSET ?`,
@@ -505,7 +570,7 @@ func (db *DB) ListConversations(limit, offset int, search, sortBy, projectID str
}
args = append(args, limit, offset)
rows, err = db.Query(
"SELECT id, title, COALESCE(pinned, 0), created_at, updated_at, project_id FROM conversations"+where+" "+orderClause+" LIMIT ? OFFSET ?",
"SELECT id, title, COALESCE(pinned, 0), created_at, updated_at, project_id, role_name, agent_mode FROM conversations"+where+" "+orderClause+" LIMIT ? OFFSET ?",
args...,
)
}
@@ -514,45 +579,7 @@ func (db *DB) ListConversations(limit, offset int, search, sortBy, projectID str
return nil, fmt.Errorf("查询对话列表失败: %w", err)
}
defer rows.Close()
var conversations []*Conversation
for rows.Next() {
var conv Conversation
var createdAt, updatedAt string
var pinned int
var projectID sql.NullString
if err := rows.Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt, &projectID); err != nil {
return nil, fmt.Errorf("扫描对话失败: %w", err)
}
if projectID.Valid {
conv.ProjectID = strings.TrimSpace(projectID.String)
}
// 尝试多种时间格式解析
var err1, err2 error
conv.CreatedAt, err1 = time.Parse("2006-01-02 15:04:05.999999999-07:00", createdAt)
if err1 != nil {
conv.CreatedAt, err1 = time.Parse("2006-01-02 15:04:05", createdAt)
}
if err1 != nil {
conv.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
}
conv.UpdatedAt, err2 = time.Parse("2006-01-02 15:04:05.999999999-07:00", updatedAt)
if err2 != nil {
conv.UpdatedAt, err2 = time.Parse("2006-01-02 15:04:05", updatedAt)
}
if err2 != nil {
conv.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt)
}
conv.Pinned = pinned != 0
conversations = append(conversations, &conv)
}
return conversations, nil
return scanConversationRows(rows)
}
func (db *DB) ListConversationsForAccess(limit, offset int, search, sortBy, projectID, userID, scope string) ([]*Conversation, error) {
@@ -571,7 +598,7 @@ func (db *DB) ListConversationsForAccess(limit, offset int, search, sortBy, proj
where, args = appendConversationAccessFilter(where, args, userID, scope, "c")
args = append(args, limit, offset)
rows, err = db.Query(
`SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id
`SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id, c.role_name, c.agent_mode
FROM conversations c`+where+`
`+orderClause+`
LIMIT ? OFFSET ?`, args...)
@@ -586,7 +613,7 @@ func (db *DB) ListConversationsForAccess(limit, offset int, search, sortBy, proj
}
args = append(args, limit, offset)
rows, err = db.Query(
"SELECT id, title, COALESCE(pinned, 0), created_at, updated_at, project_id FROM conversations"+where+" "+orderClause+" LIMIT ? OFFSET ?",
"SELECT id, title, COALESCE(pinned, 0), created_at, updated_at, project_id, role_name, agent_mode FROM conversations"+where+" "+orderClause+" LIMIT ? OFFSET ?",
args...)
}
if err != nil {
@@ -603,12 +630,20 @@ func scanConversationRows(rows *sql.Rows) ([]*Conversation, error) {
var createdAt, updatedAt string
var pinned int
var projectID sql.NullString
if err := rows.Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt, &projectID); err != nil {
var roleName sql.NullString
var agentMode sql.NullString
if err := rows.Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt, &projectID, &roleName, &agentMode); err != nil {
return nil, fmt.Errorf("扫描对话失败: %w", err)
}
if projectID.Valid {
conv.ProjectID = strings.TrimSpace(projectID.String)
}
if roleName.Valid {
conv.RoleName = normalizeConversationRoleName(roleName.String)
}
if agentMode.Valid {
conv.AgentMode = normalizeConversationAgentMode(agentMode.String)
}
var err1, err2 error
conv.CreatedAt, err1 = time.Parse("2006-01-02 15:04:05.999999999-07:00", createdAt)
if err1 != nil {
@@ -668,7 +703,7 @@ func (db *DB) ListUngroupedConversations(limit, offset int, sortBy, projectID st
where, args = appendConversationProjectFilter(where, args, projectID, "c")
args = append(args, limit, offset)
rows, err := db.Query(
`SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id `+
`SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id, c.role_name, c.agent_mode `+
where+`
`+orderClause+`
LIMIT ? OFFSET ?`,
@@ -678,43 +713,7 @@ func (db *DB) ListUngroupedConversations(limit, offset int, sortBy, projectID st
return nil, fmt.Errorf("查询未分组对话失败: %w", err)
}
defer rows.Close()
var conversations []*Conversation
for rows.Next() {
var conv Conversation
var createdAt, updatedAt string
var pinned int
var projectID sql.NullString
if err := rows.Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt, &projectID); err != nil {
return nil, fmt.Errorf("扫描对话失败: %w", err)
}
if projectID.Valid {
conv.ProjectID = strings.TrimSpace(projectID.String)
}
var err1, err2 error
conv.CreatedAt, err1 = time.Parse("2006-01-02 15:04:05.999999999-07:00", createdAt)
if err1 != nil {
conv.CreatedAt, err1 = time.Parse("2006-01-02 15:04:05", createdAt)
}
if err1 != nil {
conv.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
}
conv.UpdatedAt, err2 = time.Parse("2006-01-02 15:04:05.999999999-07:00", updatedAt)
if err2 != nil {
conv.UpdatedAt, err2 = time.Parse("2006-01-02 15:04:05", updatedAt)
}
if err2 != nil {
conv.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt)
}
conv.Pinned = pinned != 0
conversations = append(conversations, &conv)
}
return conversations, rows.Err()
return scanConversationRows(rows)
}
func (db *DB) ListUngroupedConversationsForAccess(limit, offset int, sortBy, projectID, userID, scope string) ([]*Conversation, error) {
@@ -728,7 +727,7 @@ func (db *DB) ListUngroupedConversationsForAccess(limit, offset int, sortBy, pro
where, args = appendConversationAccessFilter(where, args, userID, scope, "c")
args = append(args, limit, offset)
rows, err := db.Query(
`SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id `+
`SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id, c.role_name, c.agent_mode `+
where+`
`+orderClause+`
LIMIT ? OFFSET ?`,
@@ -861,6 +860,24 @@ func (db *DB) einoReductionBaseDir() string {
return filepath.Join("tmp", "reduction")
}
// EinoReductionBaseDir returns the configured reduction cache root.
func (db *DB) EinoReductionBaseDir() string {
return db.einoReductionBaseDir()
}
// ConversationArtifactsBaseDir returns the conversation-scoped artifacts root.
func (db *DB) ConversationArtifactsBaseDir() string {
if db == nil {
return ""
}
return strings.TrimSpace(db.conversationArtifactsDir)
}
// EinoWorkspaceBaseDir returns the configured agent workspace root.
func (db *DB) EinoWorkspaceBaseDir() string {
return db.einoWorkspaceBaseDir()
}
func (db *DB) einoWorkspaceBaseDir() string {
if db == nil {
return ""
@@ -1445,7 +1462,8 @@ func (db *DB) GetProcessDetailsSummary(messageID string) (*ProcessDetailsSummary
seenExecIDs := make(map[string]bool)
// A provider may reuse a fallback toolCallId across streaming rounds. Keep a
// FIFO per ID instead of a single index so every persisted call gets at most
// one result. Results without an ID fall back to the oldest unmatched call.
// one result. Results without a stable ID are kept separate instead of being
// guessed by order; showing no link is safer than linking to the wrong tool.
toolIndexesByCallID := make(map[string][]int)
lastMatchedToolIndexByCallID := make(map[string]int)
matchedToolIndexes := make([]bool, 0)
@@ -1520,7 +1538,7 @@ func (db *DB) GetProcessDetailsSummary(messageID string) (*ProcessDetailsSummary
}
}
}
if idx < 0 {
if idx < 0 && toolCallID != "" {
for nextUnmatchedToolIdx < len(matchedToolIndexes) && matchedToolIndexes[nextUnmatchedToolIdx] {
nextUnmatchedToolIdx++
}
@@ -5,6 +5,8 @@ type ConversationCreateMeta struct {
Source string
WebShellConnectionID string
ProjectID string
RoleName string
AgentMode string
ClientIP string
SessionHint string
}
+68
View File
@@ -183,6 +183,8 @@ func (db *DB) initTables() error {
title TEXT NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
role_name TEXT NOT NULL DEFAULT '默认',
agent_mode TEXT NOT NULL DEFAULT 'eino_single',
last_react_input TEXT,
last_react_output TEXT
);`
@@ -226,6 +228,10 @@ func (db *DB) initTables() error {
start_time DATETIME NOT NULL,
end_time DATETIME,
duration_ms INTEGER,
partial_output TEXT,
partial_output_bytes INTEGER NOT NULL DEFAULT 0,
partial_output_truncated INTEGER NOT NULL DEFAULT 0,
partial_output_updated_at DATETIME,
owner_user_id TEXT,
conversation_id TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
@@ -489,6 +495,7 @@ func (db *DB) initTables() error {
createWebshellConnectionsTable := `
CREATE TABLE IF NOT EXISTS webshell_connections (
id TEXT PRIMARY KEY,
project_id TEXT,
url TEXT NOT NULL,
password TEXT NOT NULL DEFAULT '',
type TEXT NOT NULL DEFAULT 'php',
@@ -515,6 +522,7 @@ func (db *DB) initTables() error {
createC2ListenersTable := `
CREATE TABLE IF NOT EXISTS c2_listeners (
id TEXT PRIMARY KEY,
project_id TEXT,
name TEXT NOT NULL,
type TEXT NOT NULL,
bind_host TEXT NOT NULL DEFAULT '127.0.0.1',
@@ -751,8 +759,10 @@ func (db *DB) initTables() error {
CREATE INDEX IF NOT EXISTS idx_batch_task_queues_created_at ON batch_task_queues(created_at);
CREATE INDEX IF NOT EXISTS idx_batch_task_queues_title ON batch_task_queues(title);
CREATE INDEX IF NOT EXISTS idx_webshell_connections_created_at ON webshell_connections(created_at);
CREATE INDEX IF NOT EXISTS idx_webshell_connections_project_id ON webshell_connections(project_id);
CREATE INDEX IF NOT EXISTS idx_webshell_connection_states_updated_at ON webshell_connection_states(updated_at);
CREATE INDEX IF NOT EXISTS idx_c2_listeners_created_at ON c2_listeners(created_at);
CREATE INDEX IF NOT EXISTS idx_c2_listeners_project_id ON c2_listeners(project_id);
CREATE INDEX IF NOT EXISTS idx_c2_listeners_status ON c2_listeners(status);
CREATE INDEX IF NOT EXISTS idx_c2_sessions_listener ON c2_sessions(listener_id);
CREATE INDEX IF NOT EXISTS idx_c2_sessions_status ON c2_sessions(status);
@@ -951,9 +961,15 @@ func (db *DB) initTables() error {
db.logger.Warn("迁移webshell_connections表失败", zap.Error(err))
// 不返回错误,允许继续运行
}
if err := db.migrateC2ListenersTable(); err != nil {
db.logger.Warn("迁移c2_listeners表失败", zap.Error(err))
}
if err := db.migrateWorkflowRunsTable(); err != nil {
db.logger.Warn("迁移workflow_runs表失败", zap.Error(err))
}
if err := db.migrateToolExecutionsPartialOutputColumns(); err != nil {
db.logger.Warn("迁移tool_executions partial output字段失败", zap.Error(err))
}
if err := db.migrateRBACOwnershipColumns(); err != nil {
db.logger.Warn("迁移RBAC资源归属字段失败", zap.Error(err))
}
@@ -978,6 +994,23 @@ func (db *DB) migrateRobotUserSessionsTable() error {
return nil
}
func (db *DB) migrateToolExecutionsPartialOutputColumns() error {
for _, col := range []struct {
name string
stmt string
}{
{"partial_output", "ALTER TABLE tool_executions ADD COLUMN partial_output TEXT"},
{"partial_output_bytes", "ALTER TABLE tool_executions ADD COLUMN partial_output_bytes INTEGER NOT NULL DEFAULT 0"},
{"partial_output_truncated", "ALTER TABLE tool_executions ADD COLUMN partial_output_truncated INTEGER NOT NULL DEFAULT 0"},
{"partial_output_updated_at", "ALTER TABLE tool_executions ADD COLUMN partial_output_updated_at DATETIME"},
} {
if err := db.addColumnIfMissing("tool_executions", col.name, col.stmt); err != nil {
return err
}
}
return nil
}
// migrateAssetsTable keeps databases created by the first asset-management release compatible.
func (db *DB) migrateAssetsTable() error {
columns := []struct {
@@ -1127,6 +1160,36 @@ func (db *DB) migrateConversationsTable() error {
}
}
// 检查 role_name 字段是否存在(对话绑定的业务角色,用于历史任务切换时恢复角色上下文)
err = db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('conversations') WHERE name='role_name'").Scan(&count)
if err != nil {
if _, addErr := db.Exec("ALTER TABLE conversations ADD COLUMN role_name TEXT NOT NULL DEFAULT '默认'"); addErr != nil {
errMsg := strings.ToLower(addErr.Error())
if !strings.Contains(errMsg, "duplicate column") && !strings.Contains(errMsg, "already exists") {
db.logger.Warn("添加role_name字段失败", zap.Error(addErr))
}
}
} else if count == 0 {
if _, err := db.Exec("ALTER TABLE conversations ADD COLUMN role_name TEXT NOT NULL DEFAULT '默认'"); err != nil {
db.logger.Warn("添加role_name字段失败", zap.Error(err))
}
}
// 检查 agent_mode 字段是否存在(对话绑定的执行模式,用于历史任务切换时恢复对话模式)
err = db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('conversations') WHERE name='agent_mode'").Scan(&count)
if err != nil {
if _, addErr := db.Exec("ALTER TABLE conversations ADD COLUMN agent_mode TEXT NOT NULL DEFAULT 'eino_single'"); addErr != nil {
errMsg := strings.ToLower(addErr.Error())
if !strings.Contains(errMsg, "duplicate column") && !strings.Contains(errMsg, "already exists") {
db.logger.Warn("添加agent_mode字段失败", zap.Error(addErr))
}
}
} else if count == 0 {
if _, err := db.Exec("ALTER TABLE conversations ADD COLUMN agent_mode TEXT NOT NULL DEFAULT 'eino_single'"); err != nil {
db.logger.Warn("添加agent_mode字段失败", zap.Error(err))
}
}
return nil
}
@@ -1570,6 +1633,7 @@ func (db *DB) migrateWebshellConnectionsTable() error {
name string
stmt string
}{
{name: "project_id", stmt: "ALTER TABLE webshell_connections ADD COLUMN project_id TEXT"},
{name: "encoding", stmt: "ALTER TABLE webshell_connections ADD COLUMN encoding TEXT NOT NULL DEFAULT ''"},
{name: "os", stmt: "ALTER TABLE webshell_connections ADD COLUMN os TEXT NOT NULL DEFAULT ''"},
}
@@ -1595,6 +1659,10 @@ func (db *DB) migrateWebshellConnectionsTable() error {
return nil
}
func (db *DB) migrateC2ListenersTable() error {
return db.addColumnIfMissing("c2_listeners", "project_id", "ALTER TABLE c2_listeners ADD COLUMN project_id TEXT")
}
// NewKnowledgeDB 创建知识库数据库连接(只包含知识库相关的表)
func NewKnowledgeDB(dbPath string, logger *zap.Logger) (*DB, error) {
sqlDB, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&_foreign_keys=1&_busy_timeout=5000&_synchronous=NORMAL")
+61 -24
View File
@@ -43,11 +43,19 @@ func (db *DB) SaveToolExecution(exec *mcp.ToolExecution) error {
if exec.Duration > 0 {
durationMs = sql.NullInt64{Int64: exec.Duration.Milliseconds(), Valid: true}
}
var partialUpdatedAt sql.NullTime
if exec.PartialOutputUpdatedAt != nil {
partialUpdatedAt = sql.NullTime{Time: *exec.PartialOutputUpdatedAt, Valid: true}
}
partialTruncated := 0
if exec.PartialOutputTruncated {
partialTruncated = 1
}
query := `
INSERT OR REPLACE INTO tool_executions
(id, tool_name, arguments, status, result, error, start_time, end_time, duration_ms, owner_user_id, conversation_id, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
(id, tool_name, arguments, status, result, error, start_time, end_time, duration_ms, partial_output, partial_output_bytes, partial_output_truncated, partial_output_updated_at, owner_user_id, conversation_id, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`
_, err = db.Exec(query,
@@ -60,6 +68,10 @@ func (db *DB) SaveToolExecution(exec *mcp.ToolExecution) error {
exec.StartTime,
endTime,
durationMs,
sqlNullString(exec.PartialOutput),
exec.PartialOutputBytes,
partialTruncated,
partialUpdatedAt,
strings.TrimSpace(exec.OwnerUserID),
strings.TrimSpace(exec.ConversationID),
time.Now(),
@@ -90,6 +102,13 @@ func (db *DB) UpdateToolExecutionResult(id string, result *mcp.ToolResult) error
return err
}
func sqlNullString(s string) sql.NullString {
if s == "" {
return sql.NullString{}
}
return sql.NullString{String: s, Valid: true}
}
// CountToolExecutions 统计工具执行记录总数
func (db *DB) CountToolExecutions(status, toolName string) (int, error) {
return db.CountToolExecutionsForAccess(status, toolName, RBACListAccess{Scope: RBACScopeAll})
@@ -267,7 +286,8 @@ type ToolStatsSummaryResult struct {
TopTools []*mcp.ToolStats
}
// LoadToolStatsSummary 聚合统计信息,仅返回汇总与 Top N 工具(避免全量 map 传输)
// LoadToolStatsSummary 聚合统计信息,仅返回汇总与 Top N 工具(避免全量 map 传输)
// 监控页的失败口径只包含真实失败/异常终止;用户主动取消的 cancelled 保留在总调用中,不计入失败。
func (db *DB) LoadToolStatsSummary(topN int) (*ToolStatsSummaryResult, error) {
if topN <= 0 {
topN = 6
@@ -282,19 +302,19 @@ func (db *DB) LoadToolStatsSummary(topN int) (*ToolStatsSummaryResult, error) {
summaryQuery := `
SELECT COUNT(*),
COALESCE(SUM(total_calls), 0),
COALESCE(SUM(success_calls), 0),
COALESCE(SUM(failed_calls), 0),
MAX(last_call_time)
FROM tool_stats
COALESCE(SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN status IN ('failed', 'hard_timeout', 'orphaned') THEN 1 ELSE 0 END), 0),
MAX(start_time),
COUNT(DISTINCT tool_name)
FROM tool_executions
`
var lastCallRaw sql.NullString
err := db.QueryRow(summaryQuery).Scan(
&result.Summary.ToolCount,
&result.Summary.TotalCalls,
&result.Summary.SuccessCalls,
&result.Summary.FailedCalls,
&lastCallRaw,
&result.Summary.ToolCount,
)
if err != nil {
return nil, err
@@ -310,9 +330,13 @@ func (db *DB) LoadToolStatsSummary(topN int) (*ToolStatsSummaryResult, error) {
}
topQuery := `
SELECT tool_name, total_calls, success_calls, failed_calls, last_call_time
FROM tool_stats
WHERE total_calls > 0
SELECT tool_name,
COUNT(*) AS total_calls,
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) AS success_calls,
SUM(CASE WHEN status IN ('failed', 'hard_timeout', 'orphaned') THEN 1 ELSE 0 END) AS failed_calls,
MAX(start_time) AS last_call_time
FROM tool_executions
GROUP BY tool_name
ORDER BY total_calls DESC, tool_name ASC
LIMIT ?
`
@@ -324,7 +348,7 @@ func (db *DB) LoadToolStatsSummary(topN int) (*ToolStatsSummaryResult, error) {
for rows.Next() {
var stat mcp.ToolStats
var lastCallTime sql.NullTime
var lastCallTime sql.NullString
if err := rows.Scan(
&stat.ToolName,
&stat.TotalCalls,
@@ -336,7 +360,8 @@ func (db *DB) LoadToolStatsSummary(topN int) (*ToolStatsSummaryResult, error) {
continue
}
if lastCallTime.Valid {
stat.LastCallTime = &lastCallTime.Time
parsed := parseDBTime(lastCallTime.String)
stat.LastCallTime = &parsed
}
result.TopTools = append(result.TopTools, &stat)
}
@@ -359,7 +384,7 @@ func (db *DB) LoadToolStatsSummaryForAccess(topN int, access RBACListAccess) (*T
var lastCall sql.NullString
err := db.QueryRow(`SELECT COUNT(*),
COALESCE(SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN status IN ('failed', 'cancelled') THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN status IN ('failed', 'hard_timeout', 'orphaned') THEN 1 ELSE 0 END), 0),
MAX(start_time), COUNT(DISTINCT tool_name)`+fromSQL, args...).Scan(
&result.Summary.TotalCalls, &result.Summary.SuccessCalls, &result.Summary.FailedCalls,
&lastCall, &result.Summary.ToolCount,
@@ -373,7 +398,7 @@ func (db *DB) LoadToolStatsSummaryForAccess(topN int, access RBACListAccess) (*T
}
rows, err := db.Query(`SELECT tool_name, COUNT(*),
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END),
SUM(CASE WHEN status IN ('failed', 'cancelled') THEN 1 ELSE 0 END), MAX(start_time)`+
SUM(CASE WHEN status IN ('failed', 'hard_timeout', 'orphaned') THEN 1 ELSE 0 END), MAX(start_time)`+
fromSQL+` GROUP BY tool_name ORDER BY COUNT(*) DESC, tool_name ASC LIMIT ?`, append(args, topN)...)
if err != nil {
return nil, err
@@ -482,7 +507,9 @@ func appendToolExecutionAccessSQL(query string, args []interface{}, access RBACL
// GetToolExecution 根据ID获取单条工具执行记录
func (db *DB) GetToolExecution(id string) (*mcp.ToolExecution, error) {
query := `
SELECT id, tool_name, arguments, status, result, error, start_time, end_time, duration_ms, COALESCE(owner_user_id, ''), COALESCE(conversation_id, '')
SELECT id, tool_name, arguments, status, result, error, start_time, end_time, duration_ms,
COALESCE(partial_output, ''), COALESCE(partial_output_bytes, 0), COALESCE(partial_output_truncated, 0), partial_output_updated_at,
COALESCE(owner_user_id, ''), COALESCE(conversation_id, '')
FROM tool_executions
WHERE id = ?
`
@@ -495,6 +522,8 @@ func (db *DB) GetToolExecution(id string) (*mcp.ToolExecution, error) {
var errorText sql.NullString
var endTime sql.NullTime
var durationMs sql.NullInt64
var partialTruncated int
var partialUpdatedAt sql.NullTime
err := row.Scan(
&exec.ID,
@@ -506,6 +535,10 @@ func (db *DB) GetToolExecution(id string) (*mcp.ToolExecution, error) {
&exec.StartTime,
&endTime,
&durationMs,
&exec.PartialOutput,
&exec.PartialOutputBytes,
&partialTruncated,
&partialUpdatedAt,
&exec.OwnerUserID,
&exec.ConversationID,
)
@@ -538,6 +571,10 @@ func (db *DB) GetToolExecution(id string) (*mcp.ToolExecution, error) {
if durationMs.Valid {
exec.Duration = time.Duration(durationMs.Int64) * time.Millisecond
}
exec.PartialOutputTruncated = partialTruncated != 0
if partialUpdatedAt.Valid {
exec.PartialOutputUpdatedAt = &partialUpdatedAt.Time
}
return &exec, nil
}
@@ -565,7 +602,7 @@ func (db *DB) UserCanAccessToolExecution(userID, scope, executionID string) bool
return conversation != "" && db.UserCanAccessResource(userID, scope, "conversation", conversation)
}
// CancelOrphanedRunningToolExecutions 将仍为 running 的记录批量标记为 cancelled(如进程重启后无对应执行协程)。
// CancelOrphanedRunningToolExecutions 将仍为 running 的记录批量标记为 orphaned(如进程重启后无对应执行协程)。
func (db *DB) CancelOrphanedRunningToolExecutions(endTime time.Time, errMsg string) (int64, error) {
errMsg = strings.TrimSpace(errMsg)
if errMsg == "" {
@@ -573,7 +610,7 @@ func (db *DB) CancelOrphanedRunningToolExecutions(endTime time.Time, errMsg stri
}
query := `
UPDATE tool_executions
SET status = 'cancelled',
SET status = 'orphaned',
error = ?,
end_time = ?,
duration_ms = MAX(0, CAST((julianday(?) - julianday(start_time)) * 86400000 AS INTEGER))
@@ -586,7 +623,7 @@ func (db *DB) CancelOrphanedRunningToolExecutions(endTime time.Time, errMsg stri
return res.RowsAffected()
}
// FinalizeStaleRunningToolExecutions 将「非活跃且超过 minAge」的 running 记录标记为 cancelled。
// FinalizeStaleRunningToolExecutions 将「非活跃且超过 minAge」的 running 记录标记为 orphaned。
// activeIDs 为当前进程内仍登记 cancel 的 executionId;不在集合内且已超时的视为孤儿记录。
func (db *DB) FinalizeStaleRunningToolExecutions(endTime time.Time, minAge time.Duration, activeIDs map[string]struct{}, errMsg string) (int64, error) {
errMsg = strings.TrimSpace(errMsg)
@@ -639,7 +676,7 @@ func (db *DB) FinalizeStaleRunningToolExecutions(endTime time.Time, minAge time.
}
res, err := db.Exec(`
UPDATE tool_executions
SET status = 'cancelled', error = ?, end_time = ?, duration_ms = ?
SET status = 'orphaned', error = ?, end_time = ?, duration_ms = ?
WHERE id = ? AND status = 'running'
`, errMsg, endTime, durationMs, row.id)
if err != nil {
@@ -815,7 +852,7 @@ func (db *DB) PurgeToolExecutionsBefore(cutoff time.Time) (int64, error) {
}
delta.totalCalls += count
switch status {
case "failed", "cancelled":
case "failed", "hard_timeout", "orphaned":
delta.failedCalls += count
case "completed":
delta.successCalls += count
@@ -971,7 +1008,7 @@ func (db *DB) LoadCallsTimeline(since time.Time, dailyBuckets bool) ([]CallsTime
query = `
SELECT date(start_time, 'localtime') AS bucket,
COUNT(*) AS total,
SUM(CASE WHEN status IN ('failed', 'cancelled') THEN 1 ELSE 0 END) AS failed
SUM(CASE WHEN status IN ('failed', 'hard_timeout', 'orphaned') THEN 1 ELSE 0 END) AS failed
FROM tool_executions
WHERE start_time >= ?
GROUP BY bucket
@@ -981,7 +1018,7 @@ func (db *DB) LoadCallsTimeline(since time.Time, dailyBuckets bool) ([]CallsTime
query = `
SELECT strftime('%Y-%m-%d %H:00:00', start_time, 'localtime') AS bucket,
COUNT(*) AS total,
SUM(CASE WHEN status IN ('failed', 'cancelled') THEN 1 ELSE 0 END) AS failed
SUM(CASE WHEN status IN ('failed', 'hard_timeout', 'orphaned') THEN 1 ELSE 0 END) AS failed
FROM tool_executions
WHERE start_time >= ?
GROUP BY bucket
+4 -4
View File
@@ -43,8 +43,8 @@ func TestCancelOrphanedRunningToolExecutions(t *testing.T) {
if err != nil {
t.Fatalf("GetToolExecution: %v", err)
}
if got.Status != "cancelled" {
t.Fatalf("expected cancelled, got %s", got.Status)
if got.Status != "orphaned" {
t.Fatalf("expected orphaned, got %s", got.Status)
}
if got.EndTime == nil {
t.Fatal("expected end_time to be set")
@@ -88,8 +88,8 @@ func TestFinalizeStaleRunningToolExecutions_skipsActive(t *testing.T) {
if err != nil {
t.Fatalf("GetToolExecution stale: %v", err)
}
if stale.Status != "cancelled" {
t.Fatalf("stale expected cancelled, got %s", stale.Status)
if stale.Status != "orphaned" {
t.Fatalf("stale expected orphaned, got %s", stale.Status)
}
activeExec, err := db.GetToolExecution("active")
+46
View File
@@ -84,3 +84,49 @@ func TestLoadToolStatsSummaryAndListPage(t *testing.T) {
}
}
}
func TestLoadToolStatsSummaryDoesNotCountCancelledAsFailed(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "monitor-cancelled-summary.db")
db, err := NewDB(dbPath, zap.NewNop())
if err != nil {
t.Fatalf("NewDB: %v", err)
}
defer db.Close()
now := time.Now()
for i, status := range []string{"completed", "cancelled", "failed"} {
exec := &mcp.ToolExecution{
ID: fmt.Sprintf("exec-%d", i),
ToolName: "exec",
Arguments: map[string]interface{}{},
Status: status,
StartTime: now.Add(time.Duration(i) * time.Second),
}
end := exec.StartTime.Add(time.Second)
exec.EndTime = &end
exec.Duration = time.Second
if err := db.SaveToolExecution(exec); err != nil {
t.Fatalf("SaveToolExecution(%s): %v", status, err)
}
}
summary, err := db.LoadToolStatsSummary(1)
if err != nil {
t.Fatalf("LoadToolStatsSummary: %v", err)
}
if summary.Summary.TotalCalls != 3 {
t.Fatalf("totalCalls = %d, want 3", summary.Summary.TotalCalls)
}
if summary.Summary.SuccessCalls != 1 {
t.Fatalf("successCalls = %d, want 1", summary.Summary.SuccessCalls)
}
if summary.Summary.FailedCalls != 1 {
t.Fatalf("failedCalls = %d, want 1", summary.Summary.FailedCalls)
}
if len(summary.TopTools) != 1 {
t.Fatalf("top tools = %d, want 1", len(summary.TopTools))
}
if summary.TopTools[0].FailedCalls != 1 {
t.Fatalf("top tool failedCalls = %d, want 1", summary.TopTools[0].FailedCalls)
}
}
@@ -7,7 +7,7 @@ import (
"go.uber.org/zap"
)
func TestProcessDetailsSummaryPairsMixedIdentifiedAndIDLessResults(t *testing.T) {
func TestProcessDetailsSummaryDoesNotGuessIDLessResultsByOrder(t *testing.T) {
db, conversationID, messageID := setupProcessDetailsSummaryTest(t)
for _, id := range []string{"call-1", "call-2", "call-3", "call-4"} {
if err := db.AddProcessDetail(messageID, conversationID, "tool_call", "call", map[string]interface{}{
@@ -32,14 +32,24 @@ func TestProcessDetailsSummaryPairsMixedIdentifiedAndIDLessResults(t *testing.T)
if err != nil {
t.Fatalf("GetProcessDetailsSummary: %v", err)
}
if len(summary.ToolExecutions) != 4 {
t.Fatalf("tool executions = %d, want 4", len(summary.ToolExecutions))
if len(summary.ToolExecutions) != 6 {
t.Fatalf("tool executions = %d, want 6", len(summary.ToolExecutions))
}
for i, execution := range summary.ToolExecutions {
for i, execution := range summary.ToolExecutions[:2] {
if execution.Status != "completed" {
t.Fatalf("execution %d status = %q, want completed", i, execution.Status)
}
}
for i, execution := range summary.ToolExecutions[2:4] {
if execution.Status != "result_missing" {
t.Fatalf("unmatched call %d status = %q, want result_missing", i, execution.Status)
}
}
for i, execution := range summary.ToolExecutions[4:] {
if execution.Status != "completed" || execution.ToolCallID != "" {
t.Fatalf("idless result %d = %#v, want separate completed result without toolCallId", i, execution)
}
}
}
func TestProcessDetailsSummaryPairsRepeatedToolCallIDsFIFO(t *testing.T) {
+22 -3
View File
@@ -10,7 +10,7 @@ import (
"github.com/google/uuid"
)
var factKeyPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._/-]*$`)
var factKeyPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._/-]*$`)
// ValidateFactKey 校验事实 key(项目内唯一标识)。
func ValidateFactKey(key string) error {
@@ -22,7 +22,7 @@ func ValidateFactKey(key string) error {
return fmt.Errorf("fact_key 过长(最多 128 字符)")
}
if !factKeyPattern.MatchString(key) {
return fmt.Errorf("fact_key 格式无效,仅允许小写字母、数字及 . _ / -,且须以小写字母或数字开头")
return fmt.Errorf("fact_key 格式无效,仅允许字母、数字及 . _ / -,且须以字母或数字开头(支持驼峰命名)")
}
return nil
}
@@ -111,6 +111,19 @@ func (db *DB) GetProject(id string) (*Project, error) {
return &p, nil
}
// GetProjectName returns a project display name without loading the full record.
func (db *DB) GetProjectName(id string) (string, error) {
var name string
err := db.QueryRow(`SELECT name FROM projects WHERE id = ?`, id).Scan(&name)
if err != nil {
if err == sql.ErrNoRows {
return "", fmt.Errorf("项目不存在")
}
return "", fmt.Errorf("获取项目名称失败: %w", err)
}
return strings.TrimSpace(name), nil
}
func projectListSearchPattern(q string) string {
q = strings.TrimSpace(q)
if q == "" {
@@ -263,7 +276,7 @@ func (db *DB) UpdateProject(p *Project) error {
return nil
}
// DeleteProject 删除项目(级联删除事实;对话 project_id 置空由 FK 处理;漏洞 project_id 置空)。
// DeleteProject 删除项目(级联删除事实;对话 project_id 置空由 FK 处理;其他资源 project_id 置空)。
func (db *DB) DeleteProject(id string) error {
if _, err := db.Exec(`UPDATE vulnerabilities SET project_id = NULL WHERE project_id = ?`, id); err != nil {
return fmt.Errorf("解除漏洞项目关联失败: %w", err)
@@ -271,6 +284,12 @@ func (db *DB) DeleteProject(id string) error {
if _, err := db.Exec(`UPDATE assets SET project_id = NULL WHERE project_id = ?`, id); err != nil {
return fmt.Errorf("解除资产项目关联失败: %w", err)
}
if _, err := db.Exec(`UPDATE webshell_connections SET project_id = NULL WHERE project_id = ?`, id); err != nil {
return fmt.Errorf("解除 WebShell 项目关联失败: %w", err)
}
if _, err := db.Exec(`UPDATE c2_listeners SET project_id = NULL WHERE project_id = ?`, id); err != nil {
return fmt.Errorf("解除 C2 监听器项目关联失败: %w", err)
}
_, err := db.Exec(`DELETE FROM projects WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("删除项目失败: %w", err)
+6 -2
View File
@@ -84,7 +84,7 @@ func (db *DB) ListConversationsByProjectID(projectID string, limit, offset int)
limit = 100
}
rows, err := db.Query(
`SELECT id, title, COALESCE(pinned, 0), created_at, updated_at, project_id
`SELECT id, title, COALESCE(pinned, 0), created_at, updated_at, project_id, role_name
FROM conversations WHERE project_id = ? ORDER BY updated_at DESC LIMIT ? OFFSET ?`,
projectID, limit, offset,
)
@@ -99,12 +99,16 @@ func (db *DB) ListConversationsByProjectID(projectID string, limit, offset int)
var createdAt, updatedAt string
var pinned int
var pid sql.NullString
if err := rows.Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt, &pid); err != nil {
var roleName sql.NullString
if err := rows.Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt, &pid, &roleName); err != nil {
return nil, err
}
if pid.Valid {
conv.ProjectID = strings.TrimSpace(pid.String)
}
if roleName.Valid {
conv.RoleName = normalizeConversationRoleName(roleName.String)
}
conv.CreatedAt = parseDBTime(createdAt)
conv.UpdatedAt = parseDBTime(updatedAt)
conv.Pinned = pinned != 0
+117 -11
View File
@@ -373,22 +373,46 @@ func TestRBACBatchResourceAssignmentValidationAndAtomicity(t *testing.T) {
func TestRBACWebshellAndBatchListAccess(t *testing.T) {
db := newRBACTestDB(t)
ws1 := WebShellConnection{ID: "ws_visible", URL: "http://a", Type: "php", Method: "post", CreatedAt: time.Now()}
ws2 := WebShellConnection{ID: "ws_hidden", URL: "http://b", Type: "php", Method: "post", CreatedAt: time.Now()}
ws1 := WebShellConnection{ID: "ws_visible", ProjectID: "p1", URL: "http://a", Type: "php", Method: "post", CreatedAt: time.Now()}
ws2 := WebShellConnection{ID: "ws_hidden", ProjectID: "p2", URL: "http://b", Type: "php", Method: "post", CreatedAt: time.Now()}
ws3 := WebShellConnection{ID: "ws_other_project", ProjectID: "p2", URL: "http://c", Type: "php", Method: "post", CreatedAt: time.Now()}
ws4 := WebShellConnection{ID: "ws_unbound", URL: "http://d", Type: "php", Method: "post", CreatedAt: time.Now()}
if err := db.CreateWebshellConnection(&ws1); err != nil {
t.Fatal(err)
}
if err := db.CreateWebshellConnection(&ws2); err != nil {
t.Fatal(err)
}
if err := db.CreateWebshellConnection(&ws3); err != nil {
t.Fatal(err)
}
if err := db.CreateWebshellConnection(&ws4); err != nil {
t.Fatal(err)
}
_ = db.SetResourceOwner("webshell", ws1.ID, "u1")
_ = db.SetResourceOwner("webshell", ws2.ID, "u2")
webshells, err := db.ListWebshellConnectionsForAccess("u1", RBACScopeOwn)
_ = db.SetResourceOwner("webshell", ws3.ID, "u1")
_ = db.SetResourceOwner("webshell", ws4.ID, "u1")
webshells, err := db.ListWebshellConnectionsForAccess("u1", RBACScopeOwn, "")
if err != nil {
t.Fatal(err)
}
if len(webshells) != 3 {
t.Fatalf("webshells = %#v, want 3 owned webshells including unbound", webshells)
}
webshells, err = db.ListWebshellConnectionsForAccess("u1", RBACScopeOwn, "p1")
if err != nil {
t.Fatal(err)
}
if len(webshells) != 1 || webshells[0].ID != ws1.ID {
t.Fatalf("webshells = %#v, want only %s", webshells, ws1.ID)
t.Fatalf("webshells scoped to p1 = %#v, want only %s", webshells, ws1.ID)
}
webshells, err = db.ListWebshellConnectionsForAccess("u1", RBACScopeOwn, ProjectFilterUnbound)
if err != nil {
t.Fatal(err)
}
if len(webshells) != 1 || webshells[0].ID != ws4.ID {
t.Fatalf("unbound webshells = %#v, want only %s", webshells, ws4.ID)
}
if err := db.CreateBatchQueue("q_visible", "visible", "", "eino_single", "manual", "", nil, "", 1, []map[string]interface{}{{"id": "t1", "message": "a"}}); err != nil {
@@ -411,61 +435,143 @@ func TestRBACWebshellAndBatchListAccess(t *testing.T) {
func TestRBACC2AccessInheritsListener(t *testing.T) {
db := newRBACTestDB(t)
now := time.Now()
l1 := &C2Listener{ID: "l_visible", Name: "visible", Type: "http_beacon", BindHost: "127.0.0.1", BindPort: 9001, OwnerUserID: "u1", CreatedAt: now}
l2 := &C2Listener{ID: "l_hidden", Name: "hidden", Type: "http_beacon", BindHost: "127.0.0.1", BindPort: 9002, OwnerUserID: "u2", CreatedAt: now}
l1 := &C2Listener{ID: "l_visible", ProjectID: "p1", Name: "visible", Type: "http_beacon", BindHost: "127.0.0.1", BindPort: 9001, OwnerUserID: "u1", CreatedAt: now}
l2 := &C2Listener{ID: "l_hidden", ProjectID: "p2", Name: "hidden", Type: "http_beacon", BindHost: "127.0.0.1", BindPort: 9002, OwnerUserID: "u2", CreatedAt: now}
l3 := &C2Listener{ID: "l_other_project", ProjectID: "p2", Name: "other project", Type: "http_beacon", BindHost: "127.0.0.1", BindPort: 9003, OwnerUserID: "u1", CreatedAt: now}
l4 := &C2Listener{ID: "l_unbound", Name: "unbound", Type: "http_beacon", BindHost: "127.0.0.1", BindPort: 9004, OwnerUserID: "u1", CreatedAt: now}
if err := db.CreateC2Listener(l1); err != nil {
t.Fatal(err)
}
if err := db.CreateC2Listener(l2); err != nil {
t.Fatal(err)
}
if err := db.CreateC2Listener(l3); err != nil {
t.Fatal(err)
}
if err := db.CreateC2Listener(l4); err != nil {
t.Fatal(err)
}
if err := db.UpsertC2Session(&C2Session{ID: "s_visible", ListenerID: l1.ID, ImplantUUID: "implant-visible", Status: "active", FirstSeenAt: now, LastCheckIn: now}); err != nil {
t.Fatal(err)
}
if err := db.UpsertC2Session(&C2Session{ID: "s_hidden", ListenerID: l2.ID, ImplantUUID: "implant-hidden", Status: "active", FirstSeenAt: now, LastCheckIn: now}); err != nil {
t.Fatal(err)
}
if err := db.UpsertC2Session(&C2Session{ID: "s_other_project", ListenerID: l3.ID, ImplantUUID: "implant-other-project", Status: "active", FirstSeenAt: now, LastCheckIn: now}); err != nil {
t.Fatal(err)
}
if err := db.UpsertC2Session(&C2Session{ID: "s_unbound", ListenerID: l4.ID, ImplantUUID: "implant-unbound", Status: "active", FirstSeenAt: now, LastCheckIn: now}); err != nil {
t.Fatal(err)
}
if err := db.CreateC2Task(&C2Task{ID: "t_visible", SessionID: "s_visible", TaskType: "shell", Status: "queued", CreatedAt: now}); err != nil {
t.Fatal(err)
}
if err := db.CreateC2Task(&C2Task{ID: "t_hidden", SessionID: "s_hidden", TaskType: "shell", Status: "queued", CreatedAt: now}); err != nil {
t.Fatal(err)
}
if err := db.CreateC2Task(&C2Task{ID: "t_other_project", SessionID: "s_other_project", TaskType: "shell", Status: "queued", CreatedAt: now}); err != nil {
t.Fatal(err)
}
if err := db.CreateC2Task(&C2Task{ID: "t_unbound", SessionID: "s_unbound", TaskType: "shell", Status: "queued", CreatedAt: now}); err != nil {
t.Fatal(err)
}
if err := db.AppendC2Event(&C2Event{ID: "e_visible", Level: "info", Category: "task", SessionID: "s_visible", TaskID: "t_visible", Message: "visible", CreatedAt: now}); err != nil {
t.Fatal(err)
}
if err := db.AppendC2Event(&C2Event{ID: "e_hidden", Level: "info", Category: "task", SessionID: "s_hidden", TaskID: "t_hidden", Message: "hidden", CreatedAt: now}); err != nil {
t.Fatal(err)
}
if err := db.AppendC2Event(&C2Event{ID: "e_other_project", Level: "info", Category: "task", SessionID: "s_other_project", TaskID: "t_other_project", Message: "other project", CreatedAt: now}); err != nil {
t.Fatal(err)
}
if err := db.AppendC2Event(&C2Event{ID: "e_unbound", Level: "info", Category: "task", SessionID: "s_unbound", TaskID: "t_unbound", Message: "unbound", CreatedAt: now}); err != nil {
t.Fatal(err)
}
access := RBACListAccess{UserID: "u1", Scope: RBACScopeOwn}
listeners, err := db.ListC2ListenersForAccess(access)
listeners, err := db.ListC2ListenersForAccess(access, "")
if err != nil {
t.Fatal(err)
}
if len(listeners) != 3 {
t.Fatalf("listeners = %#v, want 3 owned listeners including unbound", listeners)
}
listeners, err = db.ListC2ListenersForAccess(access, "p1")
if err != nil {
t.Fatal(err)
}
if len(listeners) != 1 || listeners[0].ID != l1.ID {
t.Fatalf("listeners = %#v, want only %s", listeners, l1.ID)
t.Fatalf("listeners scoped to p1 = %#v, want only %s", listeners, l1.ID)
}
listeners, err = db.ListC2ListenersForAccess(access, ProjectFilterUnbound)
if err != nil {
t.Fatal(err)
}
if len(listeners) != 1 || listeners[0].ID != l4.ID {
t.Fatalf("unbound listeners = %#v, want only %s", listeners, l4.ID)
}
sessions, err := db.ListC2SessionsForAccess(ListC2SessionsFilter{}, access)
if err != nil {
t.Fatal(err)
}
if len(sessions) != 3 {
t.Fatalf("sessions = %#v, want 3 owned sessions including unbound", sessions)
}
sessions, err = db.ListC2SessionsForAccess(ListC2SessionsFilter{ProjectID: "p1"}, access)
if err != nil {
t.Fatal(err)
}
if len(sessions) != 1 || sessions[0].ID != "s_visible" {
t.Fatalf("sessions = %#v, want only s_visible", sessions)
t.Fatalf("sessions scoped to p1 = %#v, want only s_visible", sessions)
}
sessions, err = db.ListC2SessionsForAccess(ListC2SessionsFilter{ProjectID: ProjectFilterUnbound}, access)
if err != nil {
t.Fatal(err)
}
if len(sessions) != 1 || sessions[0].ID != "s_unbound" {
t.Fatalf("unbound sessions = %#v, want only s_unbound", sessions)
}
tasks, err := db.ListC2TasksForAccess(ListC2TasksFilter{}, access)
if err != nil {
t.Fatal(err)
}
if len(tasks) != 3 {
t.Fatalf("tasks = %#v, want 3 owned tasks including unbound", tasks)
}
tasks, err = db.ListC2TasksForAccess(ListC2TasksFilter{ProjectID: "p1"}, access)
if err != nil {
t.Fatal(err)
}
if len(tasks) != 1 || tasks[0].ID != "t_visible" {
t.Fatalf("tasks = %#v, want only t_visible", tasks)
t.Fatalf("tasks scoped to p1 = %#v, want only t_visible", tasks)
}
tasks, err = db.ListC2TasksForAccess(ListC2TasksFilter{ProjectID: ProjectFilterUnbound}, access)
if err != nil {
t.Fatal(err)
}
if len(tasks) != 1 || tasks[0].ID != "t_unbound" {
t.Fatalf("unbound tasks = %#v, want only t_unbound", tasks)
}
events, err := db.ListC2EventsForAccess(ListC2EventsFilter{}, access)
if err != nil {
t.Fatal(err)
}
if len(events) != 3 {
t.Fatalf("events = %#v, want 3 owned events including unbound", events)
}
events, err = db.ListC2EventsForAccess(ListC2EventsFilter{ProjectID: "p1"}, access)
if err != nil {
t.Fatal(err)
}
if len(events) != 1 || events[0].ID != "e_visible" {
t.Fatalf("events = %#v, want only e_visible", events)
t.Fatalf("events scoped to p1 = %#v, want only e_visible", events)
}
events, err = db.ListC2EventsForAccess(ListC2EventsFilter{ProjectID: ProjectFilterUnbound}, access)
if err != nil {
t.Fatal(err)
}
if len(events) != 1 || events[0].ID != "e_unbound" {
t.Fatalf("unbound events = %#v, want only e_unbound", events)
}
if !db.UserCanAccessResource("u1", RBACScopeOwn, "c2_task", "t_visible") {
t.Fatalf("expected listener ownership to allow task detail")
+19 -11
View File
@@ -11,6 +11,7 @@ import (
// WebShellConnection WebShell 连接配置
type WebShellConnection struct {
ID string `json:"id"`
ProjectID string `json:"project_id,omitempty"`
URL string `json:"url"`
Password string `json:"password"`
Type string `json:"type"`
@@ -60,17 +61,24 @@ func (db *DB) UpsertWebshellConnectionState(connectionID, stateJSON string) erro
// ListWebshellConnections 列出所有 WebShell 连接,按创建时间倒序
func (db *DB) ListWebshellConnections() ([]WebShellConnection, error) {
return db.ListWebshellConnectionsForAccess("", "")
return db.ListWebshellConnectionsForAccess("", "", "")
}
func (db *DB) ListWebshellConnectionsForAccess(userID, scope string) ([]WebShellConnection, error) {
func (db *DB) ListWebshellConnectionsForAccess(userID, scope, projectID string) ([]WebShellConnection, error) {
query := `
SELECT id, url, password, type, method, cmd_param, remark,
SELECT id, COALESCE(project_id, '') AS project_id, url, password, type, method, cmd_param, remark,
COALESCE(encoding, '') AS encoding, COALESCE(os, '') AS os, created_at
FROM webshell_connections
WHERE 1=1
`
args := []interface{}{}
projectID = strings.TrimSpace(projectID)
if projectID == ProjectFilterUnbound {
query += ` AND COALESCE(project_id, '') = ''`
} else if projectID != "" {
query += ` AND COALESCE(project_id, '') = ?`
args = append(args, projectID)
}
userID = strings.TrimSpace(userID)
if userID != "" && scope != RBACScopeAll {
query += ` AND (
@@ -93,7 +101,7 @@ func (db *DB) ListWebshellConnectionsForAccess(userID, scope string) ([]WebShell
var list []WebShellConnection
for rows.Next() {
var c WebShellConnection
err := rows.Scan(&c.ID, &c.URL, &c.Password, &c.Type, &c.Method, &c.CmdParam, &c.Remark, &c.Encoding, &c.OS, &c.CreatedAt)
err := rows.Scan(&c.ID, &c.ProjectID, &c.URL, &c.Password, &c.Type, &c.Method, &c.CmdParam, &c.Remark, &c.Encoding, &c.OS, &c.CreatedAt)
if err != nil {
db.logger.Warn("扫描 WebShell 连接行失败", zap.Error(err))
continue
@@ -106,12 +114,12 @@ func (db *DB) ListWebshellConnectionsForAccess(userID, scope string) ([]WebShell
// GetWebshellConnection 根据 ID 获取一条连接
func (db *DB) GetWebshellConnection(id string) (*WebShellConnection, error) {
query := `
SELECT id, url, password, type, method, cmd_param, remark,
SELECT id, COALESCE(project_id, '') AS project_id, url, password, type, method, cmd_param, remark,
COALESCE(encoding, '') AS encoding, COALESCE(os, '') AS os, created_at
FROM webshell_connections WHERE id = ?
`
var c WebShellConnection
err := db.QueryRow(query, id).Scan(&c.ID, &c.URL, &c.Password, &c.Type, &c.Method, &c.CmdParam, &c.Remark, &c.Encoding, &c.OS, &c.CreatedAt)
err := db.QueryRow(query, id).Scan(&c.ID, &c.ProjectID, &c.URL, &c.Password, &c.Type, &c.Method, &c.CmdParam, &c.Remark, &c.Encoding, &c.OS, &c.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
@@ -125,10 +133,10 @@ func (db *DB) GetWebshellConnection(id string) (*WebShellConnection, error) {
// CreateWebshellConnection 创建 WebShell 连接
func (db *DB) CreateWebshellConnection(c *WebShellConnection) error {
query := `
INSERT INTO webshell_connections (id, url, password, type, method, cmd_param, remark, encoding, os, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO webshell_connections (id, project_id, url, password, type, method, cmd_param, remark, encoding, os, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`
_, err := db.Exec(query, c.ID, c.URL, c.Password, c.Type, c.Method, c.CmdParam, c.Remark, c.Encoding, c.OS, c.CreatedAt)
_, err := db.Exec(query, c.ID, strings.TrimSpace(c.ProjectID), c.URL, c.Password, c.Type, c.Method, c.CmdParam, c.Remark, c.Encoding, c.OS, c.CreatedAt)
if err != nil {
db.logger.Error("创建 WebShell 连接失败", zap.Error(err), zap.String("id", c.ID))
return err
@@ -140,10 +148,10 @@ func (db *DB) CreateWebshellConnection(c *WebShellConnection) error {
func (db *DB) UpdateWebshellConnection(c *WebShellConnection) error {
query := `
UPDATE webshell_connections
SET url = ?, password = ?, type = ?, method = ?, cmd_param = ?, remark = ?, encoding = ?, os = ?
SET project_id = ?, url = ?, password = ?, type = ?, method = ?, cmd_param = ?, remark = ?, encoding = ?, os = ?
WHERE id = ?
`
result, err := db.Exec(query, c.URL, c.Password, c.Type, c.Method, c.CmdParam, c.Remark, c.Encoding, c.OS, c.ID)
result, err := db.Exec(query, strings.TrimSpace(c.ProjectID), c.URL, c.Password, c.Type, c.Method, c.CmdParam, c.Remark, c.Encoding, c.OS, c.ID)
if err != nil {
db.logger.Error("更新 WebShell 连接失败", zap.Error(err), zap.String("id", c.ID))
return err
+60 -26
View File
@@ -212,7 +212,7 @@ func (h *AgentHandler) CancelRunningTaskForConversation(conversationID string) {
if h == nil || conversationID == "" || h.tasks == nil {
return
}
h.cancelActiveMCPToolForConversation(conversationID)
h.cancelRunningMCPToolsForConversation(conversationID)
h.tasks.AbortActiveEinoExecute(conversationID, "")
if ok, err := h.tasks.CancelTask(conversationID, ErrTaskCancelled); ok {
h.logger.Info("已取消会话运行中任务", zap.String("conversationId", conversationID))
@@ -221,12 +221,13 @@ func (h *AgentHandler) CancelRunningTaskForConversation(conversationID string) {
}
}
func (h *AgentHandler) cancelActiveMCPToolForConversation(conversationID string) {
if h == nil || h.tasks == nil || h.agent == nil {
func (h *AgentHandler) cancelRunningMCPToolsForConversation(conversationID string) {
if h == nil || h.agent == nil {
return
}
if execID := h.tasks.ActiveMCPExecutionID(conversationID); execID != "" {
h.agent.CancelMCPToolExecutionWithNote(execID, "")
n := h.agent.CancelRunningMCPToolsForConversation(conversationID, "会话已结束,自动终止仍在运行的工具")
if n > 0 && h.logger != nil {
h.logger.Info("已终止会话仍在运行的 MCP 工具", zap.String("conversationId", conversationID), zap.Int("count", n))
}
}
@@ -266,7 +267,7 @@ func NewAgentHandler(agent *agent.Agent, db *database.DB, cfg *config.Config, lo
batchCronParser: cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor),
auditLLM: openai.NewClient(llmCfg, llmHTTP, logger),
}
tm.SetToolCanceler(handler.cancelActiveMCPToolForConversation)
tm.SetToolCanceler(handler.cancelRunningMCPToolsForConversation)
if err := handler.hitlManager.EnsureSchema(); err != nil {
logger.Warn("初始化 HITL 表失败", zap.Error(err))
}
@@ -332,20 +333,41 @@ type ChatReasoningRequest struct {
Effort string `json:"effort,omitempty"`
}
// ChatFinalizationRequest is a caller-provided delivery policy. The server does
// not infer execution intent from natural-language user text.
type ChatFinalizationRequest struct {
RequireExecutionEvidence *bool `json:"requireExecutionEvidence,omitempty"`
}
// ChatRequest 聊天请求
type ChatRequest struct {
Message string `json:"message" binding:"required"`
ConversationID string `json:"conversationId,omitempty"`
ProjectID string `json:"projectId,omitempty"` // 新对话绑定的项目(可选;未指定时可用 config.project.default_project_id
Role string `json:"role,omitempty"` // 角色名称
Attachments []ChatAttachment `json:"attachments,omitempty"`
WebShellConnectionID string `json:"webshellConnectionId,omitempty"` // WebShell 管理 - AI 助手:当前选中的连接 ID,仅使用 webshell_* 工具
Hitl *HITLRequest `json:"hitl,omitempty"`
Reasoning *ChatReasoningRequest `json:"reasoning,omitempty"`
Message string `json:"message" binding:"required"`
ConversationID string `json:"conversationId,omitempty"`
ProjectID string `json:"projectId,omitempty"` // 新对话绑定的项目(可选;未指定时可用 config.project.default_project_id
Role string `json:"role,omitempty"` // 角色名称
Attachments []ChatAttachment `json:"attachments,omitempty"`
WebShellConnectionID string `json:"webshellConnectionId,omitempty"` // WebShell 管理 - AI 助手:当前选中的连接 ID,仅使用 webshell_* 工具
AIChannelID string `json:"aiChannelId,omitempty"` // 会话级 AI 通道;空则使用 ai.default_channel
Hitl *HITLRequest `json:"hitl,omitempty"`
Reasoning *ChatReasoningRequest `json:"reasoning,omitempty"`
Finalization ChatFinalizationRequest `json:"finalization,omitempty"`
// Orchestration 仅对 /api/multi-agent、/api/multi-agent/streamdeep | plan_execute | supervisor;空则等同 deep。机器人/批量等无请求体时由服务端默认 deep。/api/eino-agent* 不使用此字段。
Orchestration string `json:"orchestration,omitempty"`
}
func (h *AgentHandler) configForAIChannel(channelID string) (*config.Config, string, error) {
if h == nil || h.config == nil {
return nil, "", fmt.Errorf("服务器配置未加载")
}
oa, resolvedID, ok := h.config.ResolveAIChannel(channelID)
if !ok {
return nil, resolvedID, fmt.Errorf("AI 通道不存在: %s", resolvedID)
}
cfgCopy := *h.config
cfgCopy.OpenAI = oa
return &cfgCopy, resolvedID, nil
}
func chatReasoningToClientIntent(r *ChatReasoningRequest) *reasoning.ClientIntent {
if r == nil {
return nil
@@ -653,10 +675,18 @@ func (h *AgentHandler) mergeAssistantMessagePartialOnCancel(messageID, partial s
// ChatResponse 聊天响应
type ChatResponse struct {
Response string `json:"response"`
MCPExecutionIDs []string `json:"mcpExecutionIds,omitempty"` // 本次对话中执行的MCP调用ID列表
ConversationID string `json:"conversationId"` // 对话ID
Time time.Time `json:"time"`
Response string `json:"response"`
MCPExecutionIDs []string `json:"mcpExecutionIds,omitempty"` // 本次对话中执行的MCP调用ID列表
ConversationID string `json:"conversationId"` // 对话ID
Time time.Time `json:"time"`
Finalizable bool `json:"finalizable"`
Finalized bool `json:"finalized"`
Status string `json:"status,omitempty"`
CompletionReason string `json:"completionReason,omitempty"`
EvidenceVerified bool `json:"evidenceVerified"`
EvidenceRefs []string `json:"evidenceRefs,omitempty"`
PendingExecutionIDs []string `json:"pendingExecutionIds,omitempty"`
MissingChecks []string `json:"missingChecks,omitempty"`
}
func (h *AgentHandler) finalizeRobotAgentError(ctx context.Context, assistantMessageID, conversationID string, resultMA *multiagent.RunResult, errMA error) (string, string, error) {
@@ -672,19 +702,20 @@ func (h *AgentHandler) finalizeRobotAgentError(ctx context.Context, assistantMes
}
func (h *AgentHandler) finalizeRobotAgentSuccess(assistantMessageID, conversationID string, resultMA *multiagent.RunResult) (string, string, error) {
if assistantMessageID != "" {
if errU := h.db.UpdateAssistantMessageFinalize(assistantMessageID, resultMA.Response, resultMA.MCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(resultMA.LastAgentTraceInput)); errU != nil {
h.logger.Warn("机器人:更新助手消息失败", zap.Error(errU))
}
} else {
if _, err := h.db.AddMessage(conversationID, "assistant", resultMA.Response, resultMA.MCPExecutionIDs); err != nil {
decision := h.finalizeAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, "robot", resultMA, resultMA.MCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(resultMA.LastAgentTraceInput), true)
responseText := decision.FinalText
if !decision.Finalizable {
responseText = finalizationBlockedMessage(decision)
}
if assistantMessageID == "" {
if _, err := h.db.AddMessage(conversationID, "assistant", responseText, resultMA.MCPExecutionIDs); err != nil {
h.logger.Warn("机器人:保存助手消息失败", zap.Error(err))
}
}
if resultMA.LastAgentTraceInput != "" || resultMA.LastAgentTraceOutput != "" {
_ = h.db.SaveAgentTrace(conversationID, resultMA.LastAgentTraceInput, resultMA.LastAgentTraceOutput)
}
return resultMA.Response, conversationID, nil
return responseText, conversationID, nil
}
func (h *AgentHandler) runRobotEinoSingleWithRetry(
@@ -815,6 +846,9 @@ func (h *AgentHandler) ProcessMessageForRobot(ctx context.Context, platform stri
progressCallback := h.createProgressCallback(taskCtx, cancelWithCause, conversationID, assistantMessageID, nil)
robotMode := config.NormalizeAgentMode(agentMode)
if err := h.db.SetConversationAgentMode(conversationID, robotMode); err != nil {
h.logger.Warn("机器人:更新对话模式失败", zap.String("conversationId", conversationID), zap.String("agentMode", robotMode), zap.Error(err))
}
switch robotMode {
case "eino_single":
return h.runRobotEinoSingleWithRetry(taskCtx, conversationID, finalMessage, agentHistoryMessages, roleTools, progressCallback, assistantMessageID, &taskStatus)
@@ -1553,7 +1587,7 @@ func (h *AgentHandler) CancelAgentLoop(c *gin.Context) {
var cause error = ErrTaskCancelled
msg := "已提交取消请求,任务将在当前步骤完成后停止。"
h.cancelActiveMCPToolForConversation(req.ConversationID)
h.cancelRunningMCPToolsForConversation(req.ConversationID)
h.tasks.AbortActiveEinoExecute(req.ConversationID, "")
ok, err := h.tasks.CancelTask(req.ConversationID, cause)
if err != nil {
+37 -9
View File
@@ -238,6 +238,11 @@ func (h *AgentHandler) executeOneBatchSubTask(queueID string, queue *BatchTaskQu
useBatchMulti = true
batchOrch = "deep"
}
if useBatchMulti {
_ = h.db.SetConversationAgentMode(conversationID, batchOrch)
} else {
_ = h.db.SetConversationAgentMode(conversationID, "eino_single")
}
var resultMA *multiagent.RunResult
var runErr error
@@ -268,19 +273,38 @@ func (h *AgentHandler) executeOneBatchSubTask(queueID string, queue *BatchTaskQu
h.logger.Info("批量任务执行成功", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.String("conversationId", conversationID))
resText := resultMA.Response
mcpIDs := resultMA.MCPExecutionIDs
lastIn := resultMA.LastAgentTraceInput
lastOut := resultMA.LastAgentTraceOutput
reasoningContent := multiagent.AggregatedReasoningFromTraceJSON(lastIn)
agentMode := "batch_eino_single"
if useBatchMulti {
agentMode = "batch_eino_" + batchOrch
}
decision := h.finalizeAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, agentMode, resultMA, mcpIDs, reasoningContent, true)
resText := decision.FinalText
if !decision.Finalizable {
resText = finalizationBlockedMessage(decision)
finishStatus = decision.Status
sendEvent("finalization_check", resText, decision)
}
sendEvent("response", resText, finalizationResponsePayload(decision, map[string]interface{}{
"conversationId": conversationID,
"messageId": assistantMessageID,
"agentMode": agentMode,
"mcpExecutionIds": mcpIDs,
"batchQueueId": queueID,
"batchTaskId": task.ID,
"batchTaskStatus": map[bool]string{true: string(BatchTaskStatusCompleted), false: string(BatchTaskStatusFailed)}[decision.Finalizable],
"candidatePreview": safeTruncateString(resultMA.Response, 500),
}))
if assistantMessageID != "" {
if updateErr := h.db.UpdateAssistantMessageFinalize(assistantMessageID, resText, mcpIDs, multiagent.AggregatedReasoningFromTraceJSON(lastIn)); updateErr != nil {
h.logger.Warn("更新助手消息失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.Error(updateErr))
if _, err = h.db.AddMessage(conversationID, "assistant", resText, mcpIDs); err != nil {
h.logger.Error("保存助手消息失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.String("conversationId", conversationID), zap.Error(err))
}
}
} else if _, err = h.db.AddMessage(conversationID, "assistant", resText, mcpIDs); err != nil {
if assistantMessageID == "" {
_, err = h.db.AddMessage(conversationID, "assistant", resText, mcpIDs)
} else if !decision.Finalizable {
err = nil
}
if err != nil {
h.logger.Error("保存助手消息失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.String("conversationId", conversationID), zap.Error(err))
}
@@ -290,6 +314,10 @@ func (h *AgentHandler) executeOneBatchSubTask(queueID string, queue *BatchTaskQu
}
}
if !decision.Finalizable {
h.batchTaskManager.UpdateTaskStatusWithConversationID(queueID, task.ID, BatchTaskStatusFailed, resText, finalizationCheckMessage(decision), conversationID)
return
}
h.batchTaskManager.UpdateTaskStatusWithConversationID(queueID, task.ID, BatchTaskStatusCompleted, resText, "", conversationID)
}
+96 -7
View File
@@ -60,7 +60,7 @@ func (h *C2Handler) SetManager(m *c2.Manager) {
// ListListeners 获取监听器列表
func (h *C2Handler) ListListeners(c *gin.Context) {
listeners, err := h.mgr().DB().ListC2ListenersForAccess(c2AccessFromContext(c))
listeners, err := h.mgr().DB().ListC2ListenersForAccess(c2AccessFromContext(c), c.Query("project_id"))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -77,6 +77,7 @@ func (h *C2Handler) ListListeners(c *gin.Context) {
func (h *C2Handler) CreateListener(c *gin.Context) {
var req struct {
Name string `json:"name"`
ProjectID string `json:"project_id,omitempty"`
Type string `json:"type"`
BindHost string `json:"bind_host"`
BindPort int `json:"bind_port"`
@@ -92,6 +93,7 @@ func (h *C2Handler) CreateListener(c *gin.Context) {
input := c2.CreateListenerInput{
Name: req.Name,
ProjectID: req.ProjectID,
Type: req.Type,
BindHost: req.BindHost,
BindPort: req.BindPort,
@@ -100,6 +102,10 @@ func (h *C2Handler) CreateListener(c *gin.Context) {
Config: req.Config,
CallbackHost: strings.TrimSpace(req.CallbackHost),
}
if !h.canAccessProject(c, input.ProjectID) {
c.JSON(http.StatusForbidden, gin.H{"error": "project access denied"})
return
}
listener, err := h.mgr().CreateListener(input)
if err != nil {
@@ -158,6 +164,7 @@ func (h *C2Handler) UpdateListener(c *gin.Context) {
var req struct {
Name string `json:"name"`
ProjectID string `json:"project_id"`
BindHost string `json:"bind_host"`
BindPort int `json:"bind_port"`
ProfileID string `json:"profile_id"`
@@ -179,6 +186,7 @@ func (h *C2Handler) UpdateListener(c *gin.Context) {
}
listener.Name = req.Name
listener.ProjectID = strings.TrimSpace(req.ProjectID)
listener.BindHost = req.BindHost
listener.BindPort = req.BindPort
listener.ProfileID = req.ProfileID
@@ -187,6 +195,10 @@ func (h *C2Handler) UpdateListener(c *gin.Context) {
cfgJSON, _ := json.Marshal(req.Config)
listener.ConfigJSON = string(cfgJSON)
}
if !h.canAccessProject(c, listener.ProjectID) {
c.JSON(http.StatusForbidden, gin.H{"error": "project access denied"})
return
}
if req.CallbackHost != nil {
cfg := &c2.ListenerConfig{}
raw := strings.TrimSpace(listener.ConfigJSON)
@@ -275,6 +287,7 @@ func (h *C2Handler) StopListener(c *gin.Context) {
func (h *C2Handler) ListSessions(c *gin.Context) {
filter := database.ListC2SessionsFilter{
ListenerID: c.Query("listener_id"),
ProjectID: c.Query("project_id"),
Status: c.Query("status"),
OS: c.Query("os"),
Search: c.Query("search"),
@@ -404,6 +417,47 @@ func (h *C2Handler) SetSessionSleep(c *gin.Context) {
c.JSON(http.StatusOK, out)
}
// SetSessionNote 更新会话备注(仅服务端元数据,不下发植入体)
func (h *C2Handler) SetSessionNote(c *gin.Context) {
id := c.Param("id")
var req struct {
Note string `json:"note"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
note := strings.TrimSpace(req.Note)
if len(note) > 2000 {
c.JSON(http.StatusBadRequest, gin.H{"error": "note too long (max 2000 characters)"})
return
}
session, err := h.mgr().DB().GetC2Session(id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if session == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "session not found"})
return
}
if err := h.mgr().DB().SetC2SessionNote(id, note); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if h.audit != nil {
h.audit.RecordOK(c, "c2", "session_note", "更新 C2 会话备注", "c2_session", id, map[string]interface{}{
"note_len": len(note),
})
}
c.JSON(http.StatusOK, gin.H{
"updated": true,
"note": note,
})
}
// ============================================================================
// 任务 API
// ============================================================================
@@ -412,7 +466,14 @@ func (h *C2Handler) SetSessionSleep(c *gin.Context) {
func (h *C2Handler) ListTasks(c *gin.Context) {
filter := database.ListC2TasksFilter{
SessionID: c.Query("session_id"),
ProjectID: c.Query("project_id"),
Status: c.Query("status"),
TaskType: c.Query("task_type"),
}
if since := c.Query("since"); since != "" {
if t, err := database.ParseRFC3339Time(since); err == nil {
filter.Since = &t
}
}
paginated := false
@@ -447,7 +508,7 @@ func (h *C2Handler) ListTasks(c *gin.Context) {
}
// 仪表盘「待审任务」为全局 queued/pending 数量,与列表 session 过滤无关
pendingN, _ := h.mgr().DB().CountC2TasksQueuedOrPendingForAccess("", access)
pendingN, _ := h.mgr().DB().CountC2TasksQueuedOrPendingForAccess("", filter.ProjectID, access)
if !paginated {
c.JSON(http.StatusOK, gin.H{
@@ -462,9 +523,15 @@ func (h *C2Handler) ListTasks(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
statusCounts, err := h.mgr().DB().CountC2TasksByStatusForAccess(filter, access)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"tasks": tasks,
"total": total,
"status_counts": statusCounts,
"page": page,
"page_size": pageSize,
"pending_queued_count": pendingN,
@@ -784,11 +851,12 @@ func (h *C2Handler) ListEvents(c *gin.Context) {
filter := database.ListC2EventsFilter{
Level: c.Query("level"),
Category: c.Query("category"),
ProjectID: c.Query("project_id"),
SessionID: c.Query("session_id"),
TaskID: c.Query("task_id"),
}
if since := c.Query("since"); since != "" {
if t, err := time.Parse(time.RFC3339, since); err == nil {
if t, err := database.ParseRFC3339Time(since); err == nil {
filter.Since = &t
}
}
@@ -832,11 +900,17 @@ func (h *C2Handler) ListEvents(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
levelCounts, err := h.mgr().DB().CountC2EventsByLevelForAccess(filter, access)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"events": events,
"total": total,
"page": page,
"page_size": pageSize,
"events": events,
"total": total,
"level_counts": levelCounts,
"page": page,
"page_size": pageSize,
})
}
@@ -1115,6 +1189,21 @@ func c2AccessFromContext(c *gin.Context) database.RBACListAccess {
return database.RBACListAccess{UserID: session.UserID, Scope: session.Scope}
}
func (h *C2Handler) canAccessProject(c *gin.Context, projectID string) bool {
projectID = strings.TrimSpace(projectID)
if projectID == "" {
return true
}
session, ok := security.CurrentSession(c)
if !ok {
return false
}
if session.Scope == database.RBACScopeAll {
return true
}
return h.mgr().DB().UserCanAccessResource(session.UserID, session.Scope, "project", projectID)
}
func (h *C2Handler) c2ResourceAllowed(c *gin.Context, resourceType, resourceID string) bool {
session, ok := security.CurrentSession(c)
if !ok {
File diff suppressed because it is too large Load Diff
+152 -4
View File
@@ -258,6 +258,7 @@ func (h *ConfigHandler) ApplyWechatRobotBinding(wc config.RobotWechatConfig) err
// GetConfigResponse 获取配置响应
type GetConfigResponse struct {
AI config.AIConfig `json:"ai"`
OpenAI config.OpenAIConfig `json:"openai"`
Vision config.VisionConfig `json:"vision"`
FOFA config.FofaConfig `json:"fofa"`
@@ -363,6 +364,7 @@ func (h *ConfigHandler) GetConfig(c *gin.Context) {
}
c.JSON(http.StatusOK, GetConfigResponse{
AI: h.config.AI,
OpenAI: h.config.OpenAI,
Vision: h.config.Vision,
FOFA: h.config.FOFA,
@@ -706,6 +708,7 @@ func (h *ConfigHandler) GetTools(c *gin.Context) {
// UpdateConfigRequest 更新配置请求
type UpdateConfigRequest struct {
AI *config.AIConfig `json:"ai,omitempty"`
OpenAI *config.OpenAIConfig `json:"openai,omitempty"`
Vision *config.VisionConfig `json:"vision,omitempty"`
FOFA *config.FofaConfig `json:"fofa,omitempty"`
@@ -725,9 +728,14 @@ type UpdateConfigRequest struct {
// AgentConfigUpdate 用于 PATCH /api/config 的 agent 段:仅 JSON 中出现的字段(指针非 nil)覆盖内存配置。
// 避免旧版「整包替换 *AgentConfig」时,未传的整型字段被反序列化为 0 误覆盖(例如 tool_timeout_minutes 变成 0)。
type AgentConfigUpdate struct {
MaxIterations *int `json:"max_iterations,omitempty"`
ToolTimeoutMinutes *int `json:"tool_timeout_minutes,omitempty"`
SystemPromptPath *string `json:"system_prompt_path,omitempty"`
MaxIterations *int `json:"max_iterations,omitempty"`
ToolTimeoutMinutes *int `json:"tool_timeout_minutes,omitempty"`
ToolWaitTimeoutSeconds *int `json:"tool_wait_timeout_seconds,omitempty"`
ExternalMCPMaxConcurrentPerServer *int `json:"external_mcp_max_concurrent_per_server,omitempty"`
ExternalMCPMaxConcurrentTotal *int `json:"external_mcp_max_concurrent_total,omitempty"`
ExternalMCPCircuitFailureThreshold *int `json:"external_mcp_circuit_failure_threshold,omitempty"`
ExternalMCPCircuitCooldownSeconds *int `json:"external_mcp_circuit_cooldown_seconds,omitempty"`
SystemPromptPath *string `json:"system_prompt_path,omitempty"`
}
func applyAgentConfigUpdate(dst *config.AgentConfig, src *AgentConfigUpdate) {
@@ -740,6 +748,21 @@ func applyAgentConfigUpdate(dst *config.AgentConfig, src *AgentConfigUpdate) {
if src.ToolTimeoutMinutes != nil {
dst.ToolTimeoutMinutes = *src.ToolTimeoutMinutes
}
if src.ToolWaitTimeoutSeconds != nil {
dst.ToolWaitTimeoutSeconds = *src.ToolWaitTimeoutSeconds
}
if src.ExternalMCPMaxConcurrentPerServer != nil {
dst.ExternalMCPMaxConcurrentPerServer = *src.ExternalMCPMaxConcurrentPerServer
}
if src.ExternalMCPMaxConcurrentTotal != nil {
dst.ExternalMCPMaxConcurrentTotal = *src.ExternalMCPMaxConcurrentTotal
}
if src.ExternalMCPCircuitFailureThreshold != nil {
dst.ExternalMCPCircuitFailureThreshold = *src.ExternalMCPCircuitFailureThreshold
}
if src.ExternalMCPCircuitCooldownSeconds != nil {
dst.ExternalMCPCircuitCooldownSeconds = *src.ExternalMCPCircuitCooldownSeconds
}
if src.SystemPromptPath != nil {
dst.SystemPromptPath = *src.SystemPromptPath
}
@@ -765,8 +788,20 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) {
defer h.mu.Unlock()
// 更新OpenAI配置
if req.AI != nil {
h.config.AI = *req.AI
h.config.ApplyDefaultAIChannel()
h.logger.Info("更新 AI 通道配置",
zap.String("default_channel", h.config.AI.DefaultChannel),
zap.Int("channels", len(h.config.AI.Channels)),
)
}
if req.OpenAI != nil {
h.config.OpenAI = *req.OpenAI
h.config.AI.EnsureDefaultFromOpenAI(h.config.OpenAI)
if def := config.NormalizeAIChannelID(h.config.AI.DefaultChannel); def != "" {
h.config.AI.Channels[def] = config.AIChannelFromOpenAI(def, "Default", h.config.OpenAI)
}
h.logger.Info("更新OpenAI配置",
zap.String("base_url", h.config.OpenAI.BaseURL),
zap.String("model", h.config.OpenAI.Model),
@@ -815,12 +850,35 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) {
h.logger.Info("更新Agent配置",
zap.Int("max_iterations", h.config.Agent.MaxIterations),
zap.Int("tool_timeout_minutes", h.config.Agent.ToolTimeoutMinutes),
zap.Int("tool_wait_timeout_seconds", h.config.Agent.ToolWaitTimeoutSeconds),
zap.Int("external_mcp_max_concurrent_per_server", h.config.Agent.ExternalMCPMaxConcurrentPerServer),
zap.Int("external_mcp_max_concurrent_total", h.config.Agent.ExternalMCPMaxConcurrentTotal),
zap.Int("external_mcp_circuit_failure_threshold", h.config.Agent.ExternalMCPCircuitFailureThreshold),
zap.Int("external_mcp_circuit_cooldown_seconds", h.config.Agent.ExternalMCPCircuitCooldownSeconds),
)
if h.agent != nil && req.Agent.MaxIterations != nil {
h.agent.UpdateMaxIterations(h.config.Agent.MaxIterations)
}
if h.executor != nil {
h.executor.SetToolOutputMaxBytes(h.config.MultiAgent.EinoMiddleware.ReductionMaxLengthForTruncEffective())
h.executor.SetToolOutputSpillRoot(h.config.MultiAgent.EinoMiddleware.ReductionRootDir)
}
if h.mcpServer != nil {
h.mcpServer.ConfigureHTTPToolCallTimeoutFromAgentMinutes(h.config.Agent.ToolTimeoutMinutes)
h.mcpServer.ConfigureToolWaitTimeoutSeconds(h.config.Agent.ToolWaitTimeoutSeconds)
h.mcpServer.ConfigureToolResultMaxBytes(h.config.MultiAgent.EinoMiddleware.ReductionMaxLengthForTruncEffective())
h.mcpServer.ConfigureToolResultSpillRoot(h.config.MultiAgent.EinoMiddleware.ReductionRootDir)
}
if h.externalMCPMgr != nil {
h.externalMCPMgr.ConfigureToolWaitTimeoutSeconds(h.config.Agent.ToolWaitTimeoutSeconds)
h.externalMCPMgr.ConfigureToolResultMaxBytes(h.config.MultiAgent.EinoMiddleware.ReductionMaxLengthForTruncEffective())
h.externalMCPMgr.ConfigureToolResultSpillRoot(h.config.MultiAgent.EinoMiddleware.ReductionRootDir)
h.externalMCPMgr.ConfigureResilience(mcp.ExternalMCPResilienceConfig{
MaxConcurrentPerServer: h.config.Agent.ExternalMCPMaxConcurrentPerServer,
MaxConcurrentTotal: h.config.Agent.ExternalMCPMaxConcurrentTotal,
CircuitFailureThreshold: h.config.Agent.ExternalMCPCircuitFailureThreshold,
CircuitCooldown: time.Duration(h.config.Agent.ExternalMCPCircuitCooldownSeconds) * time.Second,
})
}
}
@@ -1471,7 +1529,10 @@ func (h *ConfigHandler) ApplyConfig(c *gin.Context) {
h.mcpServer.ClearTools()
// 重新注册安全工具
h.executor.SetToolOutputMaxBytes(h.config.MultiAgent.EinoMiddleware.ReductionMaxLengthForTruncEffective())
h.executor.SetToolOutputSpillRoot(h.config.MultiAgent.EinoMiddleware.ReductionRootDir)
h.executor.RegisterTools(h.mcpServer)
mcp.RegisterExecutionControlTools(h.mcpServer, h.externalMCPMgr)
// 重新注册漏洞记录工具(内置工具,必须注册)
if h.vulnerabilityToolRegistrar != nil {
@@ -1542,6 +1603,24 @@ func (h *ConfigHandler) ApplyConfig(c *gin.Context) {
}
if h.mcpServer != nil {
h.mcpServer.ConfigureHTTPToolCallTimeoutFromAgentMinutes(h.config.Agent.ToolTimeoutMinutes)
h.mcpServer.ConfigureToolWaitTimeoutSeconds(h.config.Agent.ToolWaitTimeoutSeconds)
h.mcpServer.ConfigureToolResultMaxBytes(h.config.MultiAgent.EinoMiddleware.ReductionMaxLengthForTruncEffective())
h.mcpServer.ConfigureToolResultSpillRoot(h.config.MultiAgent.EinoMiddleware.ReductionRootDir)
}
if h.executor != nil {
h.executor.SetToolOutputMaxBytes(h.config.MultiAgent.EinoMiddleware.ReductionMaxLengthForTruncEffective())
h.executor.SetToolOutputSpillRoot(h.config.MultiAgent.EinoMiddleware.ReductionRootDir)
}
if h.externalMCPMgr != nil {
h.externalMCPMgr.ConfigureToolWaitTimeoutSeconds(h.config.Agent.ToolWaitTimeoutSeconds)
h.externalMCPMgr.ConfigureToolResultMaxBytes(h.config.MultiAgent.EinoMiddleware.ReductionMaxLengthForTruncEffective())
h.externalMCPMgr.ConfigureToolResultSpillRoot(h.config.MultiAgent.EinoMiddleware.ReductionRootDir)
h.externalMCPMgr.ConfigureResilience(mcp.ExternalMCPResilienceConfig{
MaxConcurrentPerServer: h.config.Agent.ExternalMCPMaxConcurrentPerServer,
MaxConcurrentTotal: h.config.Agent.ExternalMCPMaxConcurrentTotal,
CircuitFailureThreshold: h.config.Agent.ExternalMCPCircuitFailureThreshold,
CircuitCooldown: time.Duration(h.config.Agent.ExternalMCPCircuitCooldownSeconds) * time.Second,
})
}
// 更新AttackChainHandler的OpenAI配置
@@ -1619,7 +1698,8 @@ func (h *ConfigHandler) saveConfig() error {
updateAgentConfig(root, h.config.Agent)
updateMCPConfig(root, h.config.MCP)
updateOpenAIConfig(root, h.config.OpenAI)
updateAIConfig(root, h.config.AI)
removeKeyFromMap(root.Content[0], "openai")
updateVisionConfig(root, h.config.Vision)
updateFOFAConfig(root, h.config.FOFA)
updateSpaceSearchConfig(root, "zoomeye", h.config.ZoomEye)
@@ -1731,6 +1811,11 @@ func updateAgentConfig(doc *yaml.Node, agent config.AgentConfig) {
agentNode := ensureMap(root, "agent")
setIntInMap(agentNode, "max_iterations", agent.MaxIterations)
setIntInMap(agentNode, "tool_timeout_minutes", agent.ToolTimeoutMinutes)
setIntInMap(agentNode, "tool_wait_timeout_seconds", agent.ToolWaitTimeoutSeconds)
setIntInMap(agentNode, "external_mcp_max_concurrent_per_server", agent.ExternalMCPMaxConcurrentPerServer)
setIntInMap(agentNode, "external_mcp_max_concurrent_total", agent.ExternalMCPMaxConcurrentTotal)
setIntInMap(agentNode, "external_mcp_circuit_failure_threshold", agent.ExternalMCPCircuitFailureThreshold)
setIntInMap(agentNode, "external_mcp_circuit_cooldown_seconds", agent.ExternalMCPCircuitCooldownSeconds)
setStringInMap(agentNode, "system_prompt_path", agent.SystemPromptPath)
}
@@ -1808,6 +1893,69 @@ func updateOpenAIConfig(doc *yaml.Node, cfg config.OpenAIConfig) {
}
}
func updateAIConfig(doc *yaml.Node, cfg config.AIConfig) {
root := doc.Content[0]
aiNode := ensureMap(root, "ai")
if strings.TrimSpace(cfg.DefaultChannel) != "" {
setStringInMap(aiNode, "default_channel", config.NormalizeAIChannelID(cfg.DefaultChannel))
}
channelsNode := ensureMap(aiNode, "channels")
channelsNode.Content = nil
normalized := make(map[string]config.AIChannelConfig, len(cfg.Channels))
ids := make([]string, 0, len(cfg.Channels))
for id, ch := range cfg.Channels {
nid := config.NormalizeAIChannelID(id)
if nid == "" {
continue
}
if _, exists := normalized[nid]; !exists {
ids = append(ids, nid)
}
normalized[nid] = ch
}
sort.Strings(ids)
seen := make(map[string]bool, len(ids))
for _, id := range ids {
if seen[id] {
continue
}
seen[id] = true
ch := normalized[id]
keyNode := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: id}
channelNode := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
channelsNode.Content = append(channelsNode.Content, keyNode, channelNode)
setStringInMap(channelNode, "name", ch.Name)
if strings.TrimSpace(ch.Provider) != "" {
setStringInMap(channelNode, "provider", ch.Provider)
}
setStringInMap(channelNode, "api_key", ch.APIKey)
setStringInMap(channelNode, "base_url", ch.BaseURL)
setStringInMap(channelNode, "model", ch.Model)
if ch.MaxTotalTokens > 0 {
setIntInMap(channelNode, "max_total_tokens", ch.MaxTotalTokens)
}
if ch.MaxCompletionTokens > 0 {
setIntInMap(channelNode, "max_completion_tokens", ch.MaxCompletionTokens)
}
rn := ensureMap(channelNode, "reasoning")
if strings.TrimSpace(ch.Reasoning.Mode) != "" {
setStringInMap(rn, "mode", ch.Reasoning.Mode)
}
if strings.TrimSpace(ch.Reasoning.Effort) != "" {
setStringInMap(rn, "effort", ch.Reasoning.Effort)
}
if ch.Reasoning.AllowClientReasoning != nil {
setBoolInMap(rn, "allow_client_reasoning", *ch.Reasoning.AllowClientReasoning)
}
if strings.TrimSpace(ch.Reasoning.Profile) != "" {
setStringInMap(rn, "profile", ch.Reasoning.Profile)
}
if len(rn.Content) == 0 {
removeKeyFromMap(channelNode, "reasoning")
}
}
}
func updateFOFAConfig(doc *yaml.Node, cfg config.FofaConfig) {
root := doc.Content[0]
fofaNode := ensureMap(root, "fofa")
+1
View File
@@ -373,6 +373,7 @@ func summarizeProcessDetailData(eventType string, data interface{}) interface{}
"success": true, "isError": true, "executionId": true,
"einoAgent": true, "einoRole": true, "einoScope": true, "orchestration": true,
"agentFacing": true,
"status": true, "modelFacingIsError": true, "resultPreview": true,
}
out := make(map[string]interface{}, len(allow)+1)
for k, v := range m {
@@ -68,15 +68,15 @@ func (h *AgentHandler) tryContinueOnEinoEmptyResponse(
case <-time.After(backoff):
}
inject := multiagent.FormatEmptyResponseContinueUserMessage()
h.applyEinoTraceResumeSegment(conversationID, result, curHistory, curFinalMessage, inject)
h.applyEinoTraceResumeSegment(conversationID, result, curHistory, curFinalMessage, "")
if progressCallback != nil {
progressCallback("eino_empty_response_continue", "已恢复上下文,正在续跑…", map[string]interface{}{
"conversationId": conversationID,
"source": "eino",
"attempt": *attempt,
"maxAttempts": maxAttempts,
"contextSource": "empty_response_continue",
"conversationId": conversationID,
"source": "eino",
"attempt": *attempt,
"maxAttempts": maxAttempts,
"contextSource": "empty_response_continue",
"contextInjection": false,
})
}
return true
+75 -22
View File
@@ -10,6 +10,7 @@ import (
"sync"
"time"
"cyberstrike-ai/internal/agentfinalizer"
"cyberstrike-ai/internal/mcp"
"cyberstrike-ai/internal/multiagent"
@@ -149,6 +150,14 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
return
}
runCfg, resolvedAIChannelID, err := h.configForAIChannel(req.AIChannelID)
if err != nil {
taskStatus = "failed"
h.tasks.UpdateTaskStatus(conversationID, taskStatus)
sendEvent("error", err.Error(), nil)
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
return
}
var result *multiagent.RunResult
var runErr error
@@ -181,6 +190,8 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
// 同一请求内分段续跑时,主代理 iteration 事件按偏移累计,避免 UI 出现「第3轮 → 第1轮」回跳。
var mainIterationOffset int
var emptyResponseContinueAttempt int
var finalizationAutoContinueAttempt int
var decision agentfinalizer.Decision
for {
segmentMainIterationMax := 0
@@ -222,8 +233,8 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
result, runErr = multiagent.RunEinoSingleChatModelAgent(
taskCtxLoop,
h.config,
&h.config.MultiAgent,
runCfg,
&runCfg.MultiAgent,
h.agent,
h.db,
h.logger,
@@ -236,6 +247,7 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
chatReasoningToClientIntent(req.Reasoning),
h.agentSessionContextBlock(conversationID),
)
_ = resolvedAIChannelID
if result != nil && len(result.MCPExecutionIDs) > 0 {
cumulativeMCPExecutionIDs = mergeMCPExecutionIDLists(cumulativeMCPExecutionIDs, result.MCPExecutionIDs)
@@ -249,6 +261,13 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
baseCtx, cancelWithCause, taskCtx, timeoutCancel = h.rebindEinoRunningTask(taskCtx, conversationID, timeoutCancel)
continue
}
decision = h.decideAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, "eino_single", result, cumulativeMCPExecutionIDs, requestRequiresExecutionEvidence(&req))
if h.tryAutoContinueAfterFinalization(taskCtx, conversationID, result, decision, &finalizationAutoContinueAttempt, &curHistory, &curFinalMessage, progressCallback) {
mainIterationOffset += segmentMainIterationMax
timeoutCancel()
baseCtx, cancelWithCause, taskCtx, timeoutCancel = h.rebindEinoRunningTask(taskCtx, conversationID, timeoutCancel)
continue
}
timeoutCancel()
break
}
@@ -349,9 +368,10 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
timeoutCancel()
if assistantMessageID != "" {
_ = h.db.UpdateAssistantMessageFinalize(assistantMessageID, result.Response, cumulativeMCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput))
if decision.CompletionReason == "" {
decision = h.decideAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, "eino_single", result, cumulativeMCPExecutionIDs, requestRequiresExecutionEvidence(&req))
}
h.persistFinalizationDecision(conversationID, assistantMessageID, "eino_single", cumulativeMCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput), decision)
if result.LastAgentTraceInput != "" || result.LastAgentTraceOutput != "" {
if err := h.db.SaveAgentTrace(conversationID, result.LastAgentTraceInput, result.LastAgentTraceOutput); err != nil {
@@ -359,12 +379,19 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
}
}
sendEvent("response", result.Response, map[string]interface{}{
responseText := decision.FinalText
if !decision.Finalizable {
responseText = finalizationBlockedMessage(decision)
sendEvent("finalization_check", responseText, decision)
taskStatus = decision.Status
h.tasks.UpdateTaskStatus(conversationID, taskStatus)
}
sendEvent("response", responseText, finalizationResponsePayload(decision, map[string]interface{}{
"mcpExecutionIds": cumulativeMCPExecutionIDs,
"conversationId": conversationID,
"messageId": assistantMessageID,
"agentMode": "eino_single",
})
}))
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
}
@@ -410,16 +437,24 @@ func (h *AgentHandler) EinoSingleAgentLoop(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "服务器配置未加载"})
return
}
runCfg, _, err := h.configForAIChannel(req.AIChannelID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
curHist := prep.History
curMsg := prep.FinalMessage
var result *multiagent.RunResult
var runErr error
var emptyResponseContinueAttempt int
var finalizationAutoContinueAttempt int
var decision agentfinalizer.Decision
for {
result, runErr = multiagent.RunEinoSingleChatModelAgent(
taskCtx,
h.config,
&h.config.MultiAgent,
runCfg,
&runCfg.MultiAgent,
h.agent,
h.db,
h.logger,
@@ -432,28 +467,46 @@ func (h *AgentHandler) EinoSingleAgentLoop(c *gin.Context) {
chatReasoningToClientIntent(req.Reasoning),
h.agentSessionContextBlock(prep.ConversationID),
)
if runErr == nil {
break
if runErr != nil {
if shouldPersistEinoAgentTraceAfterRunError(baseCtx) {
h.persistEinoAgentTraceForResume(prep.ConversationID, result)
}
c.JSON(http.StatusInternalServerError, gin.H{"error": runErr.Error()})
return
}
if shouldPersistEinoAgentTraceAfterRunError(baseCtx) {
h.persistEinoAgentTraceForResume(prep.ConversationID, result)
mw := &h.config.MultiAgent.EinoMiddleware
if h.tryContinueOnEinoEmptyResponse(taskCtx, mw, prep.ConversationID, result, &emptyResponseContinueAttempt, &curHist, &curMsg, progressCallback) {
continue
}
c.JSON(http.StatusInternalServerError, gin.H{"error": runErr.Error()})
return
decision = h.decideAgentRunForDeliveryWithPolicy(prep.ConversationID, prep.AssistantMessageID, "eino_single", result, result.MCPExecutionIDs, requestRequiresExecutionEvidence(&req))
if h.tryAutoContinueAfterFinalization(taskCtx, prep.ConversationID, result, decision, &finalizationAutoContinueAttempt, &curHist, &curMsg, progressCallback) {
continue
}
break
}
if prep.AssistantMessageID != "" {
_ = h.db.UpdateAssistantMessageFinalize(prep.AssistantMessageID, result.Response, result.MCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput))
}
h.persistFinalizationDecision(prep.ConversationID, prep.AssistantMessageID, "eino_single", result.MCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput), decision)
if result.LastAgentTraceInput != "" || result.LastAgentTraceOutput != "" {
_ = h.db.SaveAgentTrace(prep.ConversationID, result.LastAgentTraceInput, result.LastAgentTraceOutput)
}
responseText := decision.FinalText
if !decision.Finalizable {
responseText = finalizationBlockedMessage(decision)
}
c.JSON(http.StatusOK, gin.H{
"response": result.Response,
"conversationId": prep.ConversationID,
"mcpExecutionIds": result.MCPExecutionIDs,
"assistantMessageId": prep.AssistantMessageID,
"agentMode": "eino_single",
"response": responseText,
"conversationId": prep.ConversationID,
"mcpExecutionIds": result.MCPExecutionIDs,
"assistantMessageId": prep.AssistantMessageID,
"agentMode": "eino_single",
"finalized": decision.Finalized,
"finalizable": decision.Finalizable,
"status": decision.Status,
"completionReason": decision.CompletionReason,
"evidenceVerified": decision.EvidenceVerified,
"evidenceRefs": decision.EvidenceRefs,
"pendingExecutionIds": decision.PendingExecutionIDs,
"missingChecks": decision.MissingChecks,
})
}
@@ -0,0 +1,77 @@
package handler
import (
"context"
"time"
"cyberstrike-ai/internal/agent"
"cyberstrike-ai/internal/agentfinalizer"
"cyberstrike-ai/internal/multiagent"
"go.uber.org/zap"
)
const finalizationAutoContinueMaxAttempts = 2
func shouldAutoContinueAfterFinalization(d agentfinalizer.Decision, attempt int) bool {
if d.Finalizable || d.Finalized {
return false
}
if attempt >= finalizationAutoContinueMaxAttempts {
return false
}
return d.CompletionReason == agentfinalizer.ReasonMissingEvidence
}
func (h *AgentHandler) tryAutoContinueAfterFinalization(
taskCtx context.Context,
conversationID string,
result *multiagent.RunResult,
decision agentfinalizer.Decision,
attempt *int,
curHistory *[]agent.ChatMessage,
curFinalMessage *string,
progressCallback func(eventType, message string, data interface{}),
) bool {
if !shouldAutoContinueAfterFinalization(decision, *attempt) || result == nil || !multiagent.HasEinoResumeTrace(result) {
return false
}
*attempt++
h.persistEinoAgentTraceForResume(conversationID, result)
if hist, err := h.loadHistoryFromAgentTrace(conversationID); err == nil && len(hist) > 0 {
*curHistory = hist
} else if h.logger != nil {
h.logger.Warn("finalization auto-continue could not restore trace",
zap.String("conversationId", conversationID),
zap.Error(err))
return false
}
// Agent 无感续跑:不追加新的 user/system 文案,只使用上一段模型可见轨迹继续 Runner。
*curFinalMessage = ""
if progressCallback != nil {
progressCallback("finalization_auto_continue", "最终回复检查尚未收敛,正在基于已有轨迹继续执行…", map[string]interface{}{
"conversationId": conversationID,
"source": "finalizer",
"attempt": *attempt,
"maxAttempts": finalizationAutoContinueMaxAttempts,
"status": decision.Status,
"completionReason": decision.CompletionReason,
"missingChecks": decision.MissingChecks,
"pendingExecutionIds": decision.PendingExecutionIDs,
"contextInjection": false,
})
}
select {
case <-taskCtx.Done():
return false
case <-time.After(finalizationAutoContinueBackoff(*attempt)):
return true
}
}
func finalizationAutoContinueBackoff(attempt int) time.Duration {
if attempt <= 1 {
return 500 * time.Millisecond
}
return time.Duration(attempt) * time.Second
}
@@ -0,0 +1,59 @@
package handler
import (
"testing"
"cyberstrike-ai/internal/agentfinalizer"
)
func TestShouldAutoContinueAfterFinalization(t *testing.T) {
missingEvidence := agentfinalizer.Decision{
Status: agentfinalizer.StatusBlocked,
CompletionReason: agentfinalizer.ReasonMissingEvidence,
}
if !shouldAutoContinueAfterFinalization(missingEvidence, 0) {
t.Fatal("missing execution evidence should trigger auto-continue")
}
if shouldAutoContinueAfterFinalization(missingEvidence, finalizationAutoContinueMaxAttempts) {
t.Fatal("auto-continue should stop at max attempts")
}
finalized := agentfinalizer.Decision{
Status: agentfinalizer.StatusCompleted,
CompletionReason: agentfinalizer.ReasonVerified,
Finalizable: true,
Finalized: true,
}
if shouldAutoContinueAfterFinalization(finalized, 0) {
t.Fatal("finalized decision should not auto-continue")
}
awaitingHITL := agentfinalizer.Decision{
Status: agentfinalizer.StatusAwaitingHITL,
CompletionReason: agentfinalizer.ReasonAwaitingHITL,
}
if shouldAutoContinueAfterFinalization(awaitingHITL, 0) {
t.Fatal("awaiting HITL should not auto-continue without approval")
}
}
func TestRequestRequiresExecutionEvidenceUsesExplicitPolicyOnly(t *testing.T) {
if requestRequiresExecutionEvidence(nil) {
t.Fatal("nil request should not require execution evidence")
}
if requestRequiresExecutionEvidence(&ChatRequest{}) {
t.Fatal("missing finalization policy should not require execution evidence")
}
require := true
if !requestRequiresExecutionEvidence(&ChatRequest{
Finalization: ChatFinalizationRequest{RequireExecutionEvidence: &require},
}) {
t.Fatal("explicit true policy should require execution evidence")
}
require = false
if requestRequiresExecutionEvidence(&ChatRequest{
Finalization: ChatFinalizationRequest{RequireExecutionEvidence: &require},
}) {
t.Fatal("explicit false policy should not require execution evidence")
}
}
+171
View File
@@ -0,0 +1,171 @@
package handler
import (
"fmt"
"strings"
"time"
"cyberstrike-ai/internal/agentfinalizer"
"cyberstrike-ai/internal/multiagent"
"go.uber.org/zap"
)
func (h *AgentHandler) finalizeAgentRunForDelivery(
conversationID string,
assistantMessageID string,
agentMode string,
result *multiagent.RunResult,
mcpExecutionIDs []string,
reasoningContent string,
) agentfinalizer.Decision {
return h.finalizeAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, agentMode, result, mcpExecutionIDs, reasoningContent, false)
}
func (h *AgentHandler) finalizeAgentRunForDeliveryWithPolicy(
conversationID string,
assistantMessageID string,
agentMode string,
result *multiagent.RunResult,
mcpExecutionIDs []string,
reasoningContent string,
requireExecutionEvidence bool,
) agentfinalizer.Decision {
decision := agentfinalizer.FromRunResult(h.db, result, agentfinalizer.Input{
ConversationID: conversationID,
AssistantMessageID: assistantMessageID,
AgentMode: agentMode,
MCPExecutionIDs: mcpExecutionIDs,
RequireExecutionEvidence: requireExecutionEvidence,
})
h.persistFinalizationDecision(conversationID, assistantMessageID, agentMode, mcpExecutionIDs, reasoningContent, decision)
return decision
}
func (h *AgentHandler) decideAgentRunForDeliveryWithPolicy(
conversationID string,
assistantMessageID string,
agentMode string,
result *multiagent.RunResult,
mcpExecutionIDs []string,
requireExecutionEvidence bool,
) agentfinalizer.Decision {
return agentfinalizer.FromRunResult(h.db, result, agentfinalizer.Input{
ConversationID: conversationID,
AssistantMessageID: assistantMessageID,
AgentMode: agentMode,
MCPExecutionIDs: mcpExecutionIDs,
RequireExecutionEvidence: requireExecutionEvidence,
})
}
func (h *AgentHandler) decideAgentRunForDelivery(
conversationID string,
assistantMessageID string,
agentMode string,
result *multiagent.RunResult,
mcpExecutionIDs []string,
) agentfinalizer.Decision {
return agentfinalizer.FromRunResult(h.db, result, agentfinalizer.Input{
ConversationID: conversationID,
AssistantMessageID: assistantMessageID,
AgentMode: agentMode,
MCPExecutionIDs: mcpExecutionIDs,
RequireExecutionEvidence: false,
})
}
func (h *AgentHandler) persistFinalizationDecision(
conversationID string,
assistantMessageID string,
agentMode string,
mcpExecutionIDs []string,
reasoningContent string,
decision agentfinalizer.Decision,
) {
if assistantMessageID == "" || h.db == nil {
return
}
_ = h.db.AddProcessDetail(assistantMessageID, conversationID, "finalization_check", finalizationCheckMessage(decision), decision)
if decision.Finalizable {
if err := h.db.UpdateAssistantMessageFinalize(assistantMessageID, decision.FinalText, mcpExecutionIDs, reasoningContent); err != nil && h.logger != nil {
h.logger.Warn("更新最终助手消息失败", zap.Error(err), zap.String("conversationId", conversationID), zap.String("agentMode", agentMode))
}
return
}
_, _ = h.db.Exec("UPDATE messages SET content = ?, updated_at = ? WHERE id = ?", finalizationBlockedMessage(decision), time.Now(), assistantMessageID)
}
func (h *AgentHandler) finalizeCandidateForDelivery(
conversationID string,
assistantMessageID string,
agentMode string,
response string,
mcpExecutionIDs []string,
awaitingHITL bool,
reasoningContent string,
) agentfinalizer.Decision {
return h.finalizeCandidateForDeliveryWithPolicy(conversationID, assistantMessageID, agentMode, response, mcpExecutionIDs, awaitingHITL, reasoningContent, false)
}
func (h *AgentHandler) finalizeCandidateForDeliveryWithPolicy(
conversationID string,
assistantMessageID string,
agentMode string,
response string,
mcpExecutionIDs []string,
awaitingHITL bool,
reasoningContent string,
requireExecutionEvidence bool,
) agentfinalizer.Decision {
decision := agentfinalizer.Decide(h.db, agentfinalizer.Input{
Response: response,
ConversationID: conversationID,
AssistantMessageID: assistantMessageID,
AgentMode: agentMode,
MCPExecutionIDs: mcpExecutionIDs,
AwaitingHITL: awaitingHITL,
RequireExecutionEvidence: requireExecutionEvidence,
})
if assistantMessageID == "" || h.db == nil {
return decision
}
_ = h.db.AddProcessDetail(assistantMessageID, conversationID, "finalization_check", finalizationCheckMessage(decision), decision)
if decision.Finalizable {
if err := h.db.UpdateAssistantMessageFinalize(assistantMessageID, decision.FinalText, mcpExecutionIDs, reasoningContent); err != nil && h.logger != nil {
h.logger.Warn("更新最终助手消息失败", zap.Error(err), zap.String("conversationId", conversationID), zap.String("agentMode", agentMode))
}
return decision
}
_, _ = h.db.Exec("UPDATE messages SET content = ?, updated_at = ? WHERE id = ?", finalizationBlockedMessage(decision), time.Now(), assistantMessageID)
return decision
}
func finalizationCheckMessage(d agentfinalizer.Decision) string {
if d.Finalizable {
return "最终回复检查通过。"
}
return finalizationBlockedMessage(d)
}
func finalizationBlockedMessage(d agentfinalizer.Decision) string {
parts := []string{"任务尚未达到最终回复条件,暂不生成成功结论。"}
if d.CompletionReason != "" {
parts = append(parts, "原因: "+d.CompletionReason)
}
if len(d.PendingExecutionIDs) > 0 {
parts = append(parts, fmt.Sprintf("仍有 %d 个工具执行未结束: %s", len(d.PendingExecutionIDs), strings.Join(d.PendingExecutionIDs, ", ")))
}
if len(d.MissingChecks) > 0 {
parts = append(parts, "缺失检查: "+strings.Join(d.MissingChecks, "; "))
}
return strings.Join(parts, "\n")
}
func finalizationResponsePayload(d agentfinalizer.Decision, extra map[string]interface{}) map[string]interface{} {
return agentfinalizer.ResponsePayload(d, extra)
}
func requestRequiresExecutionEvidence(req *ChatRequest) bool {
return req != nil && req.Finalization.RequireExecutionEvidence != nil && *req.Finalization.RequireExecutionEvidence
}
+13 -4
View File
@@ -167,7 +167,7 @@ func summarizeAccessibleExecutionPage(executions []*mcp.ToolExecution, topN int)
stats[exec.ToolName] = stat
}
stat.TotalCalls++
if exec.Status == "failed" || exec.Status == "cancelled" {
if monitorStatusCountsAsFailed(exec.Status) {
stat.FailedCalls++
} else if exec.Status == "completed" {
stat.SuccessCalls++
@@ -180,6 +180,15 @@ func summarizeAccessibleExecutionPage(executions []*mcp.ToolExecution, topN int)
return summarizeToolStats(stats, topN)
}
func monitorStatusCountsAsFailed(status string) bool {
switch strings.TrimSpace(strings.ToLower(status)) {
case "failed", "hard_timeout", "orphaned":
return true
default:
return false
}
}
func (h *MonitorHandler) monitorRetentionDays() int {
if h.monitorRetention != nil {
return h.monitorRetention.RetentionDays()
@@ -813,7 +822,7 @@ func (h *MonitorHandler) loadCallsTimeline(cfg callsTimelineConfig) []CallsTimel
key := truncateToBucket(exec.StartTime, cfg.bucketSize, cfg.dailyBuckets)
entry := bucketMap[key]
entry.total++
if exec.Status == "failed" || exec.Status == "cancelled" {
if monitorStatusCountsAsFailed(exec.Status) {
entry.failed++
}
bucketMap[key] = entry
@@ -876,7 +885,7 @@ func (h *MonitorHandler) DeleteExecution(c *gin.Context) {
totalCalls := 1
successCalls := 0
failedCalls := 0
if exec.Status == "failed" || exec.Status == "cancelled" {
if monitorStatusCountsAsFailed(exec.Status) {
failedCalls = 1
} else if exec.Status == "completed" {
successCalls = 1
@@ -951,7 +960,7 @@ func (h *MonitorHandler) DeleteExecutions(c *gin.Context) {
stats := toolStats[exec.ToolName]
stats.totalCalls++
if exec.Status == "failed" || exec.Status == "cancelled" {
if monitorStatusCountsAsFailed(exec.Status) {
stats.failedCalls++
} else if exec.Status == "completed" {
stats.successCalls++
+85 -29
View File
@@ -10,6 +10,7 @@ import (
"sync"
"time"
"cyberstrike-ai/internal/agentfinalizer"
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/mcp"
"cyberstrike-ai/internal/multiagent"
@@ -158,6 +159,12 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
stopKeepalive := runSSEKeepalive(c, &sseWriteMu)
defer stopKeepalive()
runCfg, _, err := h.configForAIChannel(req.AIChannelID)
if err != nil {
sendEvent("error", err.Error(), nil)
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
return
}
var result *multiagent.RunResult
var runErr error
@@ -191,6 +198,13 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
// 同一请求内分段续跑时,主代理 iteration 事件按偏移累计,避免 UI 出现「第3轮 → 第1轮」回跳。
var mainIterationOffset int
var emptyResponseContinueAttempt int
var finalizationAutoContinueAttempt int
effectiveOrch := config.NormalizeMultiAgentOrchestration(h.config.MultiAgent.Orchestration)
if o := strings.TrimSpace(req.Orchestration); o != "" {
effectiveOrch = config.NormalizeMultiAgentOrchestration(o)
}
agentMode := "eino_" + effectiveOrch
var decision agentfinalizer.Decision
for {
segmentMainIterationMax := 0
@@ -232,8 +246,8 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
result, runErr = multiagent.RunDeepAgent(
taskCtxLoop,
h.config,
&h.config.MultiAgent,
runCfg,
&runCfg.MultiAgent,
h.agent,
h.db,
h.logger,
@@ -261,6 +275,13 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
baseCtx, cancelWithCause, taskCtx, timeoutCancel = h.rebindEinoRunningTask(taskCtx, conversationID, timeoutCancel)
continue
}
decision = h.decideAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, agentMode, result, cumulativeMCPExecutionIDs, requestRequiresExecutionEvidence(&req))
if h.tryAutoContinueAfterFinalization(taskCtx, conversationID, result, decision, &finalizationAutoContinueAttempt, &curHistory, &curFinalMessage, progressCallback) {
mainIterationOffset += segmentMainIterationMax
timeoutCancel()
baseCtx, cancelWithCause, taskCtx, timeoutCancel = h.rebindEinoRunningTask(taskCtx, conversationID, timeoutCancel)
continue
}
timeoutCancel()
break
}
@@ -361,9 +382,10 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
timeoutCancel()
if assistantMessageID != "" {
_ = h.db.UpdateAssistantMessageFinalize(assistantMessageID, result.Response, cumulativeMCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput))
if decision.CompletionReason == "" {
decision = h.decideAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, agentMode, result, cumulativeMCPExecutionIDs, requestRequiresExecutionEvidence(&req))
}
h.persistFinalizationDecision(conversationID, assistantMessageID, agentMode, cumulativeMCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput), decision)
if result.LastAgentTraceInput != "" || result.LastAgentTraceOutput != "" {
if err := h.db.SaveAgentTrace(conversationID, result.LastAgentTraceInput, result.LastAgentTraceOutput); err != nil {
@@ -371,16 +393,19 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
}
}
effectiveOrch := config.NormalizeMultiAgentOrchestration(h.config.MultiAgent.Orchestration)
if o := strings.TrimSpace(req.Orchestration); o != "" {
effectiveOrch = config.NormalizeMultiAgentOrchestration(o)
responseText := decision.FinalText
if !decision.Finalizable {
responseText = finalizationBlockedMessage(decision)
sendEvent("finalization_check", responseText, decision)
taskStatus = decision.Status
h.tasks.UpdateTaskStatus(conversationID, taskStatus)
}
sendEvent("response", result.Response, map[string]interface{}{
sendEvent("response", responseText, finalizationResponsePayload(decision, map[string]interface{}{
"mcpExecutionIds": cumulativeMCPExecutionIDs,
"conversationId": conversationID,
"messageId": assistantMessageID,
"agentMode": "eino_" + effectiveOrch,
})
"agentMode": agentMode,
}))
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
}
@@ -421,16 +446,29 @@ func (h *AgentHandler) MultiAgentLoop(c *gin.Context) {
taskCtx = multiagent.WithHITLToolInterceptor(taskCtx, func(ctx context.Context, toolName, arguments string) (string, error) {
return h.interceptHITLForEinoTool(ctx, cancelWithCause, prep.ConversationID, prep.AssistantMessageID, nil, toolName, arguments)
})
runCfg, _, err := h.configForAIChannel(req.AIChannelID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
curHist := prep.History
curMsg := prep.FinalMessage
var result *multiagent.RunResult
var runErr error
var emptyResponseContinueAttempt int
var finalizationAutoContinueAttempt int
effectiveOrch := config.NormalizeMultiAgentOrchestration(h.config.MultiAgent.Orchestration)
if o := strings.TrimSpace(req.Orchestration); o != "" {
effectiveOrch = config.NormalizeMultiAgentOrchestration(o)
}
agentMode := "eino_" + effectiveOrch
var decision agentfinalizer.Decision
for {
result, runErr = multiagent.RunDeepAgent(
taskCtx,
h.config,
&h.config.MultiAgent,
runCfg,
&runCfg.MultiAgent,
h.agent,
h.db,
h.logger,
@@ -445,24 +483,30 @@ func (h *AgentHandler) MultiAgentLoop(c *gin.Context) {
chatReasoningToClientIntent(req.Reasoning),
h.agentSessionContextBlock(prep.ConversationID),
)
if runErr == nil {
break
if runErr != nil {
if shouldPersistEinoAgentTraceAfterRunError(baseCtx) {
h.persistEinoAgentTraceForResume(prep.ConversationID, result)
}
h.logger.Error("Eino DeepAgent 执行失败", zap.Error(runErr))
errMsg := "执行失败: " + runErr.Error()
if prep.AssistantMessageID != "" {
_, _ = h.db.Exec("UPDATE messages SET content = ?, updated_at = ? WHERE id = ?", errMsg, time.Now(), prep.AssistantMessageID)
}
c.JSON(http.StatusInternalServerError, gin.H{"error": errMsg})
return
}
if shouldPersistEinoAgentTraceAfterRunError(baseCtx) {
h.persistEinoAgentTraceForResume(prep.ConversationID, result)
mw := &h.config.MultiAgent.EinoMiddleware
if h.tryContinueOnEinoEmptyResponse(taskCtx, mw, prep.ConversationID, result, &emptyResponseContinueAttempt, &curHist, &curMsg, progressCallback) {
continue
}
h.logger.Error("Eino DeepAgent 执行失败", zap.Error(runErr))
errMsg := "执行失败: " + runErr.Error()
if prep.AssistantMessageID != "" {
_, _ = h.db.Exec("UPDATE messages SET content = ?, updated_at = ? WHERE id = ?", errMsg, time.Now(), prep.AssistantMessageID)
decision = h.decideAgentRunForDeliveryWithPolicy(prep.ConversationID, prep.AssistantMessageID, agentMode, result, result.MCPExecutionIDs, requestRequiresExecutionEvidence(&req))
if h.tryAutoContinueAfterFinalization(taskCtx, prep.ConversationID, result, decision, &finalizationAutoContinueAttempt, &curHist, &curMsg, progressCallback) {
continue
}
c.JSON(http.StatusInternalServerError, gin.H{"error": errMsg})
return
break
}
if prep.AssistantMessageID != "" {
_ = h.db.UpdateAssistantMessageFinalize(prep.AssistantMessageID, result.Response, result.MCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput))
}
h.persistFinalizationDecision(prep.ConversationID, prep.AssistantMessageID, agentMode, result.MCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput), decision)
if result.LastAgentTraceInput != "" || result.LastAgentTraceOutput != "" {
if err := h.db.SaveAgentTrace(prep.ConversationID, result.LastAgentTraceInput, result.LastAgentTraceOutput); err != nil {
@@ -470,11 +514,23 @@ func (h *AgentHandler) MultiAgentLoop(c *gin.Context) {
}
}
responseText := decision.FinalText
if !decision.Finalizable {
responseText = finalizationBlockedMessage(decision)
}
c.JSON(http.StatusOK, ChatResponse{
Response: result.Response,
MCPExecutionIDs: result.MCPExecutionIDs,
ConversationID: prep.ConversationID,
Time: time.Now(),
Response: responseText,
MCPExecutionIDs: result.MCPExecutionIDs,
ConversationID: prep.ConversationID,
Time: time.Now(),
Finalizable: decision.Finalizable,
Finalized: decision.Finalized,
Status: decision.Status,
CompletionReason: decision.CompletionReason,
EvidenceVerified: decision.EvidenceVerified,
EvidenceRefs: decision.EvidenceRefs,
PendingExecutionIDs: decision.PendingExecutionIDs,
MissingChecks: decision.MissingChecks,
})
}
+16
View File
@@ -6,6 +6,7 @@ import (
"cyberstrike-ai/internal/agent"
"cyberstrike-ai/internal/audit"
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/database"
"cyberstrike-ai/internal/mcp/builtin"
"cyberstrike-ai/internal/security"
@@ -25,6 +26,13 @@ type multiAgentPrepared struct {
UserMessageID string
}
func chatRequestAgentMode(req *ChatRequest, source string) string {
if strings.HasPrefix(strings.TrimSpace(source), "multi_agent") {
return config.NormalizeMultiAgentOrchestration(req.Orchestration)
}
return "eino_single"
}
func (h *AgentHandler) prepareMultiAgentSession(req *ChatRequest, c *gin.Context, source string) (*multiAgentPrepared, error) {
if len(req.Attachments) > maxAttachments {
return nil, fmt.Errorf("附件最多 %d 个", maxAttachments)
@@ -56,6 +64,8 @@ func (h *AgentHandler) prepareMultiAgentSession(req *ChatRequest, c *gin.Context
var err error
meta := audit.ConversationCreateMetaFromGin(c, source)
meta.ProjectID = projectID
meta.RoleName = req.Role
meta.AgentMode = chatRequestAgentMode(req, source)
if webshellID != "" {
meta.Source = source + "_webshell"
meta.WebShellConnectionID = webshellID
@@ -80,6 +90,12 @@ func (h *AgentHandler) prepareMultiAgentSession(req *ChatRequest, c *gin.Context
return nil, fmt.Errorf("无权访问该对话")
}
}
if err := h.db.SetConversationRoleName(conversationID, req.Role); err != nil {
h.logger.Warn("更新对话角色失败", zap.String("conversationId", conversationID), zap.String("role", req.Role), zap.Error(err))
}
if err := h.db.SetConversationAgentMode(conversationID, chatRequestAgentMode(req, source)); err != nil {
h.logger.Warn("更新对话模式失败", zap.String("conversationId", conversationID), zap.String("source", source), zap.String("orchestration", req.Orchestration), zap.Error(err))
}
agentHistoryMessages, err := h.loadHistoryFromAgentTrace(conversationID)
if err != nil {
+201 -18
View File
@@ -35,6 +35,17 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
scheme = "https"
}
finalizationRequestSchema := map[string]interface{}{
"type": "object",
"description": "最终回复交付策略。后端不会从自然语言内容推断执行意图;执行入口应显式声明是否要求 completed 工具证据。",
"properties": map[string]interface{}{
"requireExecutionEvidence": map[string]interface{}{
"type": "boolean",
"description": "为 true 时,缺少 completed 工具执行记录会触发无注入续跑或最终阻断;普通聊天可省略或设为 false。",
},
},
}
spec := map[string]interface{}{
"openapi": "3.0.0",
"info": map[string]interface{}{
@@ -85,6 +96,70 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
},
"required": []string{"projectId"},
},
"AgentChatResponse": map[string]interface{}{
"type": "object",
"description": "Agent 非流式响应。response 只是交付文本;是否为成功最终回复必须以 finalized/finalizable/status 为准。",
"properties": map[string]interface{}{
"response": map[string]interface{}{
"type": "string",
"description": "交付给用户的文本。finalized=false 时为阻断/未完成说明,不是成功结论。",
},
"conversationId": map[string]interface{}{
"type": "string",
"description": "对话 ID",
},
"assistantMessageId": map[string]interface{}{
"type": "string",
"description": "助手消息 ID(部分接口返回)",
},
"mcpExecutionIds": map[string]interface{}{
"type": "array",
"description": "本轮关联的 MCP 工具执行 ID",
"items": map[string]interface{}{"type": "string"},
},
"agentMode": map[string]interface{}{
"type": "string",
"description": "agent 模式,例如 eino_single、eino_deep、workflow",
},
"finalized": map[string]interface{}{
"type": "boolean",
"description": "是否已经通过最终回复检查。只有 true 才能当成功最终回复。",
},
"finalizable": map[string]interface{}{
"type": "boolean",
"description": "候选输出是否可提升为最终回复。",
},
"status": map[string]interface{}{
"type": "string",
"description": "最终化状态",
"enum": []string{"completed", "in_progress", "blocked", "failed", "cancelled", "awaiting_hitl"},
},
"completionReason": map[string]interface{}{
"type": "string",
"description": "最终化或阻断原因,例如 verified、pending_tool_executions、missing_execution_evidence",
},
"evidenceVerified": map[string]interface{}{
"type": "boolean",
"description": "证据是否满足最终化要求",
},
"evidenceRefs": map[string]interface{}{
"type": "array",
"description": "证据引用,例如 mcp_execution:<id>",
"items": map[string]interface{}{"type": "string"},
},
"pendingExecutionIds": map[string]interface{}{
"type": "array",
"description": "仍处于 queued/running 的工具执行 ID",
"items": map[string]interface{}{"type": "string"},
},
"missingChecks": map[string]interface{}{
"type": "array",
"description": "未通过最终化检查的原因列表",
"items": map[string]interface{}{"type": "string"},
},
},
"required": []string{"response", "conversationId", "finalized", "finalizable", "status", "evidenceVerified"},
},
"Conversation": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
@@ -307,7 +382,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
"status": map[string]interface{}{
"type": "string",
"description": "执行状态",
"enum": []string{"success", "failed", "running"},
"enum": []string{"queued", "running", "completed", "failed", "cancelled", "hard_timeout", "orphaned"},
},
"result": map[string]interface{}{
"type": "string",
@@ -778,7 +853,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
"status": map[string]interface{}{
"type": "string",
"description": "执行状态",
"enum": []string{"success", "failed", "running"},
"enum": []string{"queued", "running", "completed", "failed", "cancelled", "hard_timeout", "orphaned"},
},
"createdAt": map[string]interface{}{
"type": "string",
@@ -839,6 +914,9 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
"type": "object",
"description": "配置信息(含 openai、vision、multi_agent 等)",
"properties": map[string]interface{}{
"agent": map[string]interface{}{
"$ref": "#/components/schemas/AgentConfig",
},
"vision": map[string]interface{}{
"$ref": "#/components/schemas/VisionConfig",
},
@@ -848,11 +926,30 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
"type": "object",
"description": "更新配置请求",
"properties": map[string]interface{}{
"agent": map[string]interface{}{
"$ref": "#/components/schemas/AgentConfig",
},
"vision": map[string]interface{}{
"$ref": "#/components/schemas/VisionConfig",
},
},
},
"AgentConfig": map[string]interface{}{
"type": "object",
"description": "Agent 运行与外部 MCP 防卡死保护配置",
"properties": map[string]interface{}{
"max_iterations": map[string]interface{}{"type": "integer", "description": "最大迭代次数"},
"tool_timeout_minutes": map[string]interface{}{"type": "integer", "description": "单次工具执行硬超时(分钟)"},
"tool_wait_timeout_seconds": map[string]interface{}{"type": "integer", "description": "工具单轮等待秒数;到时返回 execution_idworker 继续后台执行"},
"external_mcp_max_concurrent_per_server": map[string]interface{}{"type": "integer", "description": "单个外部 MCP server 并发上限;0=默认2;负数=不限制"},
"external_mcp_max_concurrent_total": map[string]interface{}{"type": "integer", "description": "外部 MCP 全局并发上限;0=默认16;负数=不限制"},
"external_mcp_circuit_failure_threshold": map[string]interface{}{"type": "integer", "description": "连续失败熔断阈值;0=默认3;负数=关闭熔断"},
"external_mcp_circuit_cooldown_seconds": map[string]interface{}{"type": "integer", "description": "熔断冷却秒数;0=默认60"},
"shell_no_output_timeout_seconds": map[string]interface{}{"type": "integer", "description": "execute/exec 连续无输出终止秒数"},
"workspace_root_dir": map[string]interface{}{"type": "string", "description": "会话工作目录根路径"},
"system_prompt_path": map[string]interface{}{"type": "string", "description": "单代理系统提示文件路径"},
},
},
"VisionConfig": map[string]interface{}{
"type": "object",
"description": "视觉分析(analyze_image MCP 工具);enabled 且 model 非空时注册工具",
@@ -1559,6 +1656,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
"conversationId": map[string]interface{}{"type": "string"},
"role": map[string]interface{}{"type": "string"},
"webshellConnectionId": map[string]interface{}{"type": "string"},
"finalization": finalizationRequestSchema,
},
"required": []string{"message"},
},
@@ -1566,7 +1664,14 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
},
},
"responses": map[string]interface{}{
"200": map[string]interface{}{"description": "成功,响应格式同 /api/eino-agent"},
"200": map[string]interface{}{
"description": "成功。只有 finalized=true 表示成功最终回复;finalized=false 时 response 为未完成/阻断说明。",
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{"$ref": "#/components/schemas/AgentChatResponse"},
},
},
},
"400": map[string]interface{}{"description": "参数错误"},
"401": map[string]interface{}{"description": "未授权"},
"500": map[string]interface{}{"description": "执行失败"},
@@ -1577,7 +1682,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
"post": map[string]interface{}{
"tags": []string{"对话交互"},
"summary": "发送消息并获取 AI 回复(Eino ADK 单代理,SSE",
"description": "向 AI 发送消息并获取流式回复(SSE)。由 Eino **单代理** ADK 执行;事件类型与多代理流式一致(含 `tool_call` / `response_delta` / `thinking` 等)。**不依赖** `multi_agent.enabled`。",
"description": "向 AI 发送消息并获取流式回复(SSE)。由 Eino **单代理** ADK 执行;事件类型与多代理流式一致(含 `tool_call` / `response_delta` / `thinking` 等)。`response_start` / `response_delta` 仅为候选/过程输出;只有 `type: response` 且 `data.finalized=true` 才表示成功最终回复。缺 completed 执行证据时可能先发送 `finalization_auto_continue`,表示服务端基于已有 trace 无注入续跑。`data.finalized=false` 时 message 为未完成/阻断说明。**不依赖** `multi_agent.enabled`。",
"operationId": "sendMessageEinoSingleAgentStream",
"requestBody": map[string]interface{}{
"required": true,
@@ -1590,6 +1695,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
"conversationId": map[string]interface{}{"type": "string"},
"role": map[string]interface{}{"type": "string"},
"webshellConnectionId": map[string]interface{}{"type": "string"},
"finalization": finalizationRequestSchema,
},
"required": []string{"message"},
},
@@ -1603,7 +1709,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
"text/event-stream": map[string]interface{}{
"schema": map[string]interface{}{
"type": "string",
"description": "SSE 流",
"description": "SSE 流。终态 response 事件 data 包含 finalized、finalizable、status、completionReason、evidenceVerified、evidenceRefs、pendingExecutionIds、missingChecks;过程事件可能包含 finalization_auto_continue。",
},
},
},
@@ -1641,6 +1747,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
"type": "string",
"description": "WebShell 连接 ID(可选,与 Eino 单/多代理流式行为一致)",
},
"finalization": finalizationRequestSchema,
"orchestration": map[string]interface{}{
"type": "string",
"description": "Eino 预置编排:deep | plan_execute | supervisor;缺省 deep",
@@ -1654,7 +1761,12 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
},
"responses": map[string]interface{}{
"200": map[string]interface{}{
"description": "成功,响应格式同 /api/eino-agent",
"description": "成功。只有 finalized=true 表示成功最终回复;finalized=false 时 response 为未完成/阻断说明。",
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{"$ref": "#/components/schemas/AgentChatResponse"},
},
},
},
"400": map[string]interface{}{"description": "参数错误"},
"401": map[string]interface{}{"description": "未授权"},
@@ -1667,7 +1779,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
"post": map[string]interface{}{
"tags": []string{"对话交互"},
"summary": "发送消息并获取 AI 回复(Eino 多代理,SSE",
"description": "与 `POST /api/eino-agent/stream` 类似;由 Eino 多代理执行。`orchestration` 指定 deep / plan_execute / supervisor,缺省 deep。**前提**`multi_agent.enabled: true`;未启用时 SSE 内首条为 `type: error` 后接 `done`。支持 `webshellConnectionId`。",
"description": "与 `POST /api/eino-agent/stream` 类似;由 Eino 多代理执行。`orchestration` 指定 deep / plan_execute / supervisor,缺省 deep。`response_start` / `response_delta` 仅为候选/过程输出;只有 `type: response` 且 `data.finalized=true` 才表示成功最终回复。缺 completed 执行证据时可能先发送 `finalization_auto_continue`,表示服务端基于已有 trace 无注入续跑。**前提**`multi_agent.enabled: true`;未启用时 SSE 内首条为 `type: error` 后接 `done`。支持 `webshellConnectionId`。",
"operationId": "sendMessageMultiAgentStream",
"requestBody": map[string]interface{}{
"required": true,
@@ -1680,6 +1792,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
"conversationId": map[string]interface{}{"type": "string"},
"role": map[string]interface{}{"type": "string"},
"webshellConnectionId": map[string]interface{}{"type": "string"},
"finalization": finalizationRequestSchema,
"orchestration": map[string]interface{}{
"type": "string",
"description": "deep | plan_execute | supervisor;缺省 deep",
@@ -1698,7 +1811,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
"text/event-stream": map[string]interface{}{
"schema": map[string]interface{}{
"type": "string",
"description": "SSE 流",
"description": "SSE 流。终态 response 事件 data 包含 finalized、finalizable、status、completionReason、evidenceVerified、evidenceRefs、pendingExecutionIds、missingChecks;过程事件可能包含 finalization_auto_continue。",
},
},
},
@@ -3483,7 +3596,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
"description": "状态筛选",
"schema": map[string]interface{}{
"type": "string",
"enum": []string{"success", "failed", "running"},
"enum": []string{"queued", "running", "completed", "failed", "cancelled", "hard_timeout", "orphaned"},
},
},
{
@@ -5776,10 +5889,15 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
"get": map[string]interface{}{
"tags": []string{"对话附件"},
"summary": "列出附件",
"description": "获取对话附件文件列表,可按对话ID过滤。",
"description": "获取对话文件列表,包含手动上传附件、工具输出和会话产物,可按会话、项目、来源、文件名搜索和分页过滤。",
"operationId": "listChatUploads",
"parameters": []map[string]interface{}{
{"name": "conversation", "in": "query", "required": false, "description": "按对话ID过滤", "schema": map[string]interface{}{"type": "string"}},
{"name": "project", "in": "query", "required": false, "description": "按项目ID过滤", "schema": map[string]interface{}{"type": "string"}},
{"name": "source", "in": "query", "required": false, "description": "按来源过滤:upload/reduction/workspace/conversation_artifact/all", "schema": map[string]interface{}{"type": "string", "enum": []string{"all", "upload", "reduction", "workspace", "conversation_artifact"}}},
{"name": "search", "in": "query", "required": false, "description": "按文件名或子路径搜索", "schema": map[string]interface{}{"type": "string"}},
{"name": "page", "in": "query", "required": false, "description": "页码,从1开始", "schema": map[string]interface{}{"type": "integer", "default": 1}},
{"name": "pageSize", "in": "query", "required": false, "description": "每页数量,传 all 返回全部", "schema": map[string]interface{}{"oneOf": []map[string]interface{}{{"type": "integer"}, {"type": "string", "enum": []string{"all"}}}}},
},
"responses": map[string]interface{}{
"200": map[string]interface{}{
@@ -5794,18 +5912,27 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
"items": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"relativePath": map[string]interface{}{"type": "string"},
"absolutePath": map[string]interface{}{"type": "string"},
"name": map[string]interface{}{"type": "string"},
"size": map[string]interface{}{"type": "integer"},
"modifiedUnix": map[string]interface{}{"type": "integer"},
"date": map[string]interface{}{"type": "string"},
"conversationId": map[string]interface{}{"type": "string"},
"subPath": map[string]interface{}{"type": "string"},
"relativePath": map[string]interface{}{"type": "string"},
"absolutePath": map[string]interface{}{"type": "string"},
"name": map[string]interface{}{"type": "string"},
"size": map[string]interface{}{"type": "integer"},
"modifiedUnix": map[string]interface{}{"type": "integer"},
"date": map[string]interface{}{"type": "string"},
"conversationId": map[string]interface{}{"type": "string"},
"conversationTitle": map[string]interface{}{"type": "string"},
"projectId": map[string]interface{}{"type": "string"},
"projectName": map[string]interface{}{"type": "string"},
"subPath": map[string]interface{}{"type": "string"},
"source": map[string]interface{}{"type": "string", "description": "upload/reduction/workspace/conversation_artifact"},
},
},
},
"folders": map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "string"}},
"total": map[string]interface{}{"type": "integer"},
"page": map[string]interface{}{"type": "integer"},
"pageSize": map[string]interface{}{
"type": "integer",
},
},
},
},
@@ -5880,6 +6007,31 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
},
},
},
"/api/chat-uploads/export": map[string]interface{}{
"get": map[string]interface{}{
"tags": []string{"对话附件"},
"summary": "导出附件",
"description": "按当前过滤条件导出对话文件 ZIP,包含 manifest.json。",
"operationId": "exportChatUploads",
"parameters": []map[string]interface{}{
{"name": "conversation", "in": "query", "required": false, "description": "按对话ID过滤", "schema": map[string]interface{}{"type": "string"}},
{"name": "project", "in": "query", "required": false, "description": "按项目ID过滤", "schema": map[string]interface{}{"type": "string"}},
{"name": "source", "in": "query", "required": false, "description": "按来源过滤:upload/reduction/workspace/conversation_artifact/all", "schema": map[string]interface{}{"type": "string", "enum": []string{"all", "upload", "reduction", "workspace", "conversation_artifact"}}},
{"name": "search", "in": "query", "required": false, "description": "按文件名或子路径搜索", "schema": map[string]interface{}{"type": "string"}},
},
"responses": map[string]interface{}{
"200": map[string]interface{}{
"description": "ZIP文件下载",
"content": map[string]interface{}{
"application/zip": map[string]interface{}{
"schema": map[string]interface{}{"type": "string", "format": "binary"},
},
},
},
"401": map[string]interface{}{"description": "未授权"},
},
},
},
"/api/chat-uploads/download": map[string]interface{}{
"get": map[string]interface{}{
"tags": []string{"对话附件"},
@@ -5903,6 +6055,37 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
},
},
},
"/api/chat-uploads/path": map[string]interface{}{
"get": map[string]interface{}{
"tags": []string{"对话附件"},
"summary": "解析附件路径",
"description": "将文件管理中的相对路径或内部虚拟路径解析为服务器绝对路径,用于复制文件/目录路径。",
"operationId": "resolveChatUploadPath",
"parameters": []map[string]interface{}{
{"name": "path", "in": "query", "required": true, "description": "相对路径或虚拟路径(如 __workspace__/projects/<id>/csv", "schema": map[string]interface{}{"type": "string"}},
{"name": "kind", "in": "query", "required": false, "description": "路径类型:file/directory,默认 file", "schema": map[string]interface{}{"type": "string", "enum": []string{"file", "directory"}}},
},
"responses": map[string]interface{}{
"200": map[string]interface{}{
"description": "解析成功",
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"absolutePath": map[string]interface{}{"type": "string"},
"isDir": map[string]interface{}{"type": "boolean"},
},
},
},
},
},
"401": map[string]interface{}{"description": "未授权"},
"403": map[string]interface{}{"description": "无权访问"},
"404": map[string]interface{}{"description": "路径不存在"},
},
},
},
"/api/chat-uploads/content": map[string]interface{}{
"get": map[string]interface{}{
"tags": []string{"对话附件"},
+1 -1
View File
@@ -61,7 +61,7 @@ var apiDocI18nSummaryToKey = map[string]string{
"获取连接状态": "getWebshellConnectionState", "保存连接状态": "saveWebshellConnectionState",
"获取AI对话历史": "getWebshellAIHistory", "列出AI对话": "listWebshellAIConversations",
"执行WebShell命令": "webshellExec", "WebShell文件操作": "webshellFileOp",
"列出附件": "listChatUploads", "上传附件": "uploadChatFile", "删除附件": "deleteChatUpload",
"列出附件": "listChatUploads", "导出附件": "exportChatUploads", "上传附件": "uploadChatFile", "删除附件": "deleteChatUpload",
"下载附件": "downloadChatUpload", "获取附件文本内容": "getChatUploadContent",
"写入附件文本内容": "putChatUploadContent", "创建附件目录": "mkdirChatUpload", "重命名附件": "renameChatUpload",
"企业微信回调验证": "wecomCallbackVerify", "企业微信消息回调": "wecomCallbackMessage",
+127
View File
@@ -6,6 +6,8 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"testing"
"time"
@@ -146,6 +148,131 @@ func TestChatUploadPathAuthorizationFollowsConversationAccess(t *testing.T) {
}
}
func TestChatUploadsListIncludesAuthorizedProjectWorkspaceFiles(t *testing.T) {
db, user := setupConversationRBACTest(t)
fsBase := t.TempDir()
workspaceBase := filepath.Join(fsBase, "workspace")
reductionBase := filepath.Join(fsBase, "reduction")
db.SetEinoConversationDirs("", "", reductionBase, workspaceBase)
allowedProject, _ := db.CreateProject(&database.Project{Name: "allowed"})
hiddenProject, _ := db.CreateProject(&database.Project{Name: "hidden"})
conversation, _ := db.CreateConversation("project conversation", database.ConversationCreateMeta{ProjectID: allowedProject.ID})
if err := db.AssignResourceToUser(user.ID, "project", allowedProject.ID); err != nil {
t.Fatal(err)
}
allowedFile := filepath.Join(workspaceBase, "projects", allowedProject.ID, "csv", "assets.csv")
hiddenFile := filepath.Join(workspaceBase, "projects", hiddenProject.ID, "csv", "secret.csv")
for _, path := range []string{allowedFile, hiddenFile} {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("name\nexample\n"), 0o644); err != nil {
t.Fatal(err)
}
}
h := NewChatUploadsHandler(zap.NewNop(), db)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodGet, "/api/chat-uploads?source=workspace&pageSize=all&conversation="+conversation.ID, nil)
c.Set(security.ContextSessionKey, security.Session{UserID: user.ID, Scope: database.RBACScopeAssigned})
h.List(c)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200: %s", w.Code, w.Body.String())
}
var response struct {
Files []ChatUploadFileItem `json:"files"`
Total int `json:"total"`
}
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
if response.Total != 1 || len(response.Files) != 1 {
t.Fatalf("files = %#v, total = %d, want only authorized workspace file", response.Files, response.Total)
}
got := response.Files[0]
if got.Source != chatUploadSourceWorkspace || got.Name != "assets.csv" || got.ProjectID != allowedProject.ID {
t.Fatalf("workspace file = %#v", got)
}
if got.ProjectName != allowedProject.Name {
t.Fatalf("projectName = %q, want %q", got.ProjectName, allowedProject.Name)
}
if got.ConversationID != conversation.ID {
t.Fatalf("conversationId = %q, want %q", got.ConversationID, conversation.ID)
}
if got.ConversationTitle != conversation.Title {
t.Fatalf("conversationTitle = %q, want %q", got.ConversationTitle, conversation.Title)
}
if got.AbsolutePath != allowedFile {
t.Fatalf("absolutePath = %q, want %q", got.AbsolutePath, allowedFile)
}
w = httptest.NewRecorder()
c, _ = gin.CreateTestContext(w)
resolveURL := "/api/chat-uploads/path?kind=directory&path=__workspace__%2Fprojects%2F" + allowedProject.ID
c.Request = httptest.NewRequest(http.MethodGet, resolveURL, nil)
c.Set(security.ContextSessionKey, security.Session{UserID: user.ID, Scope: database.RBACScopeAssigned})
h.ResolvePath(c)
if w.Code != http.StatusOK {
t.Fatalf("resolve status = %d, want 200: %s", w.Code, w.Body.String())
}
var resolved struct {
AbsolutePath string `json:"absolutePath"`
IsDir bool `json:"isDir"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resolved); err != nil {
t.Fatal(err)
}
wantDir := filepath.Join(workspaceBase, "projects", allowedProject.ID)
if !resolved.IsDir || resolved.AbsolutePath != wantDir {
t.Fatalf("resolved = %#v, want dir %q", resolved, wantDir)
}
w = httptest.NewRecorder()
c, _ = gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodGet, "/api/chat-uploads/path?kind=directory&path=__workspace__%2Fprojects", nil)
c.Set(security.ContextSessionKey, security.Session{UserID: user.ID, Scope: database.RBACScopeAssigned})
h.ResolvePath(c)
if w.Code != http.StatusOK {
t.Fatalf("resolve projects container status = %d, want 200: %s", w.Code, w.Body.String())
}
if err := json.Unmarshal(w.Body.Bytes(), &resolved); err != nil {
t.Fatal(err)
}
wantContainer := filepath.Join(workspaceBase, "projects")
if !resolved.IsDir || resolved.AbsolutePath != wantContainer {
t.Fatalf("resolved container = %#v, want dir %q", resolved, wantContainer)
}
for _, tc := range []struct {
path string
want string
}{
{"__workspace__/", workspaceBase},
{"__reduction__/", reductionBase},
{"__conversation_artifact__/", db.ConversationArtifactsBaseDir()},
} {
if err := os.MkdirAll(tc.want, 0o755); err != nil {
t.Fatal(err)
}
w = httptest.NewRecorder()
c, _ = gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodGet, "/api/chat-uploads/path?kind=directory&path="+url.QueryEscape(tc.path), nil)
c.Set(security.ContextSessionKey, security.Session{UserID: user.ID, Scope: database.RBACScopeAssigned})
h.ResolvePath(c)
if w.Code != http.StatusOK {
t.Fatalf("resolve root %q status = %d, want 200: %s", tc.path, w.Code, w.Body.String())
}
if err := json.Unmarshal(w.Body.Bytes(), &resolved); err != nil {
t.Fatal(err)
}
wantAbs, _ := filepath.Abs(tc.want)
if !resolved.IsDir || resolved.AbsolutePath != wantAbs {
t.Fatalf("resolved root %q = %#v, want dir %q", tc.path, resolved, wantAbs)
}
}
}
func TestPrepareMultiAgentSessionRejectsForeignConversation(t *testing.T) {
db, user := setupConversationRBACTest(t)
hidden, _ := db.CreateConversation("hidden", database.ConversationCreateMeta{})
+10 -2
View File
@@ -252,7 +252,7 @@ type AgentTaskManager struct {
maxHistorySize int // 最大历史记录数
historyRetention time.Duration // 历史记录保留时间
eventBus *TaskEventBus // 可选:任务结束时关闭镜像 SSE 订阅
// toolCanceler 在用户整轮停止任务时终止当前 MCP 工具(非「中断并继续」)。
// toolCanceler 在用户整轮停止任务或会话结束时终止该会话仍在运行的 MCP 工具(非「中断并继续」)。
toolCanceler func(conversationID string)
}
@@ -284,7 +284,7 @@ func (m *AgentTaskManager) SetTaskEventBus(b *TaskEventBus) {
m.eventBus = b
}
// SetToolCanceler 设置整轮停止任务时终止当前 MCP 工具的回调(由 AgentHandler 注入)。
// SetToolCanceler 设置整轮停止任务/会话结束时终止仍在运行 MCP 工具的回调(由 AgentHandler 注入)。
func (m *AgentTaskManager) SetToolCanceler(fn func(conversationID string)) {
m.mu.Lock()
defer m.mu.Unlock()
@@ -444,6 +444,8 @@ func (m *AgentTaskManager) FinishTask(conversationID string, finalStatus string)
if finalStatus != "" {
task.Status = finalStatus
}
toolCanceler := m.toolCanceler
activeEinoExecuteCancel := task.activeEinoExecuteCancel
// 保存到历史记录
completedTask := &CompletedTask{
@@ -464,6 +466,12 @@ func (m *AgentTaskManager) FinishTask(conversationID string, finalStatus string)
delete(m.tasks, conversationID)
bus := m.eventBus
m.mu.Unlock()
if toolCanceler != nil {
toolCanceler(conversationID)
}
if activeEinoExecuteCancel != nil {
activeEinoExecuteCancel()
}
if bus != nil {
bus.CloseConversation(conversationID)
}
@@ -78,3 +78,23 @@ func TestCancelTaskDefaultCauseIsTaskCancelled(t *testing.T) {
t.Fatalf("expected tool canceler path for default cancel cause")
}
}
func TestFinishTaskInvokesToolCancelerOnSessionEnd(t *testing.T) {
tm := NewAgentTaskManager()
calls := 0
tm.SetToolCanceler(func(conversationID string) {
if conversationID == "conv-3" {
calls++
}
})
_, cancel := context.WithCancelCause(context.Background())
if _, err := tm.StartTask("conv-3", "hello", cancel); err != nil {
t.Fatalf("StartTask: %v", err)
}
tm.FinishTask("conv-3", "completed")
if calls != 1 {
t.Fatalf("expected one tool cleanup on FinishTask, got %d", calls)
}
}
+77 -36
View File
@@ -352,26 +352,28 @@ func NewWebShellHandler(logger *zap.Logger, db *database.DB) *WebShellHandler {
// CreateConnectionRequest 创建连接请求
type CreateConnectionRequest struct {
URL string `json:"url" binding:"required"`
Password string `json:"password"`
Type string `json:"type"`
Method string `json:"method"`
CmdParam string `json:"cmd_param"`
Remark string `json:"remark"`
Encoding string `json:"encoding"`
OS string `json:"os"`
ProjectID string `json:"project_id"`
URL string `json:"url" binding:"required"`
Password string `json:"password"`
Type string `json:"type"`
Method string `json:"method"`
CmdParam string `json:"cmd_param"`
Remark string `json:"remark"`
Encoding string `json:"encoding"`
OS string `json:"os"`
}
// UpdateConnectionRequest 更新连接请求
type UpdateConnectionRequest struct {
URL string `json:"url" binding:"required"`
Password string `json:"password"`
Type string `json:"type"`
Method string `json:"method"`
CmdParam string `json:"cmd_param"`
Remark string `json:"remark"`
Encoding string `json:"encoding"`
OS string `json:"os"`
ProjectID string `json:"project_id"`
URL string `json:"url" binding:"required"`
Password string `json:"password"`
Type string `json:"type"`
Method string `json:"method"`
CmdParam string `json:"cmd_param"`
Remark string `json:"remark"`
Encoding string `json:"encoding"`
OS string `json:"os"`
}
// ListConnections 列出所有 WebShell 连接(GET /api/webshell/connections
@@ -381,7 +383,7 @@ func (h *WebShellHandler) ListConnections(c *gin.Context) {
return
}
session, _ := security.CurrentSession(c)
list, err := h.db.ListWebshellConnectionsForAccess(session.UserID, session.Scope)
list, err := h.db.ListWebshellConnectionsForAccess(session.UserID, session.Scope, c.Query("project_id"))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -412,6 +414,11 @@ func (h *WebShellHandler) CreateConnection(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid url"})
return
}
projectID := strings.TrimSpace(req.ProjectID)
if !h.canAccessProject(c, projectID) {
c.JSON(http.StatusForbidden, gin.H{"error": "project access denied"})
return
}
method := strings.ToLower(strings.TrimSpace(req.Method))
if method != "get" && method != "post" {
method = "post"
@@ -422,6 +429,7 @@ func (h *WebShellHandler) CreateConnection(c *gin.Context) {
}
conn := &database.WebShellConnection{
ID: "ws_" + strings.ReplaceAll(uuid.New().String(), "-", "")[:12],
ProjectID: projectID,
URL: req.URL,
Password: strings.TrimSpace(req.Password),
Type: shellType,
@@ -477,6 +485,11 @@ func (h *WebShellHandler) UpdateConnection(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid url"})
return
}
projectID := strings.TrimSpace(req.ProjectID)
if !h.canAccessProject(c, projectID) {
c.JSON(http.StatusForbidden, gin.H{"error": "project access denied"})
return
}
method := strings.ToLower(strings.TrimSpace(req.Method))
if method != "get" && method != "post" {
method = "post"
@@ -486,15 +499,16 @@ func (h *WebShellHandler) UpdateConnection(c *gin.Context) {
shellType = "php"
}
conn := &database.WebShellConnection{
ID: id,
URL: req.URL,
Password: strings.TrimSpace(req.Password),
Type: shellType,
Method: method,
CmdParam: strings.TrimSpace(req.CmdParam),
Remark: strings.TrimSpace(req.Remark),
Encoding: normalizeWebshellEncoding(req.Encoding),
OS: normalizeWebshellOS(req.OS),
ID: id,
ProjectID: projectID,
URL: req.URL,
Password: strings.TrimSpace(req.Password),
Type: shellType,
Method: method,
CmdParam: strings.TrimSpace(req.CmdParam),
Remark: strings.TrimSpace(req.Remark),
Encoding: normalizeWebshellEncoding(req.Encoding),
OS: normalizeWebshellOS(req.OS),
}
if err := h.db.UpdateWebshellConnection(conn); err != nil {
if err == sql.ErrNoRows {
@@ -721,15 +735,22 @@ func (h *WebShellHandler) Exec(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "url and command are required"})
return
}
conn, allowed := h.authorizedWebshellConnection(c, req.ConnectionID, req.URL)
if !allowed {
// Pre-save connectivity tests send form credentials without connection_id.
// Saved connections must go through resource ACL; DB credentials are authoritative.
if cid := strings.TrimSpace(req.ConnectionID); cid != "" {
conn, allowed := h.authorizedWebshellConnection(c, cid, req.URL)
if !allowed {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
return
}
// Never let a caller pair an authorized ID with attacker-controlled
// transport credentials or a URL.
req.URL, req.Password, req.Type = conn.URL, conn.Password, conn.Type
req.Method, req.CmdParam, req.Encoding = conn.Method, conn.CmdParam, conn.Encoding
} else if !security.SessionHasPermission(c, "webshell:write") {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
return
}
// The database record is authoritative. Never let a caller pair an
// authorized ID with attacker-controlled transport credentials or a URL.
req.URL, req.Password, req.Type = conn.URL, conn.Password, conn.Type
req.Method, req.CmdParam, req.Encoding = conn.Method, conn.CmdParam, conn.Encoding
parsed, err := url.Parse(req.URL)
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") {
@@ -823,13 +844,18 @@ func (h *WebShellHandler) FileOp(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "url and action are required"})
return
}
conn, allowed := h.authorizedWebshellConnection(c, req.ConnectionID, req.URL)
if !allowed {
if cid := strings.TrimSpace(req.ConnectionID); cid != "" {
conn, allowed := h.authorizedWebshellConnection(c, cid, req.URL)
if !allowed {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
return
}
req.URL, req.Password, req.Type = conn.URL, conn.Password, conn.Type
req.Method, req.CmdParam, req.Encoding, req.OS = conn.Method, conn.CmdParam, conn.Encoding, conn.OS
} else if !security.SessionHasPermission(c, "webshell:write") {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
return
}
req.URL, req.Password, req.Type = conn.URL, conn.Password, conn.Type
req.Method, req.CmdParam, req.Encoding, req.OS = conn.Method, conn.CmdParam, conn.Encoding, conn.OS
parsed, err := url.Parse(req.URL)
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") {
@@ -928,6 +954,21 @@ func (h *WebShellHandler) authorizedWebshellConnection(c *gin.Context, connectio
return conn, true
}
func (h *WebShellHandler) canAccessProject(c *gin.Context, projectID string) bool {
projectID = strings.TrimSpace(projectID)
if projectID == "" || h.db == nil {
return true
}
session, ok := security.CurrentSession(c)
if !ok {
return false
}
if session.Scope == database.RBACScopeAll {
return true
}
return h.db.UserCanAccessResource(session.UserID, session.Scope, "project", projectID)
}
// ExecWithConnection 在指定 WebShell 连接上执行命令(供 MCP/Agent 等非 HTTP 调用)
func (h *WebShellHandler) ExecWithConnection(conn *database.WebShellConnection, command string) (output string, ok bool, errMsg string) {
if conn == nil {
+35 -3
View File
@@ -40,15 +40,47 @@ func TestWebshellExecRequiresConnectionAccessWhenConnectionIDProvided(t *testing
}
}
func TestWebshellExecRejectsAdHocURLWithoutConnectionID(t *testing.T) {
func TestWebshellExecAllowsAdHocURLWithoutConnectionID(t *testing.T) {
gin.SetMode(gin.TestMode)
_, user, _, _ := setupWebshellRBACTest(t)
handler := NewWebShellHandler(zap.NewNop(), nil)
// Ad-hoc probe (connectivity test before save) must not be rejected as "无权访问".
// The target URL will fail to connect; we only assert auth allows the request through.
w := performWebshellJSON(user, http.MethodPost, "/api/webshell/exec", map[string]interface{}{
"url": "http://127.0.0.1/admin", "command": "id",
"url": "http://127.0.0.1:1/admin", "command": "id",
}, handler.Exec)
if w.Code == http.StatusForbidden {
t.Fatalf("ad-hoc URL status = %d, want non-403: %s", w.Code, w.Body.String())
}
var resp ExecResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v body=%s", err, w.Body.String())
}
if resp.OK {
t.Fatalf("expected connection failure for closed port, got ok=true")
}
}
func TestWebshellExecRejectsAdHocWithoutWritePermission(t *testing.T) {
gin.SetMode(gin.TestMode)
user := &database.RBACUser{ID: "u_ro", Username: "readonly"}
handler := NewWebShellHandler(zap.NewNop(), nil)
payload, _ := json.Marshal(map[string]interface{}{
"url": "http://127.0.0.1/admin", "command": "id",
})
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPost, "/api/webshell/exec", bytes.NewReader(payload))
c.Request.Header.Set("Content-Type", "application/json")
c.Set(security.ContextSessionKey, security.Session{
UserID: user.ID,
Username: user.Username,
Permissions: map[string]bool{"webshell:read": true},
Scope: database.RBACScopeAssigned,
})
handler.Exec(c)
if w.Code != http.StatusForbidden {
t.Fatalf("ad-hoc URL status = %d, want %d: %s", w.Code, http.StatusForbidden, w.Body.String())
t.Fatalf("status = %d, want %d: %s", w.Code, http.StatusForbidden, w.Body.String())
}
}
+51 -14
View File
@@ -152,20 +152,37 @@ func (h *AgentHandler) runRoleWorkflowStreamIfBound(
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
return true
}
if prep.AssistantMessageID != "" {
_ = h.db.UpdateAssistantMessageFinalize(prep.AssistantMessageID, result.Response, nil, "")
decision := h.finalizeCandidateForDeliveryWithPolicy(
prep.ConversationID,
prep.AssistantMessageID,
"workflow",
result.Response,
nil,
result.AwaitingHITL,
"",
true,
)
responseText := decision.FinalText
if !decision.Finalizable {
responseText = finalizationBlockedMessage(decision)
taskStatus = decision.Status
h.tasks.UpdateTaskStatus(conversationID, taskStatus)
sendEvent("finalization_check", responseText, decision)
}
payload := map[string]interface{}{
payload := finalizationResponsePayload(decision, map[string]interface{}{
"conversationId": prep.ConversationID,
"messageId": prep.AssistantMessageID,
"agentMode": "workflow",
"workflowRunId": result.RunID,
}
})
if result.AwaitingHITL {
payload["workflowStatus"] = "awaiting_hitl"
payload["awaitingHitl"] = true
} else {
payload["workflowStatus"] = result.Status
payload["awaitingHitl"] = false
}
sendEvent("response", result.Response, payload)
sendEvent("response", responseText, payload)
sendEvent("done", "", map[string]interface{}{"conversationId": prep.ConversationID})
return true
}
@@ -251,17 +268,37 @@ func (h *AgentHandler) runRoleWorkflowJSONIfBound(c *gin.Context, req *ChatReque
c.JSON(http.StatusInternalServerError, gin.H{"error": errMsg, "conversationId": conversationID})
return true
}
if prep.AssistantMessageID != "" {
_ = h.db.UpdateAssistantMessageFinalize(prep.AssistantMessageID, result.Response, nil, "")
decision := h.finalizeCandidateForDeliveryWithPolicy(
prep.ConversationID,
prep.AssistantMessageID,
"workflow",
result.Response,
nil,
result.AwaitingHITL,
"",
true,
)
responseText := decision.FinalText
if !decision.Finalizable {
responseText = finalizationBlockedMessage(decision)
taskStatus = decision.Status
}
c.JSON(http.StatusOK, gin.H{
"response": result.Response,
"conversationId": prep.ConversationID,
"assistantMessageId": prep.AssistantMessageID,
"agentMode": "workflow",
"workflowRunId": result.RunID,
"workflowStatus": result.Status,
"awaitingHitl": result.AwaitingHITL,
"response": responseText,
"conversationId": prep.ConversationID,
"assistantMessageId": prep.AssistantMessageID,
"agentMode": "workflow",
"workflowRunId": result.RunID,
"workflowStatus": result.Status,
"awaitingHitl": result.AwaitingHITL,
"finalized": decision.Finalized,
"finalizable": decision.Finalizable,
"status": decision.Status,
"completionReason": decision.CompletionReason,
"evidenceVerified": decision.EvidenceVerified,
"evidenceRefs": decision.EvidenceRefs,
"pendingExecutionIds": decision.PendingExecutionIDs,
"missingChecks": decision.MissingChecks,
})
return true
}
+11
View File
@@ -31,6 +31,11 @@ const (
// 视觉分析(本地图片 → VL 模型 → 文本摘要)
ToolAnalyzeImage = "analyze_image"
// 长耗时工具执行控制(后台 execution 查询/等待/取消)
ToolGetToolExecution = "get_tool_execution"
ToolWaitToolExecution = "wait_tool_execution"
ToolCancelToolExecution = "cancel_tool_execution"
// WebShell 助手工具(AI 在 WebShell 管理 - AI 助手 中使用)
ToolWebshellExec = "webshell_exec"
ToolWebshellFileList = "webshell_file_list"
@@ -91,6 +96,9 @@ func IsBuiltinTool(toolName string) bool {
ToolListKnowledgeRiskTypes,
ToolSearchKnowledgeBase,
ToolAnalyzeImage,
ToolGetToolExecution,
ToolWaitToolExecution,
ToolCancelToolExecution,
ToolWebshellExec,
ToolWebshellFileList,
ToolWebshellFileRead,
@@ -149,6 +157,9 @@ func GetAllBuiltinTools() []string {
ToolListKnowledgeRiskTypes,
ToolSearchKnowledgeBase,
ToolAnalyzeImage,
ToolGetToolExecution,
ToolWaitToolExecution,
ToolCancelToolExecution,
ToolWebshellExec,
ToolWebshellFileList,
ToolWebshellFileRead,
+296
View File
@@ -0,0 +1,296 @@
package mcp
import (
"context"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"time"
"cyberstrike-ai/internal/mcp/builtin"
)
const (
defaultExecutionWaitTimeout = 60 * time.Second
maxExecutionWaitTimeout = 10 * time.Minute
defaultPartialPreviewBytes = 4096
maxPartialPreviewBytes = 64 * 1024
)
// RegisterExecutionControlTools exposes execution handle operations to Eino as
// ordinary MCP tools. This keeps the agent loop native: the model calls a tool,
// receives a bounded result, and may call wait_tool_execution again if needed.
func RegisterExecutionControlTools(server *Server, external *ExternalMCPManager) {
if server == nil {
return
}
server.RegisterTool(Tool{
Name: builtin.ToolGetToolExecution,
Description: "查询后台工具 execution 的当前状态、结果和错误。用于外部 MCP 工具等待超时后,凭 execution_id 继续查看进度。",
ShortDescription: "查询后台工具执行状态",
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"execution_id": map[string]interface{}{"type": "string", "description": "工具执行 ID"},
"include_partial_output": map[string]interface{}{"type": "boolean", "description": "是否返回运行中已产生输出的尾部预览,默认 true"},
"partial_output_max_bytes": map[string]interface{}{"type": "number", "description": "partial_output 最多返回字节数,默认 4096,最大 65536"},
},
"required": []string{"execution_id"},
},
}, func(ctx context.Context, args map[string]interface{}) (*ToolResult, error) {
id := stringArg(args, "execution_id")
if id == "" {
return textToolResult("execution_id 必填", true), nil
}
exec := lookupToolExecution(server, external, id)
if exec == nil {
return textToolResult("未找到该 execution_id: "+id, true), nil
}
return textToolResult(formatExecutionForModel(exec, executionFormatOptionsFromArgs(args)), false), nil
})
server.RegisterTool(Tool{
Name: builtin.ToolWaitToolExecution,
Description: "继续等待一个后台工具 execution 完成。每次等待都有 timeout_seconds 上限;若仍未完成,会返回当前状态,模型可稍后再次调用。",
ShortDescription: "有界等待后台工具执行",
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"execution_id": map[string]interface{}{"type": "string", "description": "工具执行 ID"},
"timeout_seconds": map[string]interface{}{"type": "number", "description": "本次最多等待秒数,默认 60,最大 600"},
"include_partial_output": map[string]interface{}{"type": "boolean", "description": "是否返回运行中已产生输出的尾部预览,默认 true"},
"partial_output_max_bytes": map[string]interface{}{"type": "number", "description": "partial_output 最多返回字节数,默认 4096,最大 65536"},
},
"required": []string{"execution_id"},
},
}, func(ctx context.Context, args map[string]interface{}) (*ToolResult, error) {
id := stringArg(args, "execution_id")
if id == "" {
return textToolResult("execution_id 必填", true), nil
}
wait := durationSecondsArg(args, "timeout_seconds", defaultExecutionWaitTimeout, maxExecutionWaitTimeout)
snap, err := waitToolExecutionSnapshot(ctx, server, external, id, wait)
if err != nil && !errors.Is(err, ErrExecutionWaitTimeout) {
return textToolResult("等待 execution 失败: "+err.Error(), true), nil
}
if snap == nil || snap.Execution == nil {
return textToolResult("未找到该 execution_id: "+id, true), nil
}
body := formatExecutionForModel(snap.Execution, executionFormatOptionsFromArgs(args))
if errors.Is(err, ErrExecutionWaitTimeout) {
body += "\n\n本次等待已到达 timeout_seconds,上述 execution 仍未完成。可继续等待、取消,或采用其他步骤。"
}
return textToolResult(body, false), nil
})
server.RegisterTool(Tool{
Name: builtin.ToolCancelToolExecution,
Description: "取消一个后台工具 execution。用于外部 MCP 工具长时间运行、误调用或用户要求停止时。",
ShortDescription: "取消后台工具执行",
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"execution_id": map[string]interface{}{"type": "string", "description": "工具执行 ID"},
"reason": map[string]interface{}{"type": "string", "description": "取消原因,可选,会写入终止说明"},
},
"required": []string{"execution_id"},
},
}, func(ctx context.Context, args map[string]interface{}) (*ToolResult, error) {
id := stringArg(args, "execution_id")
if id == "" {
return textToolResult("execution_id 必填", true), nil
}
reason := stringArg(args, "reason")
if server.CancelToolExecutionWithNote(id, reason) {
return textToolResult("已请求取消内部工具 execution: "+id, false), nil
}
if external != nil && external.CancelToolExecutionWithNote(id, reason) {
return textToolResult("已请求取消外部 MCP execution: "+id, false), nil
}
return textToolResult("未找到进行中的 execution,或该 execution 已结束: "+id, true), nil
})
}
func waitToolExecutionSnapshot(ctx context.Context, server *Server, external *ExternalMCPManager, id string, wait time.Duration) (*ExecutionSnapshot, error) {
if server != nil && server.executionService != nil && server.executionService.getEntry(id) != nil {
return server.executionService.Wait(ctx, id, wait)
}
if external != nil && external.executionService != nil && external.executionService.getEntry(id) != nil {
return external.executionService.Wait(ctx, id, wait)
}
if server != nil && server.executionService != nil {
if snap, err := server.executionService.Get(id); err == nil {
return snap, nil
}
}
if external != nil && external.executionService != nil {
return external.executionService.Get(id)
}
exec := lookupToolExecution(server, external, id)
if exec == nil {
return nil, fmt.Errorf("execution not found: %s", id)
}
return &ExecutionSnapshot{Execution: exec}, nil
}
func lookupToolExecution(server *Server, external *ExternalMCPManager, id string) *ToolExecution {
if server != nil {
if exec, ok := server.GetExecution(id); ok && exec != nil {
return exec
}
}
if external != nil {
if exec, ok := external.GetExecution(id); ok && exec != nil {
return exec
}
}
return nil
}
type executionFormatOptions struct {
includePartialOutput bool
partialMaxBytes int
}
func executionFormatOptionsFromArgs(args map[string]interface{}) executionFormatOptions {
includePartial := true
if raw, ok := args["include_partial_output"]; ok {
if b, ok := raw.(bool); ok {
includePartial = b
} else if s := strings.TrimSpace(fmt.Sprint(raw)); s != "" {
includePartial = strings.EqualFold(s, "true") || s == "1" || strings.EqualFold(s, "yes")
}
}
maxBytes := intArg(args, "partial_output_max_bytes", defaultPartialPreviewBytes, maxPartialPreviewBytes)
return executionFormatOptions{includePartialOutput: includePartial, partialMaxBytes: maxBytes}
}
func formatExecutionForModel(exec *ToolExecution, opts executionFormatOptions) string {
if exec == nil {
return "execution: null"
}
payload := map[string]interface{}{
"execution_id": exec.ID,
"tool": exec.ToolName,
"status": exec.Status,
"started_at": exec.StartTime.Format(time.RFC3339),
}
if exec.EndTime != nil {
payload["ended_at"] = exec.EndTime.Format(time.RFC3339)
}
if exec.Duration > 0 {
payload["duration"] = exec.Duration.String()
}
if exec.Error != "" {
payload["error"] = exec.Error
}
if exec.Result != nil {
payload["result"] = ToolResultPlainText(exec.Result)
payload["is_error"] = exec.Result.IsError
}
if opts.includePartialOutput && exec.PartialOutput != "" {
partial := tailStringBytes(exec.PartialOutput, opts.partialMaxBytes)
payload["partial_output"] = partial
payload["partial_output_bytes"] = exec.PartialOutputBytes
payload["partial_output_truncated"] = exec.PartialOutputTruncated || len([]byte(partial)) < len([]byte(exec.PartialOutput))
if exec.PartialOutputUpdatedAt != nil {
payload["partial_output_updated_at"] = exec.PartialOutputUpdatedAt.Format(time.RFC3339)
}
}
b, err := json.MarshalIndent(payload, "", " ")
if err != nil {
return fmt.Sprintf("execution_id: %s\nstatus: %s\nerror: %s", exec.ID, exec.Status, exec.Error)
}
return string(b)
}
func tailStringBytes(s string, maxBytes int) string {
if maxBytes <= 0 {
maxBytes = defaultPartialPreviewBytes
}
b := []byte(s)
if len(b) <= maxBytes {
return s
}
return string(b[len(b)-maxBytes:])
}
func textToolResult(text string, isErr bool) *ToolResult {
return &ToolResult{Content: []Content{{Type: "text", Text: text}}, IsError: isErr}
}
func stringArg(args map[string]interface{}, key string) string {
if args == nil {
return ""
}
raw, ok := args[key]
if !ok || raw == nil {
return ""
}
switch v := raw.(type) {
case string:
return strings.TrimSpace(v)
default:
return strings.TrimSpace(fmt.Sprint(v))
}
}
func durationSecondsArg(args map[string]interface{}, key string, def, max time.Duration) time.Duration {
if args == nil {
return def
}
var seconds float64
switch v := args[key].(type) {
case int:
seconds = float64(v)
case int64:
seconds = float64(v)
case float64:
seconds = v
case json.Number:
f, _ := v.Float64()
seconds = f
case string:
f, _ := strconv.ParseFloat(strings.TrimSpace(v), 64)
seconds = f
}
if seconds <= 0 {
return def
}
d := time.Duration(seconds * float64(time.Second))
if max > 0 && d > max {
return max
}
return d
}
func intArg(args map[string]interface{}, key string, def, max int) int {
if args == nil {
return def
}
var n int
switch v := args[key].(type) {
case int:
n = v
case int64:
n = int(v)
case float64:
n = int(v)
case json.Number:
i, _ := v.Int64()
n = int(i)
case string:
i, _ := strconv.Atoi(strings.TrimSpace(v))
n = i
}
if n <= 0 {
return def
}
if max > 0 && n > max {
return max
}
return n
}
+625
View File
@@ -0,0 +1,625 @@
package mcp
import (
"context"
"errors"
"fmt"
"strings"
"sync"
"time"
"cyberstrike-ai/internal/authctx"
"github.com/google/uuid"
"go.uber.org/zap"
)
const (
ToolExecutionStatusQueued = "queued"
ToolExecutionStatusRunning = "running"
ToolExecutionStatusCompleted = "completed"
ToolExecutionStatusFailed = "failed"
ToolExecutionStatusCancelled = "cancelled"
ToolExecutionStatusHardTimeout = "hard_timeout"
ToolExecutionStatusOrphaned = "orphaned"
)
var ErrExecutionWaitTimeout = errors.New("tool execution wait timeout")
// ExecutionRunFunc is the blocking operation owned by a worker.
type ExecutionRunFunc func(context.Context) (*ToolResult, error)
type ExecutionPreRunFunc func(context.Context, *ToolExecution) (func(), error)
// ExecutionDoneFunc observes the final persisted state. It is invoked once,
// including for late completions after an agent has stopped waiting.
type ExecutionDoneFunc func(*ToolExecution)
type ExecutionRequest struct {
ID string
ToolName string
Arguments map[string]interface{}
ConversationID string
OwnerUserID string
HardTimeout time.Duration
PreRun ExecutionPreRunFunc
Run ExecutionRunFunc
OnDone ExecutionDoneFunc
}
type ExecutionHandle struct {
ID string
}
type ExecutionSnapshot struct {
Execution *ToolExecution
}
type executionEntry struct {
exec *ToolExecution
cancel context.CancelFunc
done chan struct{}
preRun ExecutionPreRunFunc
run ExecutionRunFunc
result *ToolResult
err error
}
// ExecutionService keeps Eino-facing tool calls synchronous while moving the
// untrusted blocking work into cancellable workers with explicit execution IDs.
type ExecutionService struct {
storage MonitorStorage
logger *zap.Logger
mu sync.Mutex
entries map[string]*executionEntry
abortUserNotes map[string]string
maxInMemory int
resultMaxBytes int
spillRootDir string
}
func NewExecutionService(storage MonitorStorage, logger *zap.Logger) *ExecutionService {
if logger == nil {
logger = zap.NewNop()
}
return &ExecutionService{
storage: storage,
logger: logger,
entries: make(map[string]*executionEntry),
abortUserNotes: make(map[string]string),
maxInMemory: 1000,
resultMaxBytes: DefaultToolResultMaxBytes,
}
}
func (s *ExecutionService) ConfigureToolResultMaxBytes(maxBytes int) {
if s == nil {
return
}
s.mu.Lock()
defer s.mu.Unlock()
s.resultMaxBytes = maxBytes
}
// ConfigureToolResultSpillRoot sets the reduction-compatible root used when
// oversized tool results are spilled to local files (empty → tmp/reduction).
func (s *ExecutionService) ConfigureToolResultSpillRoot(rootDir string) {
if s == nil {
return
}
s.mu.Lock()
defer s.mu.Unlock()
s.spillRootDir = strings.TrimSpace(rootDir)
}
func (s *ExecutionService) Submit(ctx context.Context, req ExecutionRequest) (*ExecutionHandle, error) {
if s == nil {
return nil, fmt.Errorf("execution service is nil")
}
if req.Run == nil {
return nil, fmt.Errorf("execution run func is nil")
}
id := strings.TrimSpace(req.ID)
if id == "" {
id = uuid.New().String()
}
start := time.Now()
exec := &ToolExecution{
ID: id,
ToolName: strings.TrimSpace(req.ToolName),
Arguments: cloneArgsMap(req.Arguments),
Status: ToolExecutionStatusQueued,
StartTime: start,
ConversationID: strings.TrimSpace(req.ConversationID),
OwnerUserID: strings.TrimSpace(req.OwnerUserID),
}
if exec.ConversationID == "" {
exec.ConversationID = MCPConversationIDFromContext(ctx)
}
if exec.OwnerUserID == "" {
if principal, ok := authctx.PrincipalFromContext(ctx); ok {
exec.OwnerUserID = principal.UserID
}
}
runCtx := detachedExecutionContext(ctx)
var cancel context.CancelFunc
if req.HardTimeout > 0 {
runCtx, cancel = context.WithTimeout(runCtx, req.HardTimeout)
} else {
runCtx, cancel = context.WithCancel(runCtx)
}
entry := &executionEntry{exec: exec, cancel: cancel, done: make(chan struct{}), preRun: req.PreRun, run: req.Run}
s.mu.Lock()
if _, exists := s.entries[id]; exists {
s.mu.Unlock()
cancel()
return nil, fmt.Errorf("execution already exists: %s", id)
}
s.entries[id] = entry
s.cleanupOldEntriesLocked()
s.mu.Unlock()
if s.storage != nil {
if err := s.storage.SaveToolExecution(exec); err != nil {
s.logger.Warn("保存执行记录到数据库失败", zap.Error(err), zap.String("executionId", id))
}
}
notifyToolRunBegin(ctx, id)
go s.runWorker(runCtx, entry, req.OnDone)
return &ExecutionHandle{ID: id}, nil
}
func (s *ExecutionService) runWorker(ctx context.Context, entry *executionEntry, onDone ExecutionDoneFunc) {
id := entry.exec.ID
ctx = WithMCPExecutionID(ctx, id)
if conv := strings.TrimSpace(entry.exec.ConversationID); conv != "" {
ctx = WithMCPConversationID(ctx, conv)
}
var release func()
defer func() {
if release != nil {
release()
}
entry.cancel()
notifyToolRunEnd(ctx, id)
close(entry.done)
}()
if entry.preRun != nil {
var preErr error
release, preErr = entry.preRun(ctx, cloneToolExecution(entry.exec))
if preErr != nil {
s.finishEntry(ctx, entry, nil, preErr, onDone)
return
}
}
s.markEntryRunning(entry)
result, err := entryResultRecover(ctx, entry.exec.ToolName, s.logger, func() (*ToolResult, error) {
return nilSafeRun(ctx, entry)
})
s.finishEntry(ctx, entry, result, err, onDone)
}
func (s *ExecutionService) markEntryRunning(entry *executionEntry) {
if s == nil || entry == nil || entry.exec == nil {
return
}
s.mu.Lock()
if !isExecutionTerminal(entry.exec.Status) {
entry.exec.Status = ToolExecutionStatusRunning
}
runningExec := cloneToolExecution(entry.exec)
s.mu.Unlock()
if s.storage != nil {
if err := s.storage.SaveToolExecution(runningExec); err != nil {
s.logger.Warn("保存执行记录到数据库失败", zap.Error(err), zap.String("executionId", runningExec.ID))
}
}
}
func (s *ExecutionService) finishEntry(ctx context.Context, entry *executionEntry, result *ToolResult, err error, onDone ExecutionDoneFunc) {
id := entry.exec.ID
cancelledWithUserNote := s.applyAbortUserNoteToCancelledToolResult(id, &result, &err)
now := time.Now()
s.mu.Lock()
spill := ToolResultSpillConfig{
RootDir: s.spillRootDir,
ConversationID: entry.exec.ConversationID,
ExecutionID: id,
}
if ctx != nil {
if pid := MCPProjectIDFromContext(ctx); pid != "" {
spill.ProjectID = pid
}
if conv := MCPConversationIDFromContext(ctx); conv != "" {
spill.ConversationID = conv
}
}
result = NormalizeToolResultForStorageWithSpill(result, s.resultMaxBytes, spill)
entry.result = result
entry.err = err
entry.exec.EndTime = &now
entry.exec.Duration = now.Sub(entry.exec.StartTime)
if err != nil {
switch {
case errors.Is(err, context.DeadlineExceeded):
entry.exec.Status = ToolExecutionStatusHardTimeout
entry.exec.Error = "工具执行超过硬超时限制"
case errors.Is(err, context.Canceled):
entry.exec.Status = ToolExecutionStatusCancelled
entry.exec.Error = "已手动终止或任务已取消"
default:
entry.exec.Status = ToolExecutionStatusFailed
entry.exec.Error = err.Error()
}
} else if result != nil && result.IsError {
if cancelledWithUserNote {
entry.exec.Status = ToolExecutionStatusCancelled
entry.exec.Error = ""
} else if isBackgroundWaitToolResult(result) {
entry.exec.Status = ToolExecutionStatusCompleted
entry.exec.Error = ""
} else {
entry.exec.Status = ToolExecutionStatusFailed
entry.exec.Error = firstToolResultText(result, "工具执行返回错误结果")
}
entry.exec.Result = result
} else {
entry.exec.Status = ToolExecutionStatusCompleted
if result == nil {
result = &ToolResult{Content: []Content{{Type: "text", Text: "工具执行完成,但未返回结果"}}}
entry.result = result
}
entry.exec.Result = result
}
finalExec := cloneToolExecution(entry.exec)
s.mu.Unlock()
if s.storage != nil {
if saveErr := s.storage.SaveToolExecution(finalExec); saveErr != nil {
s.logger.Warn("保存执行记录到数据库失败", zap.Error(saveErr), zap.String("executionId", id))
}
}
if onDone != nil {
onDone(finalExec)
}
}
func nilSafeRun(ctx context.Context, entry *executionEntry) (*ToolResult, error) {
if entry == nil {
return nil, fmt.Errorf("execution entry is nil")
}
if entry.run == nil {
return nil, fmt.Errorf("execution run func not wired")
}
return entry.run(ctx)
}
func entryResultRecover(ctx context.Context, toolName string, logger *zap.Logger, fn func() (*ToolResult, error)) (res *ToolResult, err error) {
defer func() {
if r := recover(); r != nil {
if logger != nil {
logger.Error("tool execution worker panic recovered", zap.Any("recover", r), zap.String("toolName", toolName), zap.Stack("stack"))
}
err = fmt.Errorf("tool execution panic: %v", r)
}
}()
return fn()
}
func (s *ExecutionService) Wait(ctx context.Context, executionID string, timeout time.Duration) (*ExecutionSnapshot, error) {
entry := s.getEntry(executionID)
if entry == nil {
return s.getPersistedSnapshot(executionID)
}
if isExecutionTerminal(entry.exec.Status) {
return &ExecutionSnapshot{Execution: cloneToolExecution(entry.exec)}, nil
}
var timeoutCh <-chan time.Time
var timer *time.Timer
if timeout > 0 {
timer = time.NewTimer(timeout)
timeoutCh = timer.C
defer timer.Stop()
}
select {
case <-entry.done:
return &ExecutionSnapshot{Execution: cloneToolExecution(entry.exec)}, nil
case <-timeoutCh:
return &ExecutionSnapshot{Execution: cloneToolExecution(entry.exec)}, ErrExecutionWaitTimeout
case <-ctxDone(ctx):
return &ExecutionSnapshot{Execution: cloneToolExecution(entry.exec)}, ctx.Err()
}
}
func (s *ExecutionService) Get(executionID string) (*ExecutionSnapshot, error) {
entry := s.getEntry(executionID)
if entry != nil {
return &ExecutionSnapshot{Execution: cloneToolExecution(entry.exec)}, nil
}
return s.getPersistedSnapshot(executionID)
}
func (s *ExecutionService) AppendPartialOutput(executionID, chunk string) bool {
id := strings.TrimSpace(executionID)
if s == nil || id == "" || chunk == "" {
return false
}
now := time.Now()
s.mu.Lock()
defer s.mu.Unlock()
entry := s.entries[id]
if entry == nil || entry.exec == nil {
return false
}
appendPartialOutput(entry.exec, chunk, defaultPartialOutputMaxBytes, now)
return true
}
func (s *ExecutionService) Cancel(executionID, note string) bool {
id := strings.TrimSpace(executionID)
if id == "" || s == nil {
return false
}
s.mu.Lock()
entry := s.entries[id]
if entry == nil || isExecutionTerminal(entry.exec.Status) {
s.mu.Unlock()
return false
}
if strings.TrimSpace(note) != "" {
s.abortUserNotes[id] = strings.TrimSpace(note)
}
cancel := entry.cancel
s.mu.Unlock()
if cancel != nil {
cancel()
}
return true
}
func (s *ExecutionService) ActiveRunningExecutionIDs() map[string]struct{} {
if s == nil {
return nil
}
s.mu.Lock()
defer s.mu.Unlock()
out := make(map[string]struct{})
for id, entry := range s.entries {
if entry != nil && entry.exec != nil && !isExecutionTerminal(entry.exec.Status) {
out[id] = struct{}{}
}
}
if len(out) == 0 {
return nil
}
return out
}
func (s *ExecutionService) CancelAll(note string) {
if s == nil {
return
}
s.mu.Lock()
cancels := make([]context.CancelFunc, 0, len(s.entries))
for id, entry := range s.entries {
if entry == nil || isExecutionTerminal(entry.exec.Status) {
continue
}
if strings.TrimSpace(note) != "" {
s.abortUserNotes[id] = strings.TrimSpace(note)
}
if entry.cancel != nil {
cancels = append(cancels, entry.cancel)
}
}
s.mu.Unlock()
for _, cancel := range cancels {
cancel()
}
}
func (s *ExecutionService) getEntry(executionID string) *executionEntry {
if s == nil {
return nil
}
id := strings.TrimSpace(executionID)
if id == "" {
return nil
}
s.mu.Lock()
defer s.mu.Unlock()
return s.entries[id]
}
func (s *ExecutionService) getPersistedSnapshot(executionID string) (*ExecutionSnapshot, error) {
id := strings.TrimSpace(executionID)
if id == "" {
return nil, fmt.Errorf("execution_id is required")
}
if s != nil && s.storage != nil {
exec, err := s.storage.GetToolExecution(id)
if err == nil && exec != nil {
return &ExecutionSnapshot{Execution: exec}, nil
}
if err != nil {
return nil, err
}
}
return nil, fmt.Errorf("execution not found: %s", id)
}
func (s *ExecutionService) applyAbortUserNoteToCancelledToolResult(executionID string, result **ToolResult, err *error) (cancelledWithUserNote bool) {
note := strings.TrimSpace(s.takeAbortUserNote(executionID))
if note == "" {
return false
}
hasErr := err != nil && *err != nil
hasRes := result != nil && *result != nil
if !hasErr && !hasRes {
return false
}
partial := ""
if hasRes {
partial = ToolResultPlainText(*result)
}
if partial == "" && hasErr {
partial = (*err).Error()
}
merged := MergePartialToolOutputAndAbortNote(partial, note)
if err != nil {
*err = nil
}
if result != nil {
*result = &ToolResult{Content: []Content{{Type: "text", Text: merged}}, IsError: true}
}
return true
}
func (s *ExecutionService) takeAbortUserNote(id string) string {
s.mu.Lock()
defer s.mu.Unlock()
note := s.abortUserNotes[id]
delete(s.abortUserNotes, id)
return note
}
func (s *ExecutionService) cleanupOldEntriesLocked() {
if s.maxInMemory <= 0 || len(s.entries) <= s.maxInMemory {
return
}
type oldEntry struct {
id string
startTime time.Time
}
var terminal []oldEntry
for id, entry := range s.entries {
if entry != nil && entry.exec != nil && isExecutionTerminal(entry.exec.Status) {
terminal = append(terminal, oldEntry{id: id, startTime: entry.exec.StartTime})
}
}
for len(s.entries) > s.maxInMemory && len(terminal) > 0 {
oldest := 0
for i := 1; i < len(terminal); i++ {
if terminal[i].startTime.Before(terminal[oldest].startTime) {
oldest = i
}
}
delete(s.entries, terminal[oldest].id)
terminal = append(terminal[:oldest], terminal[oldest+1:]...)
}
}
func firstToolResultText(result *ToolResult, fallback string) string {
if result != nil {
for _, c := range result.Content {
if strings.TrimSpace(c.Text) != "" {
return c.Text
}
}
}
return fallback
}
func isBackgroundWaitToolResult(result *ToolResult) bool {
text := strings.ToLower(strings.TrimSpace(ToolResultPlainText(result)))
if text == "" {
return false
}
hasExecutionID := strings.Contains(text, "execution_id:") || strings.Contains(text, `"execution_id"`)
hasRunningStatus := strings.Contains(text, "status: running") || strings.Contains(text, "status: queued") ||
strings.Contains(text, `"status": "running"`) || strings.Contains(text, `"status":"running"`) ||
strings.Contains(text, `"status": "queued"`) || strings.Contains(text, `"status":"queued"`)
hasSoftWaitSignal := strings.Contains(text, "工具已提交到后台执行") ||
strings.Contains(text, "本次等待已到达") ||
strings.Contains(text, "wait_timeout:") ||
strings.Contains(text, "background execution") ||
strings.Contains(text, "still running") ||
strings.Contains(text, "仍未完成")
return hasExecutionID && hasRunningStatus && hasSoftWaitSignal
}
func isExecutionTerminal(status string) bool {
switch strings.TrimSpace(strings.ToLower(status)) {
case ToolExecutionStatusCompleted, ToolExecutionStatusFailed, ToolExecutionStatusCancelled, ToolExecutionStatusHardTimeout, ToolExecutionStatusOrphaned:
return true
default:
return false
}
}
func ctxDone(ctx context.Context) <-chan struct{} {
if ctx == nil {
return nil
}
return ctx.Done()
}
func detachedExecutionContext(ctx context.Context) context.Context {
if ctx == nil {
return context.Background()
}
return context.WithoutCancel(ctx)
}
func cloneArgsMap(in map[string]interface{}) map[string]interface{} {
if in == nil {
return map[string]interface{}{}
}
out := make(map[string]interface{}, len(in))
for k, v := range in {
out[k] = v
}
return out
}
func cloneToolExecution(in *ToolExecution) *ToolExecution {
if in == nil {
return nil
}
out := *in
out.Arguments = cloneArgsMap(in.Arguments)
if in.Result != nil {
res := *in.Result
if in.Result.Content != nil {
res.Content = append([]Content(nil), in.Result.Content...)
}
out.Result = &res
}
if in.EndTime != nil {
t := *in.EndTime
out.EndTime = &t
}
if in.PartialOutputUpdatedAt != nil {
t := *in.PartialOutputUpdatedAt
out.PartialOutputUpdatedAt = &t
}
return &out
}
func appendPartialOutput(exec *ToolExecution, chunk string, maxBytes int, updatedAt time.Time) {
if exec == nil || chunk == "" {
return
}
if maxBytes <= 0 {
maxBytes = defaultPartialOutputMaxBytes
}
exec.PartialOutputBytes += int64(len([]byte(chunk)))
combined := exec.PartialOutput + chunk
if len([]byte(combined)) > maxBytes {
b := []byte(combined)
combined = string(b[len(b)-maxBytes:])
exec.PartialOutputTruncated = true
}
exec.PartialOutput = combined
t := updatedAt
exec.PartialOutputUpdatedAt = &t
}
+41
View File
@@ -0,0 +1,41 @@
package mcp
import (
"context"
"testing"
)
func TestExecutionServiceBackgroundWaitResultCompletesWaitTool(t *testing.T) {
service := NewExecutionService(nil, nil)
handle, err := service.Submit(context.Background(), ExecutionRequest{
ToolName: "wait_tool_execution",
Run: func(context.Context) (*ToolResult, error) {
return &ToolResult{
Content: []Content{{Type: "text", Text: `{
"execution_id": "3eaaa391-050b-4be1-a870-48a855923cb7",
"tool": "exec",
"status": "running"
}
本次等待已到达 timeout_seconds上述 execution 仍未完成可继续等待取消或采用其他步骤`}},
IsError: true,
}, nil
},
})
if err != nil {
t.Fatalf("Submit: %v", err)
}
snap, err := service.Wait(context.Background(), handle.ID, 0)
if err != nil {
t.Fatalf("Wait: %v", err)
}
if snap == nil || snap.Execution == nil {
t.Fatal("missing execution snapshot")
}
if snap.Execution.Status != ToolExecutionStatusCompleted {
t.Fatalf("status = %q, want %q", snap.Execution.Status, ToolExecutionStatusCompleted)
}
if snap.Execution.Result == nil || !snap.Execution.Result.IsError {
t.Fatal("model-facing result should remain IsError")
}
}
+420 -170
View File
@@ -2,6 +2,7 @@ package mcp
import (
"context"
"errors"
"fmt"
"strings"
"sync"
@@ -11,8 +12,6 @@ import (
"cyberstrike-ai/internal/authctx"
"cyberstrike-ai/internal/config"
"github.com/google/uuid"
"go.uber.org/zap"
)
@@ -36,32 +35,52 @@ type listToolsInflight struct {
err error
}
type ExternalMCPResilienceConfig struct {
MaxConcurrentPerServer int
MaxConcurrentTotal int
CircuitFailureThreshold int
CircuitCooldown time.Duration
}
type externalMCPServerRuntime struct {
semaphore chan struct{}
consecutiveFailures int
circuitOpenUntil time.Time
}
// ExternalMCPManager 外部MCP管理器
type ExternalMCPManager struct {
clients map[string]ExternalMCPClient
configs map[string]config.ExternalMCPServerConfig
logger *zap.Logger
storage MonitorStorage // 可选的持久化存储
executions map[string]*ToolExecution // 执行记录
stats map[string]*ToolStats // 工具统计信息
errors map[string]string // 错误信息
toolCounts map[string]int // 工具数量缓存
toolCountsMu sync.RWMutex // 工具数量缓存的锁
toolCache map[string]toolListCacheEntry // 工具列表缓存:MCP名称 -> 工具列表
toolCacheMu sync.RWMutex // 工具列表缓存的锁
listToolsMu sync.Mutex
listToolsInflight map[string]*listToolsInflight
stopRefresh chan struct{} // 停止后台刷新的信号
refreshWg sync.WaitGroup // 等待后台刷新goroutine完成
refreshing atomic.Bool // 防止 refreshToolCounts 并发堆积
mu sync.RWMutex
runningCancels map[string]context.CancelFunc
abortUserNotes map[string]string
reconnectMu sync.Mutex
reconnecting map[string]bool
reconnectLastTry map[string]time.Time
reconnectAttempts map[string]int
toolAuthorizer func(context.Context, string, map[string]interface{}) error
clients map[string]ExternalMCPClient
configs map[string]config.ExternalMCPServerConfig
logger *zap.Logger
storage MonitorStorage // 可选的持久化存储
executions map[string]*ToolExecution // 执行记录
stats map[string]*ToolStats // 工具统计信息
errors map[string]string // 错误信息
toolCounts map[string]int // 工具数量缓存
toolCountsMu sync.RWMutex // 工具数量缓存的锁
toolCache map[string]toolListCacheEntry // 工具列表缓存:MCP名称 -> 工具列表
toolCacheMu sync.RWMutex // 工具列表缓存的锁
listToolsMu sync.Mutex
listToolsInflight map[string]*listToolsInflight
stopRefresh chan struct{} // 停止后台刷新的信号
refreshWg sync.WaitGroup // 等待后台刷新goroutine完成
refreshing atomic.Bool // 防止 refreshToolCounts 并发堆积
mu sync.RWMutex
runningCancels map[string]context.CancelFunc
abortUserNotes map[string]string
reconnectMu sync.Mutex
reconnecting map[string]bool
reconnectLastTry map[string]time.Time
reconnectAttempts map[string]int
toolAuthorizer func(context.Context, string, map[string]interface{}) error
executionService *ExecutionService
toolWaitTimeout time.Duration
toolResultMaxBytes int
spillRootDir string
resilience ExternalMCPResilienceConfig
serverRuntimes map[string]*externalMCPServerRuntime
globalSemaphore chan struct{}
}
// NewExternalMCPManager 创建外部MCP管理器
@@ -80,28 +99,119 @@ func (m *ExternalMCPManager) SetToolAuthorizer(authorizer func(context.Context,
// NewExternalMCPManagerWithStorage 创建外部MCP管理器(带持久化存储)
func NewExternalMCPManagerWithStorage(logger *zap.Logger, storage MonitorStorage) *ExternalMCPManager {
manager := &ExternalMCPManager{
clients: make(map[string]ExternalMCPClient),
configs: make(map[string]config.ExternalMCPServerConfig),
logger: logger,
storage: storage,
executions: make(map[string]*ToolExecution),
stats: make(map[string]*ToolStats),
errors: make(map[string]string),
toolCounts: make(map[string]int),
toolCache: make(map[string]toolListCacheEntry),
listToolsInflight: make(map[string]*listToolsInflight),
stopRefresh: make(chan struct{}),
runningCancels: make(map[string]context.CancelFunc),
abortUserNotes: make(map[string]string),
reconnecting: make(map[string]bool),
reconnectLastTry: make(map[string]time.Time),
reconnectAttempts: make(map[string]int),
clients: make(map[string]ExternalMCPClient),
configs: make(map[string]config.ExternalMCPServerConfig),
logger: logger,
storage: storage,
executions: make(map[string]*ToolExecution),
stats: make(map[string]*ToolStats),
errors: make(map[string]string),
toolCounts: make(map[string]int),
toolCache: make(map[string]toolListCacheEntry),
listToolsInflight: make(map[string]*listToolsInflight),
stopRefresh: make(chan struct{}),
runningCancels: make(map[string]context.CancelFunc),
abortUserNotes: make(map[string]string),
reconnecting: make(map[string]bool),
reconnectLastTry: make(map[string]time.Time),
reconnectAttempts: make(map[string]int),
toolWaitTimeout: 60 * time.Second,
toolResultMaxBytes: DefaultToolResultMaxBytes,
resilience: ExternalMCPResilienceConfig{
MaxConcurrentPerServer: 2,
MaxConcurrentTotal: 16,
CircuitFailureThreshold: 3,
CircuitCooldown: 60 * time.Second,
},
serverRuntimes: make(map[string]*externalMCPServerRuntime),
globalSemaphore: make(chan struct{}, 16),
}
manager.executionService = NewExecutionService(storage, logger)
// 启动后台刷新工具数量的goroutine
manager.startToolCountRefresh()
return manager
}
func (m *ExternalMCPManager) ConfigureToolResultMaxBytes(maxBytes int) {
if m == nil {
return
}
m.mu.Lock()
m.toolResultMaxBytes = maxBytes
m.mu.Unlock()
if m.executionService != nil {
m.executionService.ConfigureToolResultMaxBytes(maxBytes)
}
}
// ConfigureToolResultSpillRoot sets the local directory root used when oversized
// tool results are spilled (aligned with reduction_root_dir; empty → tmp/reduction).
func (m *ExternalMCPManager) ConfigureToolResultSpillRoot(rootDir string) {
if m == nil {
return
}
m.mu.Lock()
m.spillRootDir = strings.TrimSpace(rootDir)
m.mu.Unlock()
if m.executionService != nil {
m.executionService.ConfigureToolResultSpillRoot(rootDir)
}
}
// ConfigureToolWaitTimeoutSeconds controls how long an agent-facing tool call
// waits for an external MCP execution before returning an execution_id that can
// be polled with wait_tool_execution. seconds<=0 waits until completion.
func (m *ExternalMCPManager) ConfigureToolWaitTimeoutSeconds(seconds int) {
if m == nil {
return
}
m.mu.Lock()
defer m.mu.Unlock()
if seconds <= 0 {
m.toolWaitTimeout = 0
return
}
m.toolWaitTimeout = time.Duration(seconds) * time.Second
}
func (m *ExternalMCPManager) ConfigureResilience(cfg ExternalMCPResilienceConfig) {
if m == nil {
return
}
normalized := normalizeExternalMCPResilienceConfig(cfg)
m.mu.Lock()
defer m.mu.Unlock()
m.resilience = normalized
m.serverRuntimes = make(map[string]*externalMCPServerRuntime)
if normalized.MaxConcurrentTotal > 0 {
m.globalSemaphore = make(chan struct{}, normalized.MaxConcurrentTotal)
} else {
m.globalSemaphore = nil
}
}
func normalizeExternalMCPResilienceConfig(cfg ExternalMCPResilienceConfig) ExternalMCPResilienceConfig {
if cfg.MaxConcurrentPerServer == 0 {
cfg.MaxConcurrentPerServer = 2
}
if cfg.MaxConcurrentTotal == 0 {
cfg.MaxConcurrentTotal = 16
}
if cfg.CircuitFailureThreshold == 0 {
cfg.CircuitFailureThreshold = 3
}
if cfg.CircuitCooldown <= 0 {
cfg.CircuitCooldown = 60 * time.Second
}
if cfg.MaxConcurrentPerServer < 0 {
cfg.MaxConcurrentPerServer = 0
}
if cfg.MaxConcurrentTotal < 0 {
cfg.MaxConcurrentTotal = 0
}
return cfg
}
// LoadConfigs 加载配置
func (m *ExternalMCPManager) LoadConfigs(cfg *config.ExternalMCPConfig) {
m.mu.Lock()
@@ -564,146 +674,264 @@ func (m *ExternalMCPManager) updateToolCache(name string, tools []Tool) {
// CallTool 调用外部MCP工具(返回执行ID)
func (m *ExternalMCPManager) CallTool(ctx context.Context, toolName string, args map[string]interface{}) (*ToolResult, string, error) {
_, authenticated := authctx.PrincipalFromContext(ctx)
m.mu.RLock()
authorizer := m.toolAuthorizer
m.mu.RUnlock()
if authorizer != nil {
if err := authorizer(ctx, toolName, args); err != nil {
return nil, "", fmt.Errorf("external tool authorization denied: %w", err)
}
} else if authenticated {
return nil, "", fmt.Errorf("external tool authorization policy is not configured")
}
// 解析工具名称:name::toolName
var mcpName, actualToolName string
if idx := findSubstring(toolName, "::"); idx > 0 {
mcpName = toolName[:idx]
actualToolName = toolName[idx+2:]
} else {
return nil, "", fmt.Errorf("无效的工具名称格式: %s", toolName)
}
client, exists := m.GetClient(mcpName)
if !exists {
return nil, "", fmt.Errorf("外部MCP客户端不存在: %s", mcpName)
}
// 检查连接状态,如果未连接或状态为error,不允许调用
if !client.IsConnected() {
status := client.GetStatus()
if status == "error" {
// 获取错误信息(如果有)
errorMsg := m.GetError(mcpName)
if errorMsg != "" {
return nil, "", fmt.Errorf("外部MCP连接失败: %s (错误: %s)", mcpName, errorMsg)
}
return nil, "", fmt.Errorf("外部MCP连接失败: %s", mcpName)
}
return nil, "", fmt.Errorf("外部MCP客户端未连接: %s (状态: %s)", mcpName, status)
}
// 创建执行记录
executionID := uuid.New().String()
execution := &ToolExecution{
ID: executionID,
ToolName: toolName, // 使用完整工具名称(包含MCP名称)
Arguments: args,
Status: "running",
StartTime: time.Now(),
if m.executionService == nil {
m.executionService = NewExecutionService(m.storage, m.logger)
m.executionService.ConfigureToolResultMaxBytes(m.toolResultMaxBytes)
m.executionService.ConfigureToolResultSpillRoot(m.spillRootDir)
}
var ownerUserID string
if principal, ok := authctx.PrincipalFromContext(ctx); ok {
execution.OwnerUserID = principal.UserID
ownerUserID = principal.UserID
}
execution.ConversationID = MCPConversationIDFromContext(ctx)
var mcpName, actualToolName string
var client ExternalMCPClient
handle, err := m.executionService.Submit(ctx, ExecutionRequest{
ToolName: toolName,
Arguments: args,
ConversationID: MCPConversationIDFromContext(ctx),
OwnerUserID: ownerUserID,
PreRun: func(runCtx context.Context, exec *ToolExecution) (func(), error) {
_, authenticated := authctx.PrincipalFromContext(runCtx)
m.mu.RLock()
authorizer := m.toolAuthorizer
m.mu.RUnlock()
if authorizer != nil {
if err := authorizer(runCtx, toolName, args); err != nil {
return nil, fmt.Errorf("external tool authorization denied: %w", err)
}
} else if authenticated {
return nil, fmt.Errorf("external tool authorization policy is not configured")
}
m.mu.Lock()
m.executions[executionID] = execution
// 如果内存中的执行记录超过限制,清理最旧的记录
m.cleanupOldExecutions()
m.mu.Unlock()
if m.storage != nil {
if err := m.storage.SaveToolExecution(execution); err != nil {
m.logger.Warn("保存执行记录到数据库失败", zap.Error(err))
}
}
execCtx, runCancel := context.WithCancel(ctx)
m.registerRunningCancel(executionID, runCancel)
notifyToolRunBegin(ctx, executionID)
defer func() {
notifyToolRunEnd(ctx, executionID)
runCancel()
m.unregisterRunningCancel(executionID)
}()
// 调用工具
result, err := client.CallTool(execCtx, actualToolName, args)
if err != nil {
m.handleConnectionDead(mcpName, client, err)
}
cancelledWithUserNote := m.applyAbortUserNoteToCancelledToolResult(executionID, &result, &err)
// 更新执行记录
m.mu.Lock()
now := time.Now()
execution.EndTime = &now
execution.Duration = now.Sub(execution.StartTime)
if err != nil {
st, msg := executionStatusAndMessage(err)
execution.Status = st
execution.Error = msg
} else if result != nil && result.IsError {
if cancelledWithUserNote {
execution.Status = "cancelled"
execution.Error = ""
execution.Result = result
} else {
execution.Status = "failed"
if len(result.Content) > 0 {
execution.Error = result.Content[0].Text
// 解析工具名称:name::toolName
if idx := findSubstring(toolName, "::"); idx > 0 {
mcpName = toolName[:idx]
actualToolName = toolName[idx+2:]
} else {
execution.Error = "工具执行返回错误结果"
return nil, fmt.Errorf("无效的工具名称格式: %s", toolName)
}
execution.Result = result
}
} else {
execution.Status = "completed"
if result == nil {
result = &ToolResult{
Content: []Content{
{Type: "text", Text: "工具执行完成,但未返回结果"},
},
var exists bool
client, exists = m.GetClient(mcpName)
if !exists {
return nil, fmt.Errorf("外部MCP客户端不存在: %s", mcpName)
}
}
execution.Result = result
if err := m.checkExternalMCPCircuit(mcpName); err != nil {
return nil, err
}
// 检查连接状态,如果未连接或状态为error,不允许调用
if !client.IsConnected() {
status := client.GetStatus()
if status == "error" {
// 获取错误信息(如果有)
errorMsg := m.GetError(mcpName)
if errorMsg != "" {
return nil, fmt.Errorf("外部MCP连接失败: %s (错误: %s)", mcpName, errorMsg)
}
return nil, fmt.Errorf("外部MCP连接失败: %s", mcpName)
}
return nil, fmt.Errorf("外部MCP客户端未连接: %s (状态: %s)", mcpName, status)
}
release, acquireErr := m.acquireExternalMCPCallSlot(runCtx, mcpName)
if acquireErr != nil {
return nil, acquireErr
}
return release, nil
},
Run: func(runCtx context.Context) (*ToolResult, error) {
result, callErr := client.CallTool(runCtx, actualToolName, args)
if callErr != nil {
m.handleConnectionDead(mcpName, client, callErr)
}
return result, callErr
},
OnDone: func(exec *ToolExecution) {
failed := exec != nil && exec.Status != ToolExecutionStatusCompleted && exec.Status != ToolExecutionStatusCancelled
if mcpName != "" {
m.recordExternalMCPResult(mcpName, failed)
}
m.updateStats(toolName, failed)
},
})
if err != nil {
return nil, "", err
}
m.mu.RLock()
waitTimeout := m.toolWaitTimeout
m.mu.RUnlock()
snapshot, waitErr := m.executionService.Wait(ctx, handle.ID, waitTimeout)
if errors.Is(waitErr, ErrExecutionWaitTimeout) {
return externalMCPWaitTimeoutResult(snapshot, waitTimeout), handle.ID, nil
}
if waitErr != nil {
return nil, handle.ID, waitErr
}
if snapshot == nil || snapshot.Execution == nil {
return &ToolResult{Content: []Content{{Type: "text", Text: "工具执行完成,但未返回执行快照"}}, IsError: true}, handle.ID, nil
}
if snapshot.Execution.Result != nil {
return snapshot.Execution.Result, handle.ID, nil
}
if snapshot.Execution.Error != "" {
return nil, handle.ID, errors.New(snapshot.Execution.Error)
}
return &ToolResult{Content: []Content{{Type: "text", Text: "工具执行完成,但未返回结果"}}, IsError: false}, handle.ID, nil
}
func externalMCPWaitTimeoutResult(snapshot *ExecutionSnapshot, waitTimeout time.Duration) *ToolResult {
execID := ""
status := ToolExecutionStatusRunning
toolName := ""
elapsed := time.Duration(0)
if snapshot != nil && snapshot.Execution != nil {
execID = snapshot.Execution.ID
status = snapshot.Execution.Status
toolName = snapshot.Execution.ToolName
elapsed = time.Since(snapshot.Execution.StartTime).Round(time.Second)
}
waitText := "unbounded"
if waitTimeout > 0 {
waitText = waitTimeout.Round(time.Second).String()
}
msg := fmt.Sprintf(`工具已提交到后台执行但本次等待已到达上限
execution_id: %s
tool: %s
status: %s
wait_timeout: %s
elapsed: %s
你可以继续推理改用其他工具或调用 wait_tool_execution 继续等待该 execution_id也可以调用 cancel_tool_execution 取消`, execID, toolName, status, waitText, elapsed)
return &ToolResult{Content: []Content{{Type: "text", Text: msg}}, IsError: true}
}
func (m *ExternalMCPManager) checkExternalMCPCircuit(mcpName string) error {
if m == nil {
return nil
}
name := strings.TrimSpace(mcpName)
if name == "" {
return nil
}
m.mu.Lock()
defer m.mu.Unlock()
if m.resilience.CircuitFailureThreshold < 0 {
return nil
}
rt := m.externalMCPRuntimeLocked(name)
if rt == nil || rt.circuitOpenUntil.IsZero() {
return nil
}
now := time.Now()
if now.Before(rt.circuitOpenUntil) {
return fmt.Errorf("外部MCP服务 %s 已临时熔断,预计 %s 后重试", name, time.Until(rt.circuitOpenUntil).Round(time.Second))
}
rt.circuitOpenUntil = time.Time{}
return nil
}
func (m *ExternalMCPManager) acquireExternalMCPCallSlot(ctx context.Context, mcpName string) (func(), error) {
if m == nil {
return func() {}, nil
}
name := strings.TrimSpace(mcpName)
m.mu.Lock()
rt := m.externalMCPRuntimeLocked(name)
serverSem := chan struct{}(nil)
if rt != nil {
serverSem = rt.semaphore
}
globalSem := m.globalSemaphore
m.mu.Unlock()
if m.storage != nil {
if err := m.storage.SaveToolExecution(execution); err != nil {
m.logger.Warn("保存执行记录到数据库失败", zap.Error(err))
releaseGlobal := false
if globalSem != nil {
select {
case globalSem <- struct{}{}:
releaseGlobal = true
case <-ctxDone(ctx):
return func() {}, contextErr(ctx)
}
}
// 更新统计信息
failed := err != nil || (result != nil && result.IsError)
m.updateStats(toolName, failed)
// 如果使用存储,从内存中删除(已持久化)
if m.storage != nil {
m.mu.Lock()
delete(m.executions, executionID)
m.mu.Unlock()
releaseServer := false
if serverSem != nil {
select {
case serverSem <- struct{}{}:
releaseServer = true
case <-ctxDone(ctx):
if releaseGlobal {
<-globalSem
}
return func() {}, contextErr(ctx)
}
}
return func() {
if releaseServer {
<-serverSem
}
if releaseGlobal {
<-globalSem
}
}, nil
}
if err != nil {
return nil, executionID, err
func contextErr(ctx context.Context) error {
if ctx == nil || ctx.Err() == nil {
return context.Canceled
}
return ctx.Err()
}
return result, executionID, nil
func (m *ExternalMCPManager) recordExternalMCPResult(mcpName string, failed bool) {
if m == nil || strings.TrimSpace(mcpName) == "" {
return
}
m.mu.Lock()
defer m.mu.Unlock()
rt := m.externalMCPRuntimeLocked(mcpName)
if rt == nil {
return
}
if !failed {
rt.consecutiveFailures = 0
rt.circuitOpenUntil = time.Time{}
return
}
if m.resilience.CircuitFailureThreshold < 0 {
return
}
rt.consecutiveFailures++
if rt.consecutiveFailures >= m.resilience.CircuitFailureThreshold {
rt.circuitOpenUntil = time.Now().Add(m.resilience.CircuitCooldown)
m.logger.Warn("外部MCP服务触发熔断",
zap.String("name", mcpName),
zap.Int("consecutiveFailures", rt.consecutiveFailures),
zap.Duration("cooldown", m.resilience.CircuitCooldown),
)
}
}
func (m *ExternalMCPManager) externalMCPRuntimeLocked(mcpName string) *externalMCPServerRuntime {
if m.serverRuntimes == nil {
m.serverRuntimes = make(map[string]*externalMCPServerRuntime)
}
name := strings.TrimSpace(mcpName)
if name == "" {
return nil
}
if rt := m.serverRuntimes[name]; rt != nil {
return rt
}
var sem chan struct{}
if m.resilience.MaxConcurrentPerServer > 0 {
sem = make(chan struct{}, m.resilience.MaxConcurrentPerServer)
}
rt := &externalMCPServerRuntime{semaphore: sem}
m.serverRuntimes[name] = rt
return rt
}
func (m *ExternalMCPManager) applyAbortUserNoteToCancelledToolResult(executionID string, result **ToolResult, err *error) (cancelledWithUserNote bool) {
@@ -785,6 +1013,11 @@ func (m *ExternalMCPManager) cleanupOldExecutions() {
// GetExecution 获取执行记录(先从内存查找,再从数据库查找)
func (m *ExternalMCPManager) GetExecution(id string) (*ToolExecution, bool) {
if m.executionService != nil {
if snap, err := m.executionService.Get(id); err == nil && snap != nil && snap.Execution != nil {
return snap.Execution, true
}
}
m.mu.RLock()
exec, exists := m.executions[id]
m.mu.RUnlock()
@@ -817,6 +1050,9 @@ func (m *ExternalMCPManager) unregisterRunningCancel(id string) {
// CancelToolExecutionWithNote 取消外部 MCP 工具;note 非空时与已返回输出合并后交给模型。
func (m *ExternalMCPManager) CancelToolExecutionWithNote(id string, note string) bool {
if m.executionService != nil && m.executionService.Cancel(id, note) {
return true
}
m.mu.Lock()
cancel, ok := m.runningCancels[id]
if !ok || cancel == nil {
@@ -844,6 +1080,11 @@ func (m *ExternalMCPManager) ActiveRunningExecutionIDs() map[string]struct{} {
if m == nil {
return nil
}
if m.executionService != nil {
if ids := m.executionService.ActiveRunningExecutionIDs(); len(ids) > 0 {
return ids
}
}
m.mu.Lock()
defer m.mu.Unlock()
if len(m.runningCancels) == 0 {
@@ -1335,12 +1576,21 @@ func (m *ExternalMCPManager) StartAllEnabled() {
// StopAll 停止所有客户端
func (m *ExternalMCPManager) StopAll() {
if m.executionService != nil {
m.executionService.CancelAll("外部 MCP 管理器正在停止")
}
clients := make(map[string]ExternalMCPClient)
m.mu.Lock()
defer m.mu.Unlock()
for name, client := range m.clients {
client.Close()
clients[name] = client
delete(m.clients, name)
}
m.mu.Unlock()
for name, client := range clients {
if client != nil {
_ = client.Close()
}
m.clearReconnectState(name)
}
@@ -1360,6 +1610,6 @@ func (m *ExternalMCPManager) StopAll() {
// 已经关闭,不需要再次关闭
default:
close(m.stopRefresh)
m.refreshWg.Wait()
}
m.refreshWg.Wait()
}
+230
View File
@@ -0,0 +1,230 @@
package mcp
import (
"context"
"errors"
"strings"
"sync/atomic"
"testing"
"time"
"go.uber.org/zap"
)
type blockingExternalMCPClient struct {
started chan struct{}
calls chan string
release chan struct{}
result *ToolResult
count atomic.Int32
}
func newBlockingExternalMCPClient(resultText string) *blockingExternalMCPClient {
return &blockingExternalMCPClient{
started: make(chan struct{}),
calls: make(chan string, 8),
release: make(chan struct{}),
result: &ToolResult{Content: []Content{{Type: "text", Text: resultText}}},
}
}
func (c *blockingExternalMCPClient) Initialize(ctx context.Context) error { return nil }
func (c *blockingExternalMCPClient) ListTools(ctx context.Context) ([]Tool, error) {
return []Tool{{Name: "slow_tool"}}, nil
}
func (c *blockingExternalMCPClient) CallTool(ctx context.Context, name string, args map[string]interface{}) (*ToolResult, error) {
c.count.Add(1)
select {
case c.calls <- name:
default:
}
select {
case <-c.started:
default:
close(c.started)
}
select {
case <-c.release:
return c.result, nil
case <-ctx.Done():
return nil, ctx.Err()
}
}
func (c *blockingExternalMCPClient) Close() error { return nil }
func (c *blockingExternalMCPClient) IsConnected() bool { return true }
func (c *blockingExternalMCPClient) GetStatus() string { return "connected" }
type failingExternalMCPClient struct{}
func (c *failingExternalMCPClient) Initialize(ctx context.Context) error { return nil }
func (c *failingExternalMCPClient) ListTools(ctx context.Context) ([]Tool, error) {
return []Tool{{Name: "fail_tool"}}, nil
}
func (c *failingExternalMCPClient) CallTool(ctx context.Context, name string, args map[string]interface{}) (*ToolResult, error) {
return nil, errors.New("boom")
}
func (c *failingExternalMCPClient) Close() error { return nil }
func (c *failingExternalMCPClient) IsConnected() bool { return true }
func (c *failingExternalMCPClient) GetStatus() string { return "connected" }
func TestExternalMCPManager_CallToolBoundedWaitThenContinue(t *testing.T) {
manager := NewExternalMCPManager(zap.NewNop())
manager.ConfigureToolWaitTimeoutSeconds(1)
manager.toolWaitTimeout = 10 * time.Millisecond
client := newBlockingExternalMCPClient("slow result ready")
manager.clients["lab"] = client
callCtx, callCancel := context.WithCancel(context.Background())
result, executionID, err := manager.CallTool(callCtx, "lab::slow_tool", map[string]interface{}{"target": "example"})
if err != nil {
t.Fatalf("CallTool returned error: %v", err)
}
if executionID == "" {
t.Fatal("expected execution id")
}
if result == nil || !result.IsError {
t.Fatalf("expected soft timeout tool result, got %#v", result)
}
text := ToolResultPlainText(result)
if !strings.Contains(text, executionID) || !strings.Contains(text, "wait_tool_execution") {
t.Fatalf("timeout result should include execution id and wait guidance, got %q", text)
}
select {
case <-client.started:
default:
t.Fatal("worker did not start")
}
callCancel()
close(client.release)
snapshot, err := manager.executionService.Wait(context.Background(), executionID, time.Second)
if err != nil {
t.Fatalf("Wait returned error: %v", err)
}
if snapshot == nil || snapshot.Execution == nil {
t.Fatal("expected execution snapshot")
}
if snapshot.Execution.Status != ToolExecutionStatusCompleted {
t.Fatalf("status = %q, want completed", snapshot.Execution.Status)
}
if got := ToolResultPlainText(snapshot.Execution.Result); got != "slow result ready" {
t.Fatalf("result = %q, want slow result ready", got)
}
}
func TestExecutionControlWaitToolReturnsCompletedResult(t *testing.T) {
manager := NewExternalMCPManager(zap.NewNop())
manager.toolWaitTimeout = 10 * time.Millisecond
client := newBlockingExternalMCPClient("control wait result")
manager.clients["lab"] = client
result, executionID, err := manager.CallTool(context.Background(), "lab::slow_tool", nil)
if err != nil {
t.Fatalf("CallTool returned error: %v", err)
}
if result == nil || !result.IsError || executionID == "" {
t.Fatalf("expected soft timeout and execution id, got result=%#v id=%q", result, executionID)
}
server := NewServer(zap.NewNop())
RegisterExecutionControlTools(server, manager)
close(client.release)
waitResult, _, err := server.CallTool(context.Background(), "wait_tool_execution", map[string]interface{}{
"execution_id": executionID,
"timeout_seconds": 1,
})
if err != nil {
t.Fatalf("wait_tool_execution returned error: %v", err)
}
if waitResult == nil || waitResult.IsError {
t.Fatalf("expected successful wait result, got %#v", waitResult)
}
body := ToolResultPlainText(waitResult)
if !strings.Contains(body, `"status": "completed"`) || !strings.Contains(body, "control wait result") {
t.Fatalf("wait result body missing completed status/result: %s", body)
}
}
func TestExternalMCPManager_PerServerConcurrencyLimitsWorkers(t *testing.T) {
manager := NewExternalMCPManager(zap.NewNop())
manager.toolWaitTimeout = 10 * time.Millisecond
manager.ConfigureResilience(ExternalMCPResilienceConfig{
MaxConcurrentPerServer: 1,
MaxConcurrentTotal: 4,
CircuitFailureThreshold: -1,
CircuitCooldown: time.Second,
})
client := newBlockingExternalMCPClient("ok")
manager.clients["lab"] = client
done1 := make(chan struct{})
go func() {
_, _, _ = manager.CallTool(context.Background(), "lab::slow_tool", nil)
close(done1)
}()
select {
case <-client.calls:
case <-time.After(time.Second):
t.Fatal("first worker did not enter client")
}
type callOutcome struct {
executionID string
err error
}
done2 := make(chan callOutcome, 1)
go func() {
_, executionID, err := manager.CallTool(context.Background(), "lab::slow_tool", nil)
done2 <- callOutcome{executionID: executionID, err: err}
}()
select {
case <-client.calls:
t.Fatal("second worker entered client before per-server slot was released")
case <-time.After(50 * time.Millisecond):
}
var second callOutcome
select {
case second = <-done2:
case <-time.After(time.Second):
t.Fatal("second call did not return after bounded wait")
}
if second.err != nil || second.executionID == "" {
t.Fatalf("second call should return queued execution id after bounded wait, id=%q err=%v", second.executionID, second.err)
}
snapshot, err := manager.executionService.Get(second.executionID)
if err != nil {
t.Fatalf("Get queued execution: %v", err)
}
if snapshot == nil || snapshot.Execution == nil || snapshot.Execution.Status != ToolExecutionStatusQueued {
t.Fatalf("second execution status = %#v, want queued", snapshot)
}
close(client.release)
select {
case <-client.calls:
case <-time.After(time.Second):
t.Fatal("second worker did not enter client after slot release")
}
<-done1
}
func TestExternalMCPManager_CircuitBreakerOpensAfterFailures(t *testing.T) {
manager := NewExternalMCPManager(zap.NewNop())
manager.ConfigureResilience(ExternalMCPResilienceConfig{
MaxConcurrentPerServer: 2,
MaxConcurrentTotal: 4,
CircuitFailureThreshold: 1,
CircuitCooldown: time.Minute,
})
manager.clients["lab"] = &failingExternalMCPClient{}
_, _, err := manager.CallTool(context.Background(), "lab::fail_tool", nil)
if err == nil || !strings.Contains(err.Error(), "boom") {
t.Fatalf("expected first call to fail with client error, got %v", err)
}
_, _, err = manager.CallTool(context.Background(), "lab::fail_tool", nil)
if err == nil || !strings.Contains(err.Error(), "熔断") {
t.Fatalf("expected circuit breaker rejection, got %v", err)
}
}
+11 -1
View File
@@ -20,10 +20,20 @@ func TestExternalManagerEnforcesConfiguredAuthorizer(t *testing.T) {
return errors.New("denied by policy")
})
ctx := authctx.WithPrincipal(context.Background(), authctx.NewPrincipal("u1", "user", "assigned", map[string]bool{"agent:execute": true}))
_, _, err := manager.CallTool(ctx, "server::tool", map[string]interface{}{})
_, executionID, err := manager.CallTool(ctx, "server::tool", map[string]interface{}{})
if err == nil || !strings.Contains(err.Error(), "authorization denied") {
t.Fatalf("external call bypassed authorizer: %v", err)
}
if executionID == "" {
t.Fatal("denied external call should still return an execution id")
}
execution, ok := manager.GetExecution(executionID)
if !ok || execution == nil {
t.Fatalf("missing denied external execution %q", executionID)
}
if execution.Status != ToolExecutionStatusFailed || !strings.Contains(execution.Error, "denied by policy") {
t.Fatalf("denied external execution = %#v, want failed with policy error", execution)
}
}
func TestExternalMCPManager_AddOrUpdateConfig(t *testing.T) {
+44
View File
@@ -22,6 +22,8 @@ type EinoExecuteRunRegistry interface {
type toolRunRegistryCtxKey struct{}
type einoExecuteRunRegistryCtxKey struct{}
type mcpConversationIDCtxKey struct{}
type mcpExecutionIDCtxKey struct{}
type mcpProjectIDCtxKey struct{}
// WithToolRunRegistry 将登记器注入 ctxEino / 原生 Agent 任务 ctx)。
func WithToolRunRegistry(ctx context.Context, reg ToolRunRegistry) context.Context {
@@ -78,6 +80,48 @@ func MCPConversationIDFromContext(ctx context.Context) string {
return v
}
// WithMCPExecutionID 将当前工具 executionId 注入 ctx,供超长输出落盘文件名对齐。
func WithMCPExecutionID(ctx context.Context, executionID string) context.Context {
if ctx == nil {
return nil
}
id := strings.TrimSpace(executionID)
if id == "" {
return ctx
}
return context.WithValue(ctx, mcpExecutionIDCtxKey{}, id)
}
// MCPExecutionIDFromContext 读取当前工具 executionId。
func MCPExecutionIDFromContext(ctx context.Context) string {
if ctx == nil {
return ""
}
v, _ := ctx.Value(mcpExecutionIDCtxKey{}).(string)
return v
}
// WithMCPProjectID 将项目 ID 注入 ctx,供 reduction/trunc 落盘路径与项目隔离对齐。
func WithMCPProjectID(ctx context.Context, projectID string) context.Context {
if ctx == nil {
return nil
}
id := strings.TrimSpace(projectID)
if id == "" {
return ctx
}
return context.WithValue(ctx, mcpProjectIDCtxKey{}, id)
}
// MCPProjectIDFromContext 读取项目 ID。
func MCPProjectIDFromContext(ctx context.Context) string {
if ctx == nil {
return ""
}
v, _ := ctx.Value(mcpProjectIDCtxKey{}).(string)
return v
}
func notifyToolRunBegin(ctx context.Context, executionID string) {
reg := ToolRunRegistryFromContext(ctx)
if reg == nil {
+213 -121
View File
@@ -15,6 +15,7 @@ import (
"time"
"cyberstrike-ai/internal/authctx"
"cyberstrike-ai/internal/mcp/builtin"
"github.com/google/uuid"
"go.uber.org/zap"
@@ -52,8 +53,14 @@ type Server struct {
httpToolTimeoutMinutes *int
httpToolTimeoutMu sync.RWMutex
toolAuthorizer func(context.Context, string, map[string]interface{}) error
executionService *ExecutionService
toolWaitTimeout time.Duration
toolResultMaxBytes int
spillRootDir string
}
const defaultPartialOutputMaxBytes = 64 * 1024
// SetToolAuthorizer installs the common policy decision point for every
// user-attributed tool call, whether it originates from HTTP or an Agent.
func (s *Server) SetToolAuthorizer(authorizer func(context.Context, string, map[string]interface{}) error) {
@@ -100,7 +107,10 @@ func NewServerWithStorage(logger *zap.Logger, storage MonitorStorage) *Server {
sseClients: make(map[string]*sseClient),
runningCancels: make(map[string]context.CancelFunc),
abortUserNotes: make(map[string]string),
toolWaitTimeout: 60 * time.Second,
toolResultMaxBytes: DefaultToolResultMaxBytes,
}
s.executionService = NewExecutionService(storage, logger)
// 初始化默认提示词和资源
s.initDefaultPrompts()
@@ -109,6 +119,32 @@ func NewServerWithStorage(logger *zap.Logger, storage MonitorStorage) *Server {
return s
}
func (s *Server) ConfigureToolResultMaxBytes(maxBytes int) {
if s == nil {
return
}
s.mu.Lock()
s.toolResultMaxBytes = maxBytes
s.mu.Unlock()
if s.executionService != nil {
s.executionService.ConfigureToolResultMaxBytes(maxBytes)
}
}
// ConfigureToolResultSpillRoot sets the local directory root used when oversized
// tool results are spilled (aligned with reduction_root_dir; empty → tmp/reduction).
func (s *Server) ConfigureToolResultSpillRoot(rootDir string) {
if s == nil {
return
}
s.mu.Lock()
s.spillRootDir = strings.TrimSpace(rootDir)
s.mu.Unlock()
if s.executionService != nil {
s.executionService.ConfigureToolResultSpillRoot(rootDir)
}
}
// ConfigureHTTPToolCallTimeoutFromAgentMinutes 将 agent.tool_timeout_minutes 同步到经 HTTP POST /api/mcp 触发的 tools/call。
// minutes<=0 表示不设置硬性截止时间(与配置「0 不限制」一致);minutes>0 为该次调用的最长等待时间。
// 未调用前对 tools/call 使用默认 30 分钟(与历史硬编码一致)。
@@ -125,6 +161,19 @@ func (s *Server) ConfigureHTTPToolCallTimeoutFromAgentMinutes(minutes int) {
s.httpToolTimeoutMinutes = &v
}
func (s *Server) ConfigureToolWaitTimeoutSeconds(seconds int) {
if s == nil {
return
}
s.mu.Lock()
defer s.mu.Unlock()
if seconds <= 0 {
s.toolWaitTimeout = 0
return
}
s.toolWaitTimeout = time.Duration(seconds) * time.Second
}
func (s *Server) effectiveHTTPToolCallDeadline(parent context.Context) (context.Context, context.CancelFunc) {
const defaultDur = 30 * time.Minute
if parent == nil {
@@ -555,13 +604,13 @@ func (s *Server) handleCallTool(requestCtx context.Context, msg *Message) *Messa
st, msg := executionStatusAndMessage(err)
execution.Status = st
execution.Error = msg
failed = true
failed = st != "cancelled"
} else if result != nil && result.IsError {
if cancelledWithUserNote {
execution.Status = "cancelled"
execution.Error = ""
execution.Result = result
failed = true
failed = false
} else {
execution.Status = "failed"
if len(result.Content) > 0 {
@@ -709,6 +758,11 @@ func (s *Server) updateStats(toolName string, failed bool) {
// GetExecution 获取执行记录(先从内存查找,再从数据库查找)
func (s *Server) GetExecution(id string) (*ToolExecution, bool) {
if s.executionService != nil {
if snap, err := s.executionService.Get(id); err == nil && snap != nil && snap.Execution != nil {
return snap.Execution, true
}
}
s.mu.RLock()
exec, exists := s.executions[id]
s.mu.RUnlock()
@@ -841,131 +895,106 @@ func (s *Server) GetAllTools() []Tool {
// CallTool 直接调用工具(用于内部调用)
func (s *Server) CallTool(ctx context.Context, toolName string, args map[string]interface{}) (*ToolResult, string, error) {
_, authenticated := authctx.PrincipalFromContext(ctx)
s.mu.RLock()
authorizer := s.toolAuthorizer
s.mu.RUnlock()
if authorizer != nil {
if err := authorizer(ctx, toolName, args); err != nil {
return nil, "", fmt.Errorf("tool authorization denied: %w", err)
}
} else if authenticated {
return nil, "", errors.New("tool authorization policy is not configured")
}
s.mu.RLock()
handler, exists := s.tools[toolName]
s.mu.RUnlock()
if !exists {
return nil, "", fmt.Errorf("工具 %s 未找到", toolName)
}
// 创建执行记录
executionID := uuid.New().String()
execution := &ToolExecution{
ID: executionID,
ToolName: toolName,
Arguments: args,
Status: "running",
StartTime: time.Now(),
if s.executionService == nil {
s.executionService = NewExecutionService(s.storage, s.logger)
s.executionService.ConfigureToolResultMaxBytes(s.toolResultMaxBytes)
s.executionService.ConfigureToolResultSpillRoot(s.spillRootDir)
}
var ownerUserID string
if principal, ok := authctx.PrincipalFromContext(ctx); ok {
execution.OwnerUserID = principal.UserID
ownerUserID = principal.UserID
}
execution.ConversationID = MCPConversationIDFromContext(ctx)
s.mu.Lock()
s.executions[executionID] = execution
// 如果内存中的执行记录超过限制,清理最旧的记录
s.cleanupOldExecutions()
s.mu.Unlock()
if s.storage != nil {
if err := s.storage.SaveToolExecution(execution); err != nil {
s.logger.Warn("保存执行记录到数据库失败", zap.Error(err))
}
}
execCtx, runCancel := context.WithCancel(ctx)
s.registerRunningCancel(executionID, runCancel)
notifyToolRunBegin(ctx, executionID)
defer func() {
notifyToolRunEnd(ctx, executionID)
runCancel()
s.unregisterRunningCancel(executionID)
}()
result, err := handler(execCtx, args)
cancelledWithUserNote := s.applyAbortUserNoteToCancelledToolResult(executionID, &result, &err)
s.mu.Lock()
now := time.Now()
execution.EndTime = &now
execution.Duration = now.Sub(execution.StartTime)
var failed bool
var finalResult *ToolResult
if err != nil {
st, msg := executionStatusAndMessage(err)
execution.Status = st
execution.Error = msg
failed = true
} else if result != nil && result.IsError {
if cancelledWithUserNote {
execution.Status = "cancelled"
execution.Error = ""
execution.Result = result
failed = true
finalResult = result
} else {
execution.Status = "failed"
if len(result.Content) > 0 {
execution.Error = result.Content[0].Text
} else {
execution.Error = "工具执行返回错误结果"
handle, err := s.executionService.Submit(ctx, ExecutionRequest{
ToolName: toolName,
Arguments: args,
ConversationID: MCPConversationIDFromContext(ctx),
OwnerUserID: ownerUserID,
Run: func(runCtx context.Context) (*ToolResult, error) {
_, authenticated := authctx.PrincipalFromContext(runCtx)
s.mu.RLock()
authorizer := s.toolAuthorizer
handler, exists := s.tools[toolName]
s.mu.RUnlock()
if authorizer != nil {
if err := authorizer(runCtx, toolName, args); err != nil {
return nil, fmt.Errorf("tool authorization denied: %w", err)
}
} else if authenticated {
return nil, errors.New("tool authorization policy is not configured")
}
execution.Result = result
failed = true
finalResult = result
}
} else {
execution.Status = "completed"
if result == nil {
result = &ToolResult{
Content: []Content{
{Type: "text", Text: "工具执行完成,但未返回结果"},
},
if !exists {
return nil, fmt.Errorf("工具 %s 未找到", toolName)
}
}
execution.Result = result
finalResult = result
failed = false
}
if finalResult == nil {
finalResult = execution.Result
}
s.mu.Unlock()
if s.storage != nil {
if err := s.storage.SaveToolExecution(execution); err != nil {
s.logger.Warn("保存执行记录到数据库失败", zap.Error(err))
}
}
s.updateStats(toolName, failed)
if s.storage != nil {
s.mu.Lock()
delete(s.executions, executionID)
s.mu.Unlock()
}
return handler(runCtx, args)
},
OnDone: func(exec *ToolExecution) {
failed := exec != nil && exec.Status != ToolExecutionStatusCompleted && exec.Status != ToolExecutionStatusCancelled
s.updateStats(toolName, failed)
},
})
if err != nil {
return nil, executionID, err
return nil, "", err
}
return finalResult, executionID, nil
s.mu.RLock()
waitTimeout := s.toolWaitTimeout
s.mu.RUnlock()
if isExecutionControlTool(toolName) {
waitTimeout = 0
}
snapshot, waitErr := s.executionService.Wait(ctx, handle.ID, waitTimeout)
if errors.Is(waitErr, ErrExecutionWaitTimeout) {
return internalMCPWaitTimeoutResult(snapshot, waitTimeout), handle.ID, nil
}
if waitErr != nil {
return nil, handle.ID, waitErr
}
if snapshot == nil || snapshot.Execution == nil {
return &ToolResult{Content: []Content{{Type: "text", Text: "工具执行完成,但未返回执行快照"}}, IsError: true}, handle.ID, nil
}
if snapshot.Execution.Result != nil {
return snapshot.Execution.Result, handle.ID, nil
}
if snapshot.Execution.Error != "" {
return nil, handle.ID, errors.New(snapshot.Execution.Error)
}
return &ToolResult{Content: []Content{{Type: "text", Text: "工具执行完成,但未返回结果"}}, IsError: false}, handle.ID, nil
}
func internalMCPWaitTimeoutResult(snapshot *ExecutionSnapshot, waitTimeout time.Duration) *ToolResult {
execID := ""
status := ToolExecutionStatusRunning
toolName := ""
elapsed := time.Duration(0)
if snapshot != nil && snapshot.Execution != nil {
execID = snapshot.Execution.ID
status = snapshot.Execution.Status
toolName = snapshot.Execution.ToolName
elapsed = time.Since(snapshot.Execution.StartTime).Round(time.Second)
}
waitText := "unbounded"
if waitTimeout > 0 {
waitText = waitTimeout.Round(time.Second).String()
}
msg := fmt.Sprintf(`工具已提交到后台执行但本次等待已到达上限
execution_id: %s
tool: %s
status: %s
wait_timeout: %s
elapsed: %s
你可以继续推理改用其他工具或调用 wait_tool_execution 继续等待该 execution_id也可以调用 cancel_tool_execution 取消`, execID, toolName, status, waitText, elapsed)
return &ToolResult{Content: []Content{{Type: "text", Text: msg}}, IsError: true}
}
func isExecutionControlTool(toolName string) bool {
switch strings.TrimSpace(toolName) {
case builtin.ToolGetToolExecution, builtin.ToolWaitToolExecution, builtin.ToolCancelToolExecution:
return true
default:
return false
}
}
// BeginToolExecution 创建 running 状态的执行记录,供 Eino 等非 CallTool 路径在工具开始时落库。
@@ -1020,6 +1049,8 @@ func (s *Server) FinishToolExecution(ctx context.Context, executionID, toolName
var finalResult *ToolResult
s.mu.Lock()
maxBytes := s.toolResultMaxBytes
spillRoot := s.spillRootDir
exec, inMem := s.executions[id]
if !inMem || exec == nil {
exec = &ToolExecution{
@@ -1047,12 +1078,19 @@ func (s *Server) FinishToolExecution(ctx context.Context, executionID, toolName
}
exec.Duration = now.Sub(exec.StartTime)
spill := ToolResultSpillConfig{
RootDir: spillRoot,
ProjectID: MCPProjectIDFromContext(ctx),
ConversationID: exec.ConversationID,
ExecutionID: id,
}
if failed {
st, msg := executionStatusAndMessage(invokeErr)
exec.Status = st
exec.Error = msg
if strings.TrimSpace(resultText) != "" {
finalResult = &ToolResult{Content: []Content{{Type: "text", Text: resultText}}}
finalResult = NormalizeToolResultForStorageWithSpill(finalResult, maxBytes, spill)
exec.Result = finalResult
}
} else {
@@ -1062,6 +1100,7 @@ func (s *Server) FinishToolExecution(ctx context.Context, executionID, toolName
text = "(无输出)"
}
finalResult = &ToolResult{Content: []Content{{Type: "text", Text: text}}}
finalResult = NormalizeToolResultForStorageWithSpill(finalResult, maxBytes, spill)
exec.Result = finalResult
}
s.mu.Unlock()
@@ -1082,6 +1121,25 @@ func (s *Server) FinishToolExecution(ctx context.Context, executionID, toolName
return id
}
// AppendToolExecutionPartialOutput records a bounded tail preview for a running local execution.
// The final Result remains authoritative and is written only when the tool finishes.
func (s *Server) AppendToolExecutionPartialOutput(executionID, chunk string) {
if s == nil || strings.TrimSpace(executionID) == "" || chunk == "" {
return
}
id := strings.TrimSpace(executionID)
if s.executionService != nil && s.executionService.AppendPartialOutput(id, chunk) {
return
}
now := time.Now()
s.mu.Lock()
exec := s.executions[id]
if exec != nil {
appendPartialOutput(exec, chunk, defaultPartialOutputMaxBytes, now)
}
s.mu.Unlock()
}
// RecordCompletedToolInvocation 将已在其它路径完成的工具调用写入监控存储(格式与 CallTool 结束后一致),
// 用于 Eino ADK filesystem execute 等未经过 CallTool 的场景;返回 executionId 供助手消息 mcpExecutionIds 关联。
func (s *Server) RecordCompletedToolInvocation(ctx context.Context, toolName string, args map[string]interface{}, resultText string, invokeErr error) string {
@@ -1098,8 +1156,16 @@ func (s *Server) UpdateToolExecutionResult(executionID string, result *ToolResul
return nil
}
s.mu.Lock()
spill := ToolResultSpillConfig{
RootDir: s.spillRootDir,
ExecutionID: executionID,
}
if exec, ok := s.executions[executionID]; ok && exec != nil {
spill.ConversationID = exec.ConversationID
result = NormalizeToolResultForStorageWithSpill(result, s.toolResultMaxBytes, spill)
exec.Result = result
} else {
result = NormalizeToolResultForStorageWithSpill(result, s.toolResultMaxBytes, spill)
}
s.mu.Unlock()
if s.storage != nil {
@@ -1157,6 +1223,24 @@ func (s *Server) unregisterRunningCancel(id string) {
s.runningCancelsMu.Unlock()
}
// RegisterToolExecutionCancel lets non-ExecutionService tool paths, such as Eino
// filesystem execute, participate in cancel_tool_execution by execution_id.
func (s *Server) RegisterToolExecutionCancel(id string, cancel context.CancelFunc) {
id = strings.TrimSpace(id)
if s == nil || id == "" || cancel == nil {
return
}
s.registerRunningCancel(id, cancel)
}
func (s *Server) UnregisterToolExecutionCancel(id string) {
id = strings.TrimSpace(id)
if s == nil || id == "" {
return
}
s.unregisterRunningCancel(id)
}
func (s *Server) readAbortUserNote(id string) string {
s.runningCancelsMu.Lock()
defer s.runningCancelsMu.Unlock()
@@ -1205,6 +1289,9 @@ func (s *Server) applyAbortUserNoteToCancelledToolResult(executionID string, res
// CancelToolExecutionWithNote 取消内部工具;note 非空时与工具已返回文本合并后交给上层模型。
func (s *Server) CancelToolExecutionWithNote(id string, note string) bool {
if s.executionService != nil && s.executionService.Cancel(id, note) {
return true
}
s.runningCancelsMu.Lock()
cancel, ok := s.runningCancels[id]
if !ok || cancel == nil {
@@ -1232,12 +1319,17 @@ func (s *Server) ActiveRunningExecutionIDs() map[string]struct{} {
if s == nil {
return nil
}
out := make(map[string]struct{})
if s.executionService != nil {
for id := range s.executionService.ActiveRunningExecutionIDs() {
out[id] = struct{}{}
}
}
s.runningCancelsMu.Lock()
defer s.runningCancelsMu.Unlock()
if len(s.runningCancels) == 0 {
if len(s.runningCancels) == 0 && len(out) == 0 {
return nil
}
out := make(map[string]struct{}, len(s.runningCancels))
for id := range s.runningCancels {
out[id] = struct{}{}
}
+196 -1
View File
@@ -3,7 +3,9 @@ package mcp
import (
"context"
"errors"
"strings"
"testing"
"time"
"cyberstrike-ai/internal/authctx"
@@ -21,9 +23,20 @@ func TestToolAuthorizerIsUniversalAndExecutionKeepsOwner(t *testing.T) {
}
return nil
})
if _, _, err := server.CallTool(context.Background(), "echo", nil); err == nil {
_, deniedExecutionID, err := server.CallTool(context.Background(), "echo", nil)
if err == nil {
t.Fatal("tool call without principal was allowed")
}
if deniedExecutionID == "" {
t.Fatal("denied tool call should still return an execution id")
}
deniedExecution, ok := server.GetExecution(deniedExecutionID)
if !ok || deniedExecution == nil {
t.Fatalf("missing denied execution %q", deniedExecutionID)
}
if deniedExecution.Status != ToolExecutionStatusFailed || !strings.Contains(deniedExecution.Error, "principal required") {
t.Fatalf("denied execution = %#v, want failed with authorization error", deniedExecution)
}
ctx := authctx.WithPrincipal(context.Background(), authctx.NewPrincipal("u1", "user", "assigned", map[string]bool{"mcp:execute": true}))
_, executionID, err := server.CallTool(ctx, "echo", nil)
if err != nil {
@@ -34,3 +47,185 @@ func TestToolAuthorizerIsUniversalAndExecutionKeepsOwner(t *testing.T) {
t.Fatalf("execution owner = %#v, want u1", execution)
}
}
func TestServerCallToolBoundedWaitForInternalTool(t *testing.T) {
server := NewServer(zap.NewNop())
server.toolWaitTimeout = 10 * time.Millisecond
release := make(chan struct{})
started := make(chan struct{})
server.RegisterTool(Tool{Name: "slow", InputSchema: map[string]interface{}{"type": "object"}}, func(ctx context.Context, args map[string]interface{}) (*ToolResult, error) {
close(started)
select {
case <-release:
return &ToolResult{Content: []Content{{Type: "text", Text: "internal done"}}}, nil
case <-ctx.Done():
return nil, ctx.Err()
}
})
callCtx, callCancel := context.WithCancel(context.Background())
result, executionID, err := server.CallTool(callCtx, "slow", nil)
if err != nil {
t.Fatalf("CallTool returned error: %v", err)
}
if executionID == "" || result == nil || !result.IsError {
t.Fatalf("expected soft timeout with execution id, result=%#v id=%q", result, executionID)
}
if text := ToolResultPlainText(result); !strings.Contains(text, executionID) || !strings.Contains(text, "wait_tool_execution") {
t.Fatalf("timeout result missing execution guidance: %q", text)
}
select {
case <-started:
default:
t.Fatal("internal worker did not start")
}
callCancel()
close(release)
snapshot, err := server.executionService.Wait(context.Background(), executionID, time.Second)
if err != nil {
t.Fatalf("wait internal execution: %v", err)
}
if snapshot == nil || snapshot.Execution == nil || snapshot.Execution.Status != ToolExecutionStatusCompleted {
t.Fatalf("snapshot = %#v, want completed", snapshot)
}
if got := ToolResultPlainText(snapshot.Execution.Result); got != "internal done" {
t.Fatalf("result = %q, want internal done", got)
}
}
func TestWaitToolExecutionWaitsForInternalActiveExecution(t *testing.T) {
server := NewServer(zap.NewNop())
server.toolWaitTimeout = 10 * time.Millisecond
release := make(chan struct{})
server.RegisterTool(Tool{Name: "slow", InputSchema: map[string]interface{}{"type": "object"}}, func(ctx context.Context, args map[string]interface{}) (*ToolResult, error) {
select {
case <-release:
return &ToolResult{Content: []Content{{Type: "text", Text: "wait saw completion"}}}, nil
case <-ctx.Done():
return nil, ctx.Err()
}
})
RegisterExecutionControlTools(server, nil)
result, executionID, err := server.CallTool(context.Background(), "slow", nil)
if err != nil {
t.Fatalf("CallTool returned error: %v", err)
}
if result == nil || !result.IsError || executionID == "" {
t.Fatalf("expected initial bounded wait timeout, result=%#v id=%q", result, executionID)
}
done := make(chan *ToolResult, 1)
errCh := make(chan error, 1)
go func() {
waitResult, _, waitErr := server.CallTool(context.Background(), "wait_tool_execution", map[string]interface{}{
"execution_id": executionID,
"timeout_seconds": 1,
})
if waitErr != nil {
errCh <- waitErr
return
}
done <- waitResult
}()
select {
case <-done:
t.Fatal("wait_tool_execution returned before target execution completed")
case err := <-errCh:
t.Fatalf("wait_tool_execution errored before release: %v", err)
case <-time.After(50 * time.Millisecond):
}
close(release)
select {
case err := <-errCh:
t.Fatalf("wait_tool_execution returned error: %v", err)
case waitResult := <-done:
if waitResult == nil || waitResult.IsError {
t.Fatalf("expected successful wait result, got %#v", waitResult)
}
if body := ToolResultPlainText(waitResult); !strings.Contains(body, "wait saw completion") || !strings.Contains(body, `"status": "completed"`) {
t.Fatalf("wait result missing completed target: %s", body)
}
case <-time.After(time.Second):
t.Fatal("wait_tool_execution did not return after target completion")
}
}
func TestWaitToolExecutionTimeoutIsObservationNotFailure(t *testing.T) {
server := NewServer(zap.NewNop())
server.toolWaitTimeout = 10 * time.Millisecond
release := make(chan struct{})
server.RegisterTool(Tool{Name: "slow_observed", InputSchema: map[string]interface{}{"type": "object"}}, func(ctx context.Context, args map[string]interface{}) (*ToolResult, error) {
<-release
return &ToolResult{Content: []Content{{Type: "text", Text: "done"}}}, nil
})
RegisterExecutionControlTools(server, nil)
result, executionID, err := server.CallTool(context.Background(), "slow_observed", nil)
if err != nil {
t.Fatalf("CallTool returned error: %v", err)
}
if result == nil || !result.IsError || executionID == "" {
t.Fatalf("expected initial bounded wait timeout, result=%#v id=%q", result, executionID)
}
waitResult, _, err := server.CallTool(context.Background(), "wait_tool_execution", map[string]interface{}{
"execution_id": executionID,
"timeout_seconds": 0.01,
})
if err != nil {
t.Fatalf("wait_tool_execution returned error: %v", err)
}
if waitResult == nil {
t.Fatal("missing wait result")
}
if waitResult.IsError {
t.Fatalf("wait timeout should be a successful observation, got %#v", waitResult)
}
body := ToolResultPlainText(waitResult)
if !strings.Contains(body, `"status": "running"`) || !strings.Contains(body, "本次等待已到达") {
t.Fatalf("wait timeout body missing running status/guidance: %s", body)
}
close(release)
}
func TestGetToolExecutionIncludesBoundedPartialOutput(t *testing.T) {
server := NewServer(zap.NewNop())
RegisterExecutionControlTools(server, nil)
executionID := server.BeginToolExecution(context.Background(), "execute", map[string]interface{}{"command": "demo"})
if executionID == "" {
t.Fatal("missing execution id")
}
server.AppendToolExecutionPartialOutput(executionID, "first\n")
server.AppendToolExecutionPartialOutput(executionID, strings.Repeat("x", 32))
result, _, err := server.CallTool(context.Background(), "get_tool_execution", map[string]interface{}{
"execution_id": executionID,
"partial_output_max_bytes": 8,
})
if err != nil {
t.Fatalf("get_tool_execution: %v", err)
}
body := ToolResultPlainText(result)
if !strings.Contains(body, `"partial_output": "xxxxxxxx"`) {
t.Fatalf("missing bounded partial output: %s", body)
}
if !strings.Contains(body, `"partial_output_bytes": 38`) {
t.Fatalf("missing partial byte count: %s", body)
}
result, _, err = server.CallTool(context.Background(), "get_tool_execution", map[string]interface{}{
"execution_id": executionID,
"include_partial_output": false,
})
if err != nil {
t.Fatalf("get_tool_execution without partial: %v", err)
}
if body := ToolResultPlainText(result); strings.Contains(body, "partial_output") {
t.Fatalf("partial output should be omitted: %s", body)
}
}
+65
View File
@@ -0,0 +1,65 @@
package mcp
import "cyberstrike-ai/internal/tooloutput"
const DefaultToolResultMaxBytes = 12000
// ToolResultSpillConfig controls where oversized tool results are written on disk
// before the in-memory/DB/agent-facing payload is truncated.
type ToolResultSpillConfig struct {
RootDir string
ProjectID string
ConversationID string
ExecutionID string
}
// NormalizeToolResultForStorage returns the canonical result used by both the
// agent-facing response and monitor persistence. When maxBytes is exceeded the
// full text is spilled under the reduction cache tree and replaced with a
// <persisted-output> notice that includes the file path.
func NormalizeToolResultForStorage(result *ToolResult, maxBytes int) *ToolResult {
return NormalizeToolResultForStorageWithSpill(result, maxBytes, ToolResultSpillConfig{})
}
// NormalizeToolResultForStorageWithSpill is NormalizeToolResultForStorage with
// an explicit spill location (conversation/execution scoped).
func NormalizeToolResultForStorageWithSpill(result *ToolResult, maxBytes int, spill ToolResultSpillConfig) *ToolResult {
if result == nil {
return nil
}
out := cloneToolResult(result)
if maxBytes <= 0 {
return out
}
total := 0
for _, c := range out.Content {
if c.Type == "text" {
total += len(c.Text)
}
}
if total <= maxBytes {
return out
}
full := ToolResultPlainText(out)
bound := tooloutput.BoundWithSpill(full, maxBytes, tooloutput.SpillOpts{
RootDir: spill.RootDir,
ProjectID: spill.ProjectID,
ConversationID: spill.ConversationID,
ExecutionID: spill.ExecutionID,
})
out.Content = []Content{{Type: "text", Text: bound}}
return out
}
func cloneToolResult(in *ToolResult) *ToolResult {
if in == nil {
return nil
}
out := *in
if in.Content != nil {
out.Content = append([]Content(nil), in.Content...)
}
return &out
}
+158
View File
@@ -0,0 +1,158 @@
package mcp
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
"go.uber.org/zap"
)
type inMemoryMonitorStorage struct {
executions map[string]*ToolExecution
}
func newInMemoryMonitorStorage() *inMemoryMonitorStorage {
return &inMemoryMonitorStorage{executions: map[string]*ToolExecution{}}
}
func (s *inMemoryMonitorStorage) SaveToolExecution(exec *ToolExecution) error {
if exec != nil {
s.executions[exec.ID] = cloneToolExecution(exec)
}
return nil
}
func (s *inMemoryMonitorStorage) UpdateToolExecutionResult(id string, result *ToolResult) error {
exec := s.executions[id]
if exec == nil {
exec = &ToolExecution{ID: id}
s.executions[id] = exec
}
exec.Result = cloneToolResult(result)
return nil
}
func (s *inMemoryMonitorStorage) LoadToolExecutions() ([]*ToolExecution, error) {
out := make([]*ToolExecution, 0, len(s.executions))
for _, exec := range s.executions {
out = append(out, cloneToolExecution(exec))
}
return out, nil
}
func (s *inMemoryMonitorStorage) GetToolExecution(id string) (*ToolExecution, error) {
if exec := s.executions[id]; exec != nil {
return cloneToolExecution(exec), nil
}
return nil, nil
}
func (s *inMemoryMonitorStorage) SaveToolStats(string, *ToolStats) error { return nil }
func (s *inMemoryMonitorStorage) LoadToolStats() (map[string]*ToolStats, error) {
return map[string]*ToolStats{}, nil
}
func (s *inMemoryMonitorStorage) UpdateToolStats(string, int, int, int, *time.Time) error {
return nil
}
func TestServerCallToolStoresAndReturnsSameGuardedResult(t *testing.T) {
storage := newInMemoryMonitorStorage()
server := NewServerWithStorage(zap.NewNop(), storage)
server.ConfigureToolWaitTimeoutSeconds(0)
server.ConfigureToolResultMaxBytes(400)
spillRoot := t.TempDir()
server.ConfigureToolResultSpillRoot(spillRoot)
server.RegisterTool(Tool{Name: "big", InputSchema: map[string]interface{}{"type": "object"}}, func(ctx context.Context, args map[string]interface{}) (*ToolResult, error) {
return &ToolResult{Content: []Content{{Type: "text", Text: strings.Repeat("x", 800)}}}, nil
})
ctx := WithMCPConversationID(context.Background(), "conv-spill")
result, executionID, err := server.CallTool(ctx, "big", nil)
if err != nil {
t.Fatalf("CallTool: %v", err)
}
if executionID == "" {
t.Fatal("missing execution id")
}
returned := ToolResultPlainText(result)
if !strings.Contains(returned, "<persisted-output>") || !strings.Contains(returned, "Full output saved to:") {
t.Fatalf("returned result was not spilled: %q", returned)
}
if len(returned) > 400 {
t.Fatalf("returned result exceeded hard limit: len=%d text=%q", len(returned), returned)
}
spillPath := filepath.Join(spillRoot, "conversations", "conv-spill", "trunc", executionID)
abs, err := filepath.Abs(spillPath)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(returned, abs) {
t.Fatalf("missing spill path %q in %q", abs, returned)
}
body, err := os.ReadFile(abs)
if err != nil {
t.Fatalf("read spill file: %v", err)
}
if string(body) != strings.Repeat("x", 800) {
t.Fatalf("spill body mismatch: len=%d", len(body))
}
inMem, ok := server.GetExecution(executionID)
if !ok || inMem == nil || inMem.Result == nil {
t.Fatalf("missing in-memory execution: %#v", inMem)
}
stored := storage.executions[executionID]
if stored == nil || stored.Result == nil {
t.Fatalf("missing stored execution: %#v", stored)
}
if ToolResultPlainText(inMem.Result) != returned {
t.Fatalf("in-memory result != returned\nmem=%q\nret=%q", ToolResultPlainText(inMem.Result), returned)
}
if ToolResultPlainText(stored.Result) != returned {
t.Fatalf("stored result != returned\nstored=%q\nret=%q", ToolResultPlainText(stored.Result), returned)
}
}
func TestExecutionServiceStoresGuardedResult(t *testing.T) {
service := NewExecutionService(nil, zap.NewNop())
service.ConfigureToolResultMaxBytes(400)
spillRoot := t.TempDir()
service.ConfigureToolResultSpillRoot(spillRoot)
handle, err := service.Submit(context.Background(), ExecutionRequest{
ToolName: "big",
ConversationID: "svc-conv",
Run: func(context.Context) (*ToolResult, error) {
return &ToolResult{Content: []Content{{Type: "text", Text: strings.Repeat("a", 800)}}}, nil
},
})
if err != nil {
t.Fatalf("Submit: %v", err)
}
snap, err := service.Wait(context.Background(), handle.ID, time.Second)
if err != nil {
t.Fatalf("Wait: %v", err)
}
got := ToolResultPlainText(snap.Execution.Result)
if !strings.Contains(got, "<persisted-output>") {
t.Fatalf("service result was not spilled: %q", got)
}
if len(got) > 400 {
t.Fatalf("service result exceeded hard limit: len=%d text=%q", len(got), got)
}
path := filepath.Join(spillRoot, "conversations", "svc-conv", "trunc", handle.ID)
abs, _ := filepath.Abs(path)
body, err := os.ReadFile(abs)
if err != nil {
t.Fatalf("read spill: %v", err)
}
if string(body) != strings.Repeat("a", 800) {
t.Fatalf("unexpected spill body len=%d", len(body))
}
}
+6
View File
@@ -199,6 +199,12 @@ type ToolExecution struct {
StartTime time.Time `json:"startTime"`
EndTime *time.Time `json:"endTime,omitempty"`
Duration time.Duration `json:"duration,omitempty"`
// PartialOutput is a bounded tail preview of output produced by a running tool.
// It is intentionally separate from Result, which remains the final canonical tool result.
PartialOutput string `json:"partialOutput,omitempty"`
PartialOutputBytes int64 `json:"partialOutputBytes,omitempty"`
PartialOutputTruncated bool `json:"partialOutputTruncated,omitempty"`
PartialOutputUpdatedAt *time.Time `json:"partialOutputUpdatedAt,omitempty"`
// ConversationID 仅 API 展示用(进行中的 Agent 任务),不写入 tool_executions 表。
ConversationID string `json:"conversationId,omitempty"`
OwnerUserID string `json:"-"`
+2 -2
View File
@@ -14,7 +14,7 @@ const (
staleRunningReconcileGap = 2 * time.Minute
)
// ExecutionReconciler 在启动或运行期将无对应协程的 running 执行记录收尾为 cancelled。
// ExecutionReconciler 在启动或运行期将无对应协程的 running 执行记录收尾为 orphaned。
type ExecutionReconciler struct {
db *database.DB
mcpServer *mcp.Server
@@ -32,7 +32,7 @@ func NewExecutionReconciler(db *database.DB, mcpServer *mcp.Server, externalMgr
}
}
// ReconcileOnStartup marks every persisted running row as cancelled (safe right after process start).
// ReconcileOnStartup marks every persisted running row as orphaned (safe right after process start).
func (r *ExecutionReconciler) ReconcileOnStartup() {
if r == nil || r.db == nil {
return
+2 -2
View File
@@ -32,7 +32,7 @@ func TestExecutionReconciler_ReconcileOnStartup(t *testing.T) {
if err != nil {
t.Fatalf("GetToolExecution: %v", err)
}
if got.Status != "cancelled" {
t.Fatalf("expected cancelled after startup reconcile, got %s", got.Status)
if got.Status != "orphaned" {
t.Fatalf("expected orphaned after startup reconcile, got %s", got.Status)
}
}
+48 -4
View File
@@ -8,6 +8,7 @@ import (
"io"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"sync/atomic"
@@ -315,10 +316,12 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
if len(preview) > 200 {
preview = preview[:200] + "..."
}
backgroundRunning := isErr && isMCPBackgroundWaitResult(content)
displayIsErr := isErr && !backgroundRunning
data := map[string]interface{}{
"toolName": toolName,
"success": !isErr,
"isError": isErr,
"success": !displayIsErr,
"isError": displayIsErr,
"result": content,
"resultPreview": preview,
"agentFacing": true, // 与 reduction 后送入 ChatModel 的正文一致,供前端展示
@@ -327,6 +330,13 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
"einoRole": einoRoleTag(agentName),
"source": "eino",
}
if backgroundRunning {
data["status"] = "background_running"
data["modelFacingIsError"] = isErr
if execID := mcpExecutionIDFromWaitResult(content); execID != "" {
data["executionId"] = execID
}
}
tid := strings.TrimSpace(toolCallID)
if tid == "" {
if inferred, ok := popNextPendingForAgent(agentName); ok {
@@ -347,8 +357,8 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
data["toolCallId"] = tid
toolCallID = tid
}
recordPendingExecuteStdoutDup(toolName, content, isErr)
recordEinoADKFilesystemToolMonitor(ctx, args.FilesystemMonitorAgent, args.FilesystemMonitorRecord, args.MCPExecutionBinder, toolName, toolCallID, runAccumulatedMsgs, content, isErr)
recordPendingExecuteStdoutDup(toolName, content, displayIsErr)
recordEinoADKFilesystemToolMonitor(ctx, args.FilesystemMonitorAgent, args.FilesystemMonitorRecord, args.MCPExecutionBinder, toolName, toolCallID, runAccumulatedMsgs, content, displayIsErr)
if args.FilesystemMonitorAgent != nil && args.MCPExecutionBinder != nil {
if execID := args.MCPExecutionBinder.ExecutionID(toolCallID); execID != "" {
args.FilesystemMonitorAgent.UpdateMCPExecutionDisplayResult(execID, content)
@@ -1242,6 +1252,40 @@ func einoToolResultIsError(toolName, content string) bool {
return false
}
func isMCPBackgroundWaitResult(content string) bool {
text := strings.ToLower(strings.TrimSpace(content))
if text == "" {
return false
}
hasExecutionID := strings.Contains(text, "execution_id:") || strings.Contains(text, `"execution_id"`)
hasRunningStatus := strings.Contains(text, "status: running") || strings.Contains(text, "status: queued") ||
strings.Contains(text, `"status": "running"`) || strings.Contains(text, `"status":"running"`) ||
strings.Contains(text, `"status": "queued"`) || strings.Contains(text, `"status":"queued"`)
hasSoftWaitSignal := strings.Contains(text, "工具已提交到后台执行") ||
strings.Contains(text, "本次等待已到达") ||
strings.Contains(text, "wait_timeout:") ||
strings.Contains(text, "background execution") ||
strings.Contains(text, "still running") ||
strings.Contains(text, "仍未完成")
return hasExecutionID && hasRunningStatus && hasSoftWaitSignal
}
func mcpExecutionIDFromWaitResult(content string) string {
re := regexp.MustCompile(`(?i)"?execution_id"?\s*[:=]\s*"?([0-9a-f]{8}-[0-9a-f-]{12,})"?`)
if m := re.FindStringSubmatch(content); len(m) > 1 {
return strings.TrimSpace(m[1])
}
for _, line := range strings.Split(content, "\n") {
line = strings.TrimSpace(line)
lower := strings.ToLower(line)
if !strings.HasPrefix(lower, "execution_id:") {
continue
}
return strings.Trim(strings.TrimSpace(line[len("execution_id:"):]), `"'`)
}
return ""
}
// einoToolResultBody 去掉工具错误前缀,返回展示/持久化正文。
func einoToolResultBody(content string) string {
if strings.HasPrefix(content, einomcp.ToolErrorPrefix) {
@@ -112,3 +112,41 @@ func TestFriendlyEinoExecuteInvokeTail(t *testing.T) {
t.Fatal("unexpected error should get tail")
}
}
func TestMCPBackgroundWaitResultIsDisplayRunning(t *testing.T) {
body := `工具已提交到后台执行但本次等待已到达上限
execution_id: 3eaaa391-050b-4be1-a870-48a855923cb7
tool: exec
status: running
wait_timeout: 10s
elapsed: 10s
你可以继续推理改用其他工具或调用 wait_tool_execution 继续等待该 execution_id也可以调用 cancel_tool_execution 取消`
modelFacing := einomcp.ToolErrorPrefix + body
if !einoToolResultIsError("exec", modelFacing) {
t.Fatal("soft wait timeout must remain model-facing tool error")
}
if !isMCPBackgroundWaitResult(einoToolResultBody(modelFacing)) {
t.Fatal("soft wait timeout should display as background running")
}
if got := mcpExecutionIDFromWaitResult(einoToolResultBody(modelFacing)); got != "3eaaa391-050b-4be1-a870-48a855923cb7" {
t.Fatalf("execution id = %q", got)
}
if isMCPBackgroundWaitResult("execution_id: abc\nstatus: failed\nerror: boom") {
t.Fatal("real failures must not display as background running")
}
jsonBody := `{
"execution_id": "e98baefc-72eb-4a7e-9091-9be179a75d71",
"tool": "exec",
"status": "running"
}
本次等待已到达 timeout_seconds上述 execution 仍未完成可继续等待取消或采用其他步骤`
if !isMCPBackgroundWaitResult(jsonBody) {
t.Fatal("json wait_tool_execution timeout should display as background running")
}
if got := mcpExecutionIDFromWaitResult(jsonBody); got != "e98baefc-72eb-4a7e-9091-9be179a75d71" {
t.Fatalf("json execution id = %q", got)
}
}
+22 -1
View File
@@ -12,6 +12,9 @@ import (
// 与 CallTool 路径一致,使监控页能展示「执行中」状态。
func newEinoExecuteMonitorCallbacks(ctx context.Context, ag *agent.Agent, recorder einomcp.ExecutionRecorder) (
begin func(toolCallID, command string) string,
appendPartial func(executionID, toolCallID, chunk string),
registerCancel func(executionID string, cancel context.CancelFunc),
unregisterCancel func(executionID string),
finish func(executionID, toolCallID, command, stdout string, success bool, invokeErr error),
) {
begin = func(toolCallID, command string) string {
@@ -25,6 +28,24 @@ func newEinoExecuteMonitorCallbacks(ctx context.Context, ag *agent.Agent, record
}
return id
}
appendPartial = func(executionID, toolCallID, chunk string) {
if ag == nil || executionID == "" || chunk == "" {
return
}
ag.AppendLocalToolExecutionPartialOutput(executionID, chunk)
}
registerCancel = func(executionID string, cancel context.CancelFunc) {
if ag == nil || executionID == "" || cancel == nil {
return
}
ag.RegisterLocalToolExecutionCancel(executionID, cancel)
}
unregisterCancel = func(executionID string) {
if ag == nil || executionID == "" {
return
}
ag.UnregisterLocalToolExecutionCancel(executionID)
}
finish = func(executionID, toolCallID, command, stdout string, success bool, invokeErr error) {
if ag == nil {
return
@@ -43,5 +64,5 @@ func newEinoExecuteMonitorCallbacks(ctx context.Context, ag *agent.Agent, record
recorder(id, toolCallID)
}
}
return begin, finish
return begin, appendPartial, registerCancel, unregisterCancel, finish
}
@@ -63,11 +63,16 @@ type einoStreamingShellWrap struct {
outputChunk func(toolName, toolCallID, chunk string)
// toolTimeoutMinutes 与 agent.tool_timeout_minutes 对齐;>0 时对单次 execute 套用 context 超时(与 MCP 工具经 executeToolViaMCP 行为一致)。0 表示仅依赖上层 ctx(如整任务 10h 上限)。
toolTimeoutMinutes int
// toolWaitTimeoutSeconds 与 agent.tool_wait_timeout_seconds 对齐;>0 时本轮等待到期后返回 execution_idshell 继续后台运行。
toolWaitTimeoutSeconds int
// shellNoOutputTimeoutSec:无任何输出时的空闲秒数;0=关闭。
shellNoOutputTimeoutSec int
// beginMonitor 在 execute 开始时写入 running 状态;finishMonitor 在流结束后更新为 completed/failed。
beginMonitor func(toolCallID, command string) string
finishMonitor func(executionID, toolCallID, command, stdout string, success bool, invokeErr error)
beginMonitor func(toolCallID, command string) string
appendPartialMonitor func(executionID, toolCallID, chunk string)
registerCancelMonitor func(executionID string, cancel context.CancelFunc)
unregisterCancelMonitor func(executionID string)
finishMonitor func(executionID, toolCallID, command, stdout string, success bool, invokeErr error)
}
func (w *einoStreamingShellWrap) ExecuteStreaming(ctx context.Context, input *filesystem.ExecuteRequest) (*schema.StreamReader[*filesystem.ExecuteResponse], error) {
@@ -104,6 +109,9 @@ func (w *einoStreamingShellWrap) ExecuteStreaming(ctx context.Context, input *fi
if w.toolTimeoutMinutes > 0 {
execCtx, timeoutCancel = context.WithTimeout(execCtx, time.Duration(w.toolTimeoutMinutes)*time.Minute)
}
if monitorExecID != "" && w.registerCancelMonitor != nil {
w.registerCancelMonitor(monitorExecID, execCancel)
}
if execReg != nil && convID != "" {
execReg.RegisterActiveEinoExecute(convID, execCancel)
}
@@ -116,6 +124,9 @@ func (w *einoStreamingShellWrap) ExecuteStreaming(ctx context.Context, input *fi
if execCancel != nil {
execCancel()
}
if monitorExecID != "" && w.unregisterCancelMonitor != nil {
w.unregisterCancelMonitor(monitorExecID)
}
if einoExecuteRecvErrIsToolTimeout(err, execCtx) {
hint := "\n\n" + einoExecuteTimeoutUserHint() + "\n"
if w.finishMonitor != nil {
@@ -146,7 +157,7 @@ func (w *einoStreamingShellWrap) ExecuteStreaming(ctx context.Context, input *fi
outR, outW := schema.Pipe[*filesystem.ExecuteResponse](32)
go func(inner *schema.StreamReader[*filesystem.ExecuteResponse], command string, cancel context.CancelFunc, timeoutCleanup context.CancelFunc, tctx context.Context, conversationID string, reg mcp.EinoExecuteRunRegistry, toolReg mcp.ToolRunRegistry, execID string, toolCallID string, noOutputSec int) {
go func(inner *schema.StreamReader[*filesystem.ExecuteResponse], command string, cancel context.CancelFunc, timeoutCleanup context.CancelFunc, tctx context.Context, conversationID string, reg mcp.EinoExecuteRunRegistry, toolReg mcp.ToolRunRegistry, execID string, toolCallID string, noOutputSec int, waitTimeoutSec int) {
var innerCloseOnce sync.Once
closeInner := func() {
innerCloseOnce.Do(func() { inner.Close() })
@@ -164,6 +175,9 @@ func (w *einoStreamingShellWrap) ExecuteStreaming(ctx context.Context, input *fi
if toolReg != nil && conversationID != "" && execID != "" {
defer toolReg.UnregisterRunningTool(conversationID, execID)
}
if w.unregisterCancelMonitor != nil && execID != "" {
defer w.unregisterCancelMonitor(execID)
}
// ctx 取消时关闭内层流,避免 amass 等长时间无换行输出时 Recv 永久阻塞。
stopWatch := make(chan struct{})
@@ -181,11 +195,30 @@ func (w *einoStreamingShellWrap) ExecuteStreaming(ctx context.Context, input *fi
var invokeErr error
exitCode := 0
hasExitCode := false
softReturned := false
var outCloseOnce sync.Once
closeOut := func() {
outCloseOnce.Do(func() { outW.Close() })
}
defer closeOut()
sendOut := func(resp *filesystem.ExecuteResponse, err error) bool {
if softReturned {
return false
}
return outW.Send(resp, err)
}
idleWatch := security.NewShellInactivityWatch(noOutputSec)
if idleWatch != nil {
defer idleWatch.Stop()
}
var waitTimeoutCh <-chan time.Time
var waitTimer *time.Timer
if waitTimeoutSec > 0 {
waitTimer = time.NewTimer(time.Duration(waitTimeoutSec) * time.Second)
waitTimeoutCh = waitTimer.C
defer waitTimer.Stop()
}
type execRecvMsg struct {
resp *filesystem.ExecuteResponse
@@ -206,8 +239,11 @@ func (w *einoStreamingShellWrap) ExecuteStreaming(ctx context.Context, input *fi
success = false
invokeErr = fmt.Errorf("shell inactivity timeout (%ds)", idleWatch.Sec)
msg := security.ShellNoOutputTimeoutMessage(idleWatch.Sec)
_ = outW.Send(&filesystem.ExecuteResponse{Output: msg}, nil)
_ = sendOut(&filesystem.ExecuteResponse{Output: msg}, nil)
sb.WriteString(msg)
if w.appendPartialMonitor != nil && execID != "" {
w.appendPartialMonitor(execID, toolCallID, msg)
}
if w.outputChunk != nil && toolCallID != "" {
w.outputChunk("execute", toolCallID, msg)
}
@@ -227,6 +263,14 @@ func (w *einoStreamingShellWrap) ExecuteStreaming(ctx context.Context, input *fi
case <-idleCh:
fireInactivityTimeout()
break recvLoop
case <-waitTimeoutCh:
if execID != "" && !softReturned {
msg := einoExecuteSoftWaitTimeoutResult(execID, waitTimeoutSec)
_ = outW.Send(&filesystem.ExecuteResponse{Output: msg}, nil)
softReturned = true
closeOut()
}
waitTimeoutCh = nil
case msg := <-recvCh:
rerr := msg.err
resp := msg.resp
@@ -244,7 +288,7 @@ func (w *einoStreamingShellWrap) ExecuteStreaming(ctx context.Context, input *fi
invokeErr = context.Canceled
break recvLoop
}
_ = outW.Send(nil, rerr)
_ = sendOut(nil, rerr)
break recvLoop
}
if resp != nil {
@@ -263,11 +307,14 @@ func (w *einoStreamingShellWrap) ExecuteStreaming(ctx context.Context, input *fi
}
sb.WriteString(resp.Output)
appended = resp.Output
if w.appendPartialMonitor != nil && execID != "" {
w.appendPartialMonitor(execID, toolCallID, appended)
}
}
if w.outputChunk != nil && strings.TrimSpace(appended) != "" {
w.outputChunk("execute", toolCallID, appended)
}
if outW.Send(resp, nil) {
if sendOut(resp, nil) {
success = false
invokeErr = fmt.Errorf("execute stream closed by consumer")
break recvLoop
@@ -304,7 +351,10 @@ func (w *einoStreamingShellWrap) ExecuteStreaming(ctx context.Context, input *fi
// ADK 从本 Pipe 拼出 tool 消息正文;仅 Notify 尾标不会进入模型上下文。超时句写入流,与 UI 一致。
if invokeErr != nil && errors.Is(invokeErr, context.DeadlineExceeded) {
hint := "\n\n" + einoExecuteTimeoutUserHint() + "\n"
_ = outW.Send(&filesystem.ExecuteResponse{Output: hint}, nil)
_ = sendOut(&filesystem.ExecuteResponse{Output: hint}, nil)
if w.appendPartialMonitor != nil && execID != "" {
w.appendPartialMonitor(execID, toolCallID, hint)
}
if w.outputChunk != nil && tid != "" {
w.outputChunk("execute", tid, hint)
}
@@ -313,9 +363,9 @@ func (w *einoStreamingShellWrap) ExecuteStreaming(ctx context.Context, input *fi
// 中断时循环内已逐行写入 stdout;此处只追加 USER INTERRUPT NOTE,避免整段输出重复。
if invokeErr != nil && errors.Is(invokeErr, context.Canceled) && abortNote != "" {
if partialStreamed != "" {
_ = outW.Send(&filesystem.ExecuteResponse{Output: "\n\n" + mcp.AbortNoteBannerForModel + "\n" + abortNote}, nil)
_ = sendOut(&filesystem.ExecuteResponse{Output: "\n\n" + mcp.AbortNoteBannerForModel + "\n" + abortNote}, nil)
} else if text := strings.TrimSpace(sb.String()); text != "" {
_ = outW.Send(&filesystem.ExecuteResponse{Output: text + "\n"}, nil)
_ = sendOut(&filesystem.ExecuteResponse{Output: text + "\n"}, nil)
}
}
rawOutput := sb.String()
@@ -323,7 +373,10 @@ func (w *einoStreamingShellWrap) ExecuteStreaming(ctx context.Context, input *fi
if !success && hasExitCode && exitCode != 0 {
statusLine := security.ExecuteFailureStatusLine(exitCode)
if !strings.Contains(rawOutput, "命令执行失败:") {
_ = outW.Send(&filesystem.ExecuteResponse{Output: statusLine}, nil)
_ = sendOut(&filesystem.ExecuteResponse{Output: statusLine}, nil)
if w.appendPartialMonitor != nil && execID != "" {
w.appendPartialMonitor(execID, toolCallID, statusLine)
}
sb.WriteString(statusLine)
}
fireBody = einomcp.ToolErrorPrefix + security.FormatCommandFailureResult(exitCode, rawOutput)
@@ -332,10 +385,25 @@ func (w *einoStreamingShellWrap) ExecuteStreaming(ctx context.Context, input *fi
w.finishMonitor(execID, toolCallID, command, sb.String(), success, invokeErr)
}
if w.invokeNotify != nil {
w.invokeNotify.Fire(toolCallID, "execute", agentTag, success, fireBody, invokeErr)
if !softReturned {
w.invokeNotify.Fire(toolCallID, "execute", agentTag, success, fireBody, invokeErr)
}
}
outW.Close()
}(sr, userCmd, execCancel, timeoutCancel, execCtx, convID, execReg, toolRunReg, monitorExecID, tid, w.shellNoOutputTimeoutSec)
}(sr, userCmd, execCancel, timeoutCancel, execCtx, convID, execReg, toolRunReg, monitorExecID, tid, w.shellNoOutputTimeoutSec, w.toolWaitTimeoutSeconds)
return outR, nil
}
func einoExecuteSoftWaitTimeoutResult(executionID string, waitTimeoutSec int) string {
waitText := "configured wait timeout"
if waitTimeoutSec > 0 {
waitText = fmt.Sprintf("%ds", waitTimeoutSec)
}
return fmt.Sprintf(`工具已提交到后台执行当前仍在运行
execution_id: %s
status: running
wait_timeout: %s
你可以继续推理改用其他工具或调用 get_tool_execution / wait_tool_execution 读取 partial_output 并继续等待也可以调用 cancel_tool_execution 取消`, executionID, waitText)
}
@@ -153,6 +153,81 @@ func TestEinoStreamingShellWrap_InactivityAfterPartialOutput(t *testing.T) {
}
}
func TestEinoStreamingShellWrap_SoftWaitTimeoutReturnsExecutionIDAndKeepsRunning(t *testing.T) {
inner := &mockStreamingShellPartialThenHang{}
partialCh := make(chan string, 4)
cancelCh := make(chan context.CancelFunc, 1)
unregistered := make(chan string, 1)
wrap := &einoStreamingShellWrap{
inner: inner,
toolWaitTimeoutSeconds: 1,
beginMonitor: func(toolCallID, command string) string {
return "exec-soft-wait"
},
appendPartialMonitor: func(executionID, toolCallID, chunk string) {
partialCh <- chunk
},
registerCancelMonitor: func(executionID string, cancel context.CancelFunc) {
if executionID == "exec-soft-wait" {
cancelCh <- cancel
}
},
unregisterCancelMonitor: func(executionID string) {
unregistered <- executionID
},
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
sr, err := wrap.ExecuteStreaming(ctx, &filesystem.ExecuteRequest{Command: "sudo whoami"})
if err != nil {
t.Fatalf("ExecuteStreaming: %v", err)
}
defer sr.Close()
var got strings.Builder
for {
resp, rerr := sr.Recv()
if errors.Is(rerr, io.EOF) {
break
}
if rerr != nil {
t.Fatalf("recv: %v", rerr)
}
if resp != nil {
got.WriteString(resp.Output)
}
}
body := got.String()
if !strings.Contains(body, "execution_id: exec-soft-wait") || !strings.Contains(body, "status: running") {
t.Fatalf("expected background execution marker, got: %q", body)
}
select {
case chunk := <-partialCh:
if !strings.Contains(chunk, "[sudo] password") {
t.Fatalf("unexpected partial chunk: %q", chunk)
}
default:
t.Fatal("expected streamed partial output before soft wait return")
}
if !inner.called {
t.Fatal("inner shell did not run")
}
select {
case registeredCancel := <-cancelCh:
registeredCancel()
case <-time.After(time.Second):
t.Fatal("expected execution cancel registration")
}
select {
case id := <-unregistered:
if id != "exec-soft-wait" {
t.Fatalf("unexpected unregistered id: %q", id)
}
case <-time.After(time.Second):
t.Fatal("expected execution cancel unregister")
}
}
type mockStreamingShellHanging struct {
called bool
}
+2 -2
View File
@@ -82,7 +82,7 @@ func RunEinoSingleChatModelAgent(
}
toolInvokeNotify := einomcp.NewToolInvokeNotifyHolder()
einoExecBegin, einoExecFinish := newEinoExecuteMonitorCallbacks(ctx, ag, recorder)
einoExecBegin, einoExecAppendPartial, einoExecRegisterCancel, einoExecUnregisterCancel, einoExecFinish := newEinoExecuteMonitorCallbacks(ctx, ag, recorder)
mainDefs := ag.ToolsForRole(roleTools)
mainTools, err := einomcp.ToolsFromDefinitions(ag, holder, mainDefs, recorder, nil, toolInvokeNotify, einoSingleAgentName)
if err != nil {
@@ -139,7 +139,7 @@ func RunEinoSingleChatModelAgent(
}
if einoSkillMW != nil {
if einoFSTools && einoLoc != nil {
fsMw, fsErr := subAgentFilesystemMiddleware(ctx, einoLoc, toolInvokeNotify, einoSingleAgentName, einoExecBegin, einoExecFinish, agentToolTimeoutMinutes(appCfg), agentShellNoOutputTimeoutSeconds(appCfg), nil)
fsMw, fsErr := subAgentFilesystemMiddleware(ctx, einoLoc, toolInvokeNotify, einoSingleAgentName, einoExecBegin, einoExecAppendPartial, einoExecRegisterCancel, einoExecUnregisterCancel, einoExecFinish, agentToolTimeoutMinutes(appCfg), agentToolWaitTimeoutSeconds(appCfg), agentShellNoOutputTimeoutSeconds(appCfg), nil)
if fsErr != nil {
return nil, fmt.Errorf("eino single filesystem 中间件: %w", fsErr)
}
+15
View File
@@ -107,8 +107,12 @@ func subAgentFilesystemMiddleware(
invokeNotify *einomcp.ToolInvokeNotifyHolder,
einoAgentName string,
beginMonitor func(toolCallID, command string) string,
appendPartialMonitor func(executionID, toolCallID, chunk string),
registerCancelMonitor func(executionID string, cancel context.CancelFunc),
unregisterCancelMonitor func(executionID string),
finishMonitor func(executionID, toolCallID, command, stdout string, success bool, invokeErr error),
toolTimeoutMinutes int,
toolWaitTimeoutSeconds int,
shellNoOutputTimeoutSec int,
outputChunk func(toolName, toolCallID, chunk string),
) (adk.ChatModelAgentMiddleware, error) {
@@ -123,8 +127,12 @@ func subAgentFilesystemMiddleware(
einoAgentName: strings.TrimSpace(einoAgentName),
outputChunk: outputChunk,
beginMonitor: beginMonitor,
appendPartialMonitor: appendPartialMonitor,
registerCancelMonitor: registerCancelMonitor,
unregisterCancelMonitor: unregisterCancelMonitor,
finishMonitor: finishMonitor,
toolTimeoutMinutes: toolTimeoutMinutes,
toolWaitTimeoutSeconds: toolWaitTimeoutSeconds,
shellNoOutputTimeoutSec: shellNoOutputTimeoutSec,
},
})
@@ -138,6 +146,13 @@ func agentToolTimeoutMinutes(cfg *config.Config) int {
return cfg.Agent.ToolTimeoutMinutes
}
func agentToolWaitTimeoutSeconds(cfg *config.Config) int {
if cfg == nil {
return 0
}
return cfg.Agent.ToolWaitTimeoutSeconds
}
// agentShellNoOutputTimeoutSeconds0=默认 300s5 分钟);-1=关闭;>0=自定义秒数。
func agentShellNoOutputTimeoutSeconds(cfg *config.Config) int {
if cfg == nil {
+5 -1
View File
@@ -60,6 +60,7 @@ func isEinoTransientRunError(err error) bool {
"bad gateway",
"gateway timeout",
"internal server error",
"unexpected internal error",
"connection reset",
"connection refused",
"connection closed",
@@ -72,6 +73,7 @@ func isEinoTransientRunError(err error) bool {
"dial tcp",
"tls handshake timeout",
"stream error",
"failed to receive stream chunk",
"goaway", // http2: server sent GOAWAY and closed the connection
"unexpected eof",
`": eof`, // net/http: Post "url": EOF (often wraps io.EOF)
@@ -136,7 +138,8 @@ func einoTransientRunErrorUserDetail(err error) (kind, summary string) {
case strings.Contains(lower, "overloaded") ||
strings.Contains(lower, "capacity") ||
strings.Contains(lower, "temporarily unavailable") ||
strings.Contains(lower, "service unavailable"):
strings.Contains(lower, "service unavailable") ||
strings.Contains(lower, "unexpected internal error"):
kind = "upstream_busy"
case strings.Contains(lower, "connection reset") ||
strings.Contains(lower, "connection refused") ||
@@ -153,6 +156,7 @@ func einoTransientRunErrorUserDetail(err error) (kind, summary string) {
strings.Contains(lower, "unexpected eof"):
kind = "network"
case strings.Contains(lower, "stream error") ||
strings.Contains(lower, "failed to receive stream chunk") ||
strings.Contains(lower, "unexpected end of json"):
kind = "stream"
default:
@@ -34,6 +34,7 @@ func TestIsEinoTransientRunError(t *testing.T) {
{"rate limit", errors.New(`{"error":"rate limit exceeded"}`), true},
{"connection reset", errors.New("read tcp: connection reset by peer"), true},
{"http2 goaway", errors.New("failed to receive stream chunk: error, http2: server sent GOAWAY and closed the connection; LastStreamID=791, ErrCode=NO_ERROR"), true},
{"unexpected internal stream chunk", errors.New("failed to receive stream chunk: error, The service encountered an unexpected internal error. Request id: 0217851391106464f01ec66621d0980a42fd45436ed75957a6a0a"), true},
{"unexpected eof", errors.New("unexpected EOF"), true},
{"503", errors.New("upstream returned 503"), true},
{"iteration limit", errors.New("max iteration reached"), false},
@@ -74,6 +75,7 @@ func TestEinoTransientRunErrorUserDetail(t *testing.T) {
{"upstream", errors.New("upstream returned 503"), "upstream_server"},
{"network", errors.New("read tcp: connection reset by peer"), "network"},
{"stream", errors.New("unexpected end of JSON"), "stream"},
{"stream chunk", errors.New("failed to receive stream chunk: error, The service encountered an unexpected internal error. Request id: abc"), "upstream_busy"},
}
for _, tc := range cases {
tc := tc
+67 -2
View File
@@ -3,6 +3,7 @@ package multiagent
import (
"context"
"errors"
"io"
"strings"
"github.com/cloudwego/eino/adk"
@@ -65,11 +66,51 @@ func hitlClearReturnDirectlyIfTransfer(ctx context.Context, toolName string) {
})
}
func hitlEditedArgumentsNotice(original, edited string) string {
original = strings.TrimSpace(original)
edited = strings.TrimSpace(edited)
if edited == "" || edited == original {
return ""
}
return "[HITL] Human reviewer approved this tool call with edited arguments.\n" +
"Original arguments: " + original + "\n" +
"Executed arguments: " + edited + "\n\n"
}
func hitlPrependEditedArgumentsNotice(result, original, edited string) string {
notice := hitlEditedArgumentsNotice(original, edited)
if notice == "" {
return result
}
return notice + result
}
func hitlCollectStringStream(sr *schema.StreamReader[string]) (string, error) {
if sr == nil {
return "", nil
}
defer sr.Close()
var b strings.Builder
for {
chunk, err := sr.Recv()
if errors.Is(err, io.EOF) {
return b.String(), nil
}
if err != nil {
return b.String(), err
}
b.WriteString(chunk)
}
}
func hitlInvokableToolCallMiddleware() compose.InvokableToolMiddleware {
return func(next compose.InvokableToolEndpoint) compose.InvokableToolEndpoint {
return func(ctx context.Context, input *compose.ToolInput) (*compose.ToolOutput, error) {
originalArgs := ""
editedArgs := ""
if input != nil {
if fn, ok := ctx.Value(hitlInterceptorKey{}).(HITLToolInterceptor); ok && fn != nil {
originalArgs = input.Arguments
edited, err := fn(ctx, input.Name, input.Arguments)
if err != nil {
if IsHumanRejectError(err) {
@@ -85,11 +126,17 @@ func hitlInvokableToolCallMiddleware() compose.InvokableToolMiddleware {
return nil, err
}
if edited != "" {
editedArgs = edited
input.Arguments = edited
}
}
}
return next(ctx, input)
out, err := next(ctx, input)
if err != nil || out == nil {
return out, err
}
out.Result = hitlPrependEditedArgumentsNotice(out.Result, originalArgs, editedArgs)
return out, nil
}
}
}
@@ -97,8 +144,11 @@ func hitlInvokableToolCallMiddleware() compose.InvokableToolMiddleware {
func hitlStreamableToolCallMiddleware() compose.StreamableToolMiddleware {
return func(next compose.StreamableToolEndpoint) compose.StreamableToolEndpoint {
return func(ctx context.Context, input *compose.ToolInput) (*compose.StreamToolOutput, error) {
originalArgs := ""
editedArgs := ""
if input != nil {
if fn, ok := ctx.Value(hitlInterceptorKey{}).(HITLToolInterceptor); ok && fn != nil {
originalArgs = input.Arguments
edited, err := fn(ctx, input.Name, input.Arguments)
if err != nil {
if IsHumanRejectError(err) {
@@ -111,11 +161,26 @@ func hitlStreamableToolCallMiddleware() compose.StreamableToolMiddleware {
return nil, err
}
if edited != "" {
editedArgs = edited
input.Arguments = edited
}
}
}
return next(ctx, input)
out, err := next(ctx, input)
if err != nil || out == nil {
return out, err
}
if hitlEditedArgumentsNotice(originalArgs, editedArgs) == "" {
return out, nil
}
result, collectErr := hitlCollectStringStream(out.Result)
if collectErr != nil {
return nil, collectErr
}
out.Result = schema.StreamReaderFromArray([]string{
hitlPrependEditedArgumentsNotice(result, originalArgs, editedArgs),
})
return out, nil
}
}
}
@@ -96,7 +96,17 @@ func (m *modelOutputGuardMiddleware) AfterModelRewriteState(
badIndex := -1
argumentBytes := 0
if strings.EqualFold(strings.TrimSpace(finishReason), "length") {
reason = "output_limit"
if len(last.ToolCalls) == 0 {
reason = "output_limit"
} else {
for i, tc := range last.ToolCalls {
r, n := validateGeneratedToolCall(tc, m.cfg)
if r != "" {
reason, badIndex, argumentBytes = "output_limit", i, n
break
}
}
}
} else {
for i, tc := range last.ToolCalls {
r, n := validateGeneratedToolCall(tc, m.cfg)
@@ -50,6 +50,18 @@ func TestModelOutputGuardRejectsTruncatedToolCallBeforeExecution(t *testing.T) {
}
}
func TestModelOutputGuardAllowsValidToolCallDespiteLengthFinish(t *testing.T) {
original := `{"command":"echo ok"}`
state, err := runModelOutputGuard(t, []adk.Message{schema.UserMessage("run"), guardedAssistant(original, "length")}, config.MultiAgentEinoMiddlewareConfig{})
if err != nil {
t.Fatal(err)
}
got := state.Messages[len(state.Messages)-1].ToolCalls[0].Function.Arguments
if got != original {
t.Fatalf("valid arguments should pass unchanged: %q", got)
}
}
func TestModelOutputGuardRejectsInvalidJSONShapes(t *testing.T) {
for _, arguments := range []string{"", `[]`, `{"command":`} {
t.Run(arguments, func(t *testing.T) {
+15 -4
View File
@@ -40,6 +40,13 @@ type RunResult struct {
MCPExecutionIDs []string
LastAgentTraceInput string // 已序列化的消息带(JSON):原生循环或 Eino 均写入,供续跑/攻击链等恢复上下文
LastAgentTraceOutput string // 本轮助手侧对外展示文本(摘要或最终回复)
Finalized bool
Status string
CompletionReason string
EvidenceVerified bool
EvidenceRefs []string
PendingExecutionIDs []string
MissingChecks []string
}
// toolCallPendingInfo tracks a tool_call emitted to the UI so we can later
@@ -135,7 +142,7 @@ func RunDeepAgent(
mcpIDs = append(mcpIDs, id)
mcpIDsMu.Unlock()
}
einoExecBegin, einoExecFinish := newEinoExecuteMonitorCallbacks(ctx, ag, recorder)
einoExecBegin, einoExecAppendPartial, einoExecRegisterCancel, einoExecUnregisterCancel, einoExecFinish := newEinoExecuteMonitorCallbacks(ctx, ag, recorder)
// 与单代理流式一致:在 response_start / response_delta 的 data 中带当前 mcpExecutionIds,供主聊天绑定复制与展示。
snapshotMCPIDs := func() []string {
@@ -240,7 +247,7 @@ func RunDeepAgent(
}
if einoSkillMW != nil {
if einoFSTools && einoLoc != nil {
subFs, fsErr := subAgentFilesystemMiddleware(ctx, einoLoc, toolInvokeNotify, id, einoExecBegin, einoExecFinish, agentToolTimeoutMinutes(appCfg), agentShellNoOutputTimeoutSeconds(appCfg), nil)
subFs, fsErr := subAgentFilesystemMiddleware(ctx, einoLoc, toolInvokeNotify, id, einoExecBegin, einoExecAppendPartial, einoExecRegisterCancel, einoExecUnregisterCancel, einoExecFinish, agentToolTimeoutMinutes(appCfg), agentToolWaitTimeoutSeconds(appCfg), agentShellNoOutputTimeoutSeconds(appCfg), nil)
if fsErr != nil {
return nil, fmt.Errorf("子代理 %q filesystem 中间件: %w", id, fsErr)
}
@@ -387,8 +394,12 @@ func RunDeepAgent(
einoAgentName: orchestratorName,
outputChunk: nil,
beginMonitor: einoExecBegin,
appendPartialMonitor: einoExecAppendPartial,
registerCancelMonitor: einoExecRegisterCancel,
unregisterCancelMonitor: einoExecUnregisterCancel,
finishMonitor: einoExecFinish,
toolTimeoutMinutes: agentToolTimeoutMinutes(appCfg),
toolWaitTimeoutSeconds: agentToolWaitTimeoutSeconds(appCfg),
shellNoOutputTimeoutSec: agentShellNoOutputTimeoutSeconds(appCfg),
}
}
@@ -486,7 +497,7 @@ func RunDeepAgent(
// 构建 filesystem 中间件(与 Deep sub-agent 一致)
var peFsMw adk.ChatModelAgentMiddleware
if einoSkillMW != nil && einoFSTools && einoLoc != nil {
peFsMw, err = subAgentFilesystemMiddleware(ctx, einoLoc, toolInvokeNotify, "executor", einoExecBegin, einoExecFinish, agentToolTimeoutMinutes(appCfg), agentShellNoOutputTimeoutSeconds(appCfg), nil)
peFsMw, err = subAgentFilesystemMiddleware(ctx, einoLoc, toolInvokeNotify, "executor", einoExecBegin, einoExecAppendPartial, einoExecRegisterCancel, einoExecUnregisterCancel, einoExecFinish, agentToolTimeoutMinutes(appCfg), agentToolWaitTimeoutSeconds(appCfg), agentShellNoOutputTimeoutSeconds(appCfg), nil)
if err != nil {
return nil, fmt.Errorf("plan_execute filesystem 中间件: %w", err)
}
@@ -715,7 +726,7 @@ func historyToMessages(history []agent.ChatMessage, appCfg *config.Config, mwCfg
opts = append(opts, schema.WithToolName(tn))
}
content := h.Content
if !h.ModelFacingTrace {
if !h.ModelFacingTrace || (toolContentMax > 0 && len(content) > toolContentMax) {
content = normalizeRestoredToolContent(content, toolContentMax)
}
raw = append(raw, schema.ToolMessage(content, h.ToolCallID, opts...))
@@ -44,7 +44,7 @@ func TestHistoryToMessagesNormalizesLegacyRawToolOutput(t *testing.T) {
func TestHistoryToMessagesRestoresModelFacingTraceByteForByte(t *testing.T) {
mw := &config.MultiAgentEinoMiddlewareConfig{
ReductionMaxLengthForTrunc: 128,
ReductionMaxLengthForTrunc: 4096,
LatestUserMessageMaxRunes: 64,
}
userContent := strings.Repeat("model-facing-user-", 100)
@@ -66,6 +66,24 @@ func TestHistoryToMessagesRestoresModelFacingTraceByteForByte(t *testing.T) {
}
}
func TestHistoryToMessagesCapsOversizedModelFacingToolTrace(t *testing.T) {
mw := &config.MultiAgentEinoMiddlewareConfig{ReductionMaxLengthForTrunc: 128}
h := []agent.ChatMessage{
{Role: "assistant", ModelFacingTrace: true, ToolCalls: []agent.ToolCall{{ID: "t1", Type: "function", Function: agent.FunctionCall{Name: "exec"}}}},
{Role: "tool", ToolCallID: "t1", ToolName: "exec", Content: strings.Repeat("model-facing-tool-", 1000), ModelFacingTrace: true},
}
msgs := historyToMessages(h, nil, mw)
if len(msgs) != 2 {
t.Fatalf("len=%d", len(msgs))
}
if len(msgs[1].Content) > 128 {
t.Fatalf("restored model-facing tool bytes=%d, want <=128", len(msgs[1].Content))
}
if !strings.Contains(msgs[1].Content, "legacy tool output discarded") {
t.Fatalf("missing cap marker: %q", msgs[1].Content)
}
}
func TestHistoryToMessagesNeverReinjectsRawOversizedUserFallback(t *testing.T) {
appCfg := &config.Config{OpenAI: config.OpenAIConfig{MaxTotalTokens: 10000}}
mw := &config.MultiAgentEinoMiddlewareConfig{LatestUserMessageMaxRunes: 8000}

Some files were not shown because too many files have changed in this diff Show More