diff --git a/.flocks/flockshub/index.json b/.flocks/flockshub/index.json index 226009aef..7ac3a1117 100644 --- a/.flocks/flockshub/index.json +++ b/.flocks/flockshub/index.json @@ -14573,7 +14573,7 @@ "type": "device", "name": "Chaitin SafeLine WAF", "description": "Chaitin SafeLine WAF OpenAPI integration.", - "version": "1.0.0", + "version": "1.0.1", "category": "integration", "tags": [ "waf", diff --git a/.flocks/flockshub/plugins/tools/device/chaitin_safeline_waf_v1_0_0/manifest.json b/.flocks/flockshub/plugins/tools/device/chaitin_safeline_waf_v1_0_0/manifest.json index 065286060..11ec79673 100644 --- a/.flocks/flockshub/plugins/tools/device/chaitin_safeline_waf_v1_0_0/manifest.json +++ b/.flocks/flockshub/plugins/tools/device/chaitin_safeline_waf_v1_0_0/manifest.json @@ -5,7 +5,7 @@ "name": "Chaitin SafeLine WAF", "description": "Chaitin SafeLine WAF OpenAPI integration.", "descriptionCn": "长亭雷池 WAF OpenAPI 接入。", - "version": "1.0.0", + "version": "1.0.1", "author": "Flocks Team", "license": "MIT", "category": "integration", diff --git a/.flocks/plugins/skills/tdp-use/SKILL.md b/.flocks/plugins/skills/tdp-use/SKILL.md index bcf4bab74..15f556540 100644 --- a/.flocks/plugins/skills/tdp-use/SKILL.md +++ b/.flocks/plugins/skills/tdp-use/SKILL.md @@ -38,9 +38,10 @@ API 参数和适用场景见 [references/api-reference.md](references/api-refere - API 调用必须以当前 tool schema 为准,优先使用 schema 暴露的顶层语义化参数;列表类工具常见 `keyword`、`severity`、`cur_page`、`page_size`、`sort_by`,但 `tdp_log_search.sql` 是过滤表达式不是完整 SQL,禁止 `SELECT/FROM`,控制返回数量用 `size`,`terms` 可不传 `sql`,外部攻击结果筛选用 `result_list`。 - 用户说“告警”“告警记录”“告警日志”“明细记录”“查某 IP 的告警”时,默认走 `tdp_log_search` - 用户说“看板”“概览”“趋势”“统计”时,先用 `tdp_dashboard_status` -- 用户说“威胁事件”“外部攻击”“攻击事件”“事件总览”“事件趋势”时,优先用 `tdp_incident_list` 或 `tdp_threat_inbound_attack` -- 用户说“实时监控”“威胁监控列表”时,优先用正式 API 工具 `tdp_threat_monitor_list`;不要用 Cookie 认证的旧 Web CLI 代替 -- 用户说“告警主机”“受害主机”“主机下的事件”时,优先用 `tdp_host_threat_list` +- 用户明确说“智能聚合”“聚合攻击事件”“智能聚合事件”“攻击过程”“事件攻击时间线”时,优先用 `tdp_threat_intelligent_aggregation` +- 用户说“外部攻击”“外部攻击严重性分布”时,优先用 `tdp_threat_external_attack` +- 用户说“威胁事件列表”“实时监控”“威胁实时监控列表”“威胁监控列表”时,优先用正式 API 工具 `tdp_threat_monitor_list`;不要用 Cookie 认证的旧 Web CLI 代替 +- 用户说“告警主机”“受害主机”“主机下的事件”时,优先用 `tdp_threat_alert_host` - 用户说“系统状态”“核心服务状态”“数据库状态”时,优先用 `tdp_system_status` - 用户说“MDR”“研判结果”“研判统计”时,优先用 `tdp_mdr_alert_list` - 用户说“脆弱性”“弱口令”“登录入口”“上传接口”“API 风险”“隐私数据”时,走对应资产或风险类工具 diff --git a/.flocks/plugins/skills/tdp-use/references/api-reference.md b/.flocks/plugins/skills/tdp-use/references/api-reference.md index 3bd3bef99..9aa51339b 100644 --- a/.flocks/plugins/skills/tdp-use/references/api-reference.md +++ b/.flocks/plugins/skills/tdp-use/references/api-reference.md @@ -9,11 +9,11 @@ | 看安全态势、趋势、TOP 统计 | `tdp_dashboard_status` | `status` / `security` / `threat_event` / `alert_level_trend` | 通常可空参;列表类 action 可补 `machine_type`、`severity`、分页参数 | | 查原始告警日志 | `tdp_log_search` | `search` | `time_from`、`time_to`、`sql` | | 查字段聚合统计 | `tdp_log_search` | `terms` | `time_from`、`time_to`、`term` | -| 查威胁事件列表 | `tdp_incident_list` | `search` | `time_from`、`time_to`;可补 `severity`、`phase`、`result`、`keyword`、分页参数 | -| 查威胁实时监控列表 | `tdp_threat_monitor_list` | 默认 | 时间范围不超过 24 小时;可补 `sql`、`net_data_type`、`assets_group`、分页参数 | -| 看事件时间线 / 结果分布 / 攻击者明细 | `tdp_incident_list` | `timeline` / `result_distribution` / `attacker_ip_detail` | 通常先要 `incident_id` | -| 查外部攻击严重性分布 | `tdp_threat_inbound_attack` | 默认 | `time_from`、`time_to`;可补 `severity`、`result_list`、`keyword` | -| 查告警主机汇总 / 主机下事件 | `tdp_host_threat_list` | `summary` / `events` | `summary` 可补 `severity`、`direction`、`threat_type`、`keyword`;`events` 至少要 `asset_machine` | +| 查智能聚合事件 / 聚合攻击事件 | `tdp_threat_intelligent_aggregation` | `incident_search` | `time_from`、`time_to`;可补 `severity`、`phase`、`result`、`keyword`、分页参数 | +| 查威胁事件列表 / 威胁实时监控列表 | `tdp_threat_monitor_list` | 默认 | 时间范围不超过 24 小时;可补 `sql`、`net_data_type`、`assets_group`、分页参数 | +| 看事件时间线 / 结果分布 / 攻击者明细 | `tdp_threat_intelligent_aggregation` | `attack_timeline` / `attack_result_distribution` / `attacker_ip_detail` | 通常先要 `incident_id` | +| 查外部攻击严重性分布 | `tdp_threat_external_attack` | 默认 | `time_from`、`time_to`;可补 `severity`、`result_list`、`keyword` | +| 查告警主机列表 / 指定主机威胁列表 | `tdp_threat_alert_host` | `alert_host_list` / `host_threat_list` | `alert_host_list` 可补 `severity`、`direction`、`threat_type`、`keyword`;`host_threat_list` 至少要 `asset_machine` | | 查脆弱性 | `tdp_vulnerability_list` | 默认 | 常见补 `time_from`、`time_to`、`severity`、`status`、`keyword`、分页参数 | | 查弱口令 | `tdp_login_weakpwd_list` | 默认 | 常见补 `time_from`、`time_to`、`data`、`result`、`app_class`、`keyword` | | 查服务 / 主机 / 框架资产 | `tdp_machine_asset_list` | `service_list` / `host_asset_list` / `web_app_framework_list` | 可空参;常见补 `service`、`service_class`、`is_public`、`keyword` | @@ -90,8 +90,8 @@ tdp_log_search(time_from=today_start, time_to=today_end, sql="...") | `tdp_log_search` | `size` | `page_size`、`cur_page` | `/api/v1/log/searchBySql` 只支持返回数量控制,不支持分页参数 | | `tdp_log_search.sql` | 过滤表达式 | `SELECT * FROM alert` | TDP 只接受类似 WHERE 条件的表达式,不接受完整 SQL 查询 | | `tdp_log_search(action="terms")` | 可只传 `term` | 强制补 `sql` | `/api/v1/log/terms` 的 `sql` 是可选过滤条件 | -| `tdp_threat_inbound_attack` | `result_list` | `result` | API 文档字段为 `condition.result_list` | -| `tdp_incident_list` | `result` | `result_list` | 事件搜索 API 文档字段为 `condition.result` | +| `tdp_threat_external_attack` | `result_list` | `result` | API 文档字段为 `condition.result_list` | +| `tdp_threat_intelligent_aggregation` | `result` | `result_list` | 事件搜索 API 文档字段为 `condition.result` | 示例: @@ -109,19 +109,21 @@ tdp_log_search(time_from=today_start, time_to=today_end, sql="...") | 用户实际要查什么 | 推荐 tool | 说明 | |---|---|---| -| 威胁事件 / 攻击事件 / 事件总览 | `tdp_incident_list` / `tdp_threat_inbound_attack` | 事件维度,平台已聚合 | -| 实时监控 / 威胁监控列表 | `tdp_threat_monitor_list` | 实时威胁监控列表,单次时间范围不得超过 24 小时 | +| 智能聚合事件 / 聚合攻击事件 / 攻击过程 | `tdp_threat_intelligent_aggregation` | 智能聚合维度,一条事件可关联多条告警 | +| 外部攻击严重性分布 | `tdp_threat_external_attack` | 外部攻击统计维度 | +| 威胁事件列表 / 实时监控 / 威胁监控列表 | `tdp_threat_monitor_list` | 威胁实时监控列表,单次时间范围不得超过 24 小时 | | 告警 / 告警日志 / 原始检测记录 | `tdp_log_search` | 告警维度,一条就是一条原始记录 | -| 告警主机 / 受害主机 / 主机下事件 | `tdp_host_threat_list` | 主机维度,按主机聚合 | +| 告警主机 / 受害主机 / 主机下事件 | `tdp_threat_alert_host` | 主机维度,按主机聚合 | | 漏洞、弱口令、登录入口、API 风险等 | 对应资产或风险类 tool | 资产/风险维度,不要混进日志查询 | 建议按下面的用词来路由: - 提到“告警”“最近一小时告警”“查某 IP 的告警”时,默认优先 `tdp_log_search` -- 提到“威胁事件”“攻击事件”“看下最近有什么事件”时,优先 `tdp_incident_list` -- 提到“实时监控”“威胁监控列表”时,优先 `tdp_threat_monitor_list` -- 提到“哪些主机被打了”“告警主机”“受害主机”时,优先 `tdp_host_threat_list` -- 用户没说清时,默认把“明细”理解为告警日志,把“总览/聚合”理解为事件 +- 明确提到“智能聚合”“聚合攻击事件”“智能聚合事件”“攻击过程”时,优先 `tdp_threat_intelligent_aggregation` +- 提到“外部攻击”“外部攻击严重性分布”时,优先 `tdp_threat_external_attack` +- 提到“威胁事件列表”“实时监控”“威胁实时监控列表”“威胁监控列表”时,优先 `tdp_threat_monitor_list` +- 提到“哪些主机被打了”“告警主机”“受害主机”时,优先 `tdp_threat_alert_host` +- 用户没说清时,默认把“事件列表/实时监控”理解为威胁监控列表,把“总览/聚合”理解为智能聚合事件 ## 高频场景 @@ -279,18 +281,18 @@ machine = '192.168.1.100' - 统计某时间段内最多的威胁名称 - 聚合源 IP、目的 IP、URL、威胁类型 -### 4. 威胁事件列表 +### 4. 智能聚合事件查询 推荐: -- `tdp_incident_list` -- `action=search` +- `tdp_threat_intelligent_aggregation` +- `action=incident_search` 最小可运行参数集: ```json { - "action": "search", + "action": "incident_search", "time_from": 1741536000, "time_to": 1741622400, "severity": [3, 4], @@ -308,12 +310,12 @@ machine = '192.168.1.100' 高频 action: -- `search`: 事件列表 +- `incident_search`: 查询智能聚合后的攻击事件列表;不是“威胁-实时监控”中的威胁事件列表 - `top_attacked_entity`: 受攻击实体 -- `result`: 事件研判结果 -- `timeline`: 时间线 -- `alert_search`: 事件下告警列表 -- `result_distribution`: 结果分布 +- `attack_success`: 攻击成功统计 +- `attack_timeline`: 攻击过程时间线 +- `attack_alert_list`: 攻击过程告警详情 +- `attack_result_distribution`: 成功、失败、尝试的数量分布 - `attacker_ip_list`: 攻击者 IP 列表 - `attacker_ip_detail`: 攻击者 IP 详情 @@ -321,14 +323,14 @@ machine = '192.168.1.100' ```json { - "action": "timeline", + "action": "attack_timeline", "incident_id": "b62899499fec914d6246137eed3b6ec4-1777334454", "time_from": 1777305600, "time_to": 1777391999 } ``` -`timeline` 会默认传 `show_attack=true`。如果 TDP 仍返回 `show_attack is false`,说明该事件当前不支持攻击过程展开,改用 `result`、`alert_search` 或回退浏览器查看事件详情。 +`attack_timeline` 会默认传 `show_attack=true`。如果 TDP 仍返回 `show_attack is false`,说明该事件当前不支持攻击过程展开,改用 `attack_success`、`attack_alert_list` 或回退浏览器查看事件详情。 返回结果重点关注: @@ -357,13 +359,13 @@ machine = '192.168.1.100' } ``` -注意:这里的攻击结果字段是 `result_list`,来自 API 文档的 `condition.result_list`;不要使用事件列表里的 `result` 参数名。 +注意:这里的攻击结果字段是 `result_list`,来自 API 文档的 `condition.result_list`;不要使用智能聚合事件查询里的 `result` 参数名。 告警主机汇总: ```json { - "action": "summary", + "action": "alert_host_list", "time_from": 1741536000, "time_to": 1741622400, "severity": [3, 4], @@ -374,13 +376,13 @@ machine = '192.168.1.100' } ``` -`summary` 默认覆盖全部常见威胁类型,且 `threat_characters` 默认不限制。用户明确要“失陷主机”时再传 `threat_characters=["is_compromised"]`。 +`alert_host_list` 默认覆盖全部常见威胁类型,且 `threat_characters` 默认不限制。用户明确要“失陷主机”时再传 `threat_characters=["is_compromised"]`。 某主机下事件: ```json { - "action": "events", + "action": "host_threat_list", "asset_machine": "asset-123", "time_from": 1741536000, "time_to": 1741622400 diff --git a/.flocks/plugins/tools/device/tdp_v3_3_10/_test.yaml b/.flocks/plugins/tools/device/tdp_v3_3_10/_test.yaml index 1eae486e1..97439e990 100644 --- a/.flocks/plugins/tools/device/tdp_v3_3_10/_test.yaml +++ b/.flocks/plugins/tools/device/tdp_v3_3_10/_test.yaml @@ -49,12 +49,12 @@ fixtures: assert: success: true - tdp_incident_list: - - label: "Search incidents (page 1)" - label_cn: "查询威胁事件列表(第 1 页)" + tdp_threat_intelligent_aggregation: + - label: "Search intelligent aggregation incidents (page 1)" + label_cn: "查询智能聚合威胁事件(第 1 页)" tags: [smoke, incidents] params: - action: search + action: incident_search cur_page: 1 page_size: 20 assert: @@ -66,12 +66,12 @@ fixtures: params: action: top_attacked_entity - tdp_host_threat_list: - - label: "Get threat host summary" - label_cn: "查询威胁主机摘要" + tdp_threat_alert_host: + - label: "List alert hosts" + label_cn: "查询告警主机列表" tags: [smoke, threats] params: - action: summary + action: alert_host_list cur_page: 1 page_size: 20 assert: @@ -147,15 +147,15 @@ fixtures: action: search size: 20 - tdp_threat_inbound_attack: - - label: "Get inbound attack overview (last 24 hours)" - label_cn: "查询外部入侵攻击概览(最近 24 小时)" + tdp_threat_external_attack: + - label: "Get external attack severity distribution (last 24 hours)" + label_cn: "查询外部攻击严重性分布(最近 24 小时)" tags: [smoke, threats] params: {} tdp_threat_monitor_list: - - label: "List real-time threats (last 24 hours)" - label_cn: "查询威胁实时监控列表(最近 24 小时)" + - label: "List monitored threats (last 24 hours)" + label_cn: "查询威胁监控列表(最近 24 小时)" tags: [smoke, threats] params: cur_page: 1 diff --git a/.flocks/plugins/tools/device/tdp_v3_3_10/tdp.handler.py b/.flocks/plugins/tools/device/tdp_v3_3_10/tdp.handler.py index 1e39d02b2..649355021 100644 --- a/.flocks/plugins/tools/device/tdp_v3_3_10/tdp.handler.py +++ b/.flocks/plugins/tools/device/tdp_v3_3_10/tdp.handler.py @@ -479,7 +479,7 @@ def _condition_time_body(body: dict[str, Any]) -> dict[str, Any]: def _incident_search_body(body: dict[str, Any]) -> dict[str, Any]: defaults = _body_with_condition_time( { - "condition": {"duration": {"begin_duration": 0, "end_duration": 24}}, + "condition": {"duration": {"begin_duration": 0}}, "page": {"cur_page": 1, "page_size": 20, "sort": [{"sort_by": "last_time", "sort_order": "desc"}]}, } ) @@ -845,17 +845,17 @@ def _passthrough_body(body: Any) -> Any: } THREAT_HOST_ACTIONS: JsonActionMap = { - "summary": ("/api/v1/host/getFallHostSumList", _threat_host_summary_body, "host_get_fall_host_sum_list"), - "events": ("/api/v1/host/threat/list", _threat_host_event_body, "host_threat_list"), + "alert_host_list": ("/api/v1/host/getFallHostSumList", _threat_host_summary_body, "host_get_fall_host_sum_list"), + "host_threat_list": ("/api/v1/host/threat/list", _threat_host_event_body, "host_threat_list"), } INCIDENT_ACTIONS: JsonActionMap = { - "search": ("/api/v1/incident/search", _incident_search_body, "incident_search"), + "incident_search": ("/api/v1/incident/search", _incident_search_body, "incident_search"), "top_attacked_entity": ("/api/v1/incident/topAttackedEntity", _condition_time_body, "incident_top_attacked_entity"), - "result": ("/api/v1/incident/result", _dashboard_body, "incident_result"), - "timeline": ("/api/v1/incident/timeline", _dashboard_body, "incident_timeline"), - "alert_search": ("/api/v1/alert/search", _condition_time_body, "incident_alert_search"), - "result_distribution": ( + "attack_success": ("/api/v1/incident/result", _dashboard_body, "incident_result"), + "attack_timeline": ("/api/v1/incident/timeline", _dashboard_body, "incident_timeline"), + "attack_alert_list": ("/api/v1/alert/search", _condition_time_body, "incident_alert_search"), + "attack_result_distribution": ( "/api/v1/incident/result/distribution", _condition_time_body, "incident_result_distribution", @@ -1267,7 +1267,7 @@ async def dashboard_status( async def threat_host_list( context: ToolContext, - action: str = "summary", + action: str = "alert_host_list", condition: dict[str, Any] | None = None, page: dict[str, Any] | None = None, time_from: int | None = None, @@ -1289,7 +1289,7 @@ async def threat_host_list( sort_order: str | None = None, ) -> ToolResult: del context - selected_action = _normalize_action(action, "summary") + selected_action = _normalize_action(action, "alert_host_list") body = _compose_payload(condition=condition, page=page) condition_dict = body.setdefault("condition", {}) _set_if_present(condition_dict, "time_from", time_from) @@ -1306,16 +1306,21 @@ async def threat_host_list( _set_list_if_present(condition_dict, "host_type", host_type) _set_keyword_fuzzy(condition_dict, keyword, HOST_THREAT_FUZZY_FIELDS) _set_page_overrides(body, cur_page=cur_page, page_size=page_size, sort_by=sort_by, sort_order=sort_order) - if selected_action == "events": + if selected_action == "host_threat_list": validation_error = _validate_required_body_fields(selected_action, body, "condition.asset_machine") if validation_error: return validation_error - return await _run_action_json_tool(THREAT_HOST_ACTIONS, default_action="summary", action=selected_action, body=body) + return await _run_action_json_tool( + THREAT_HOST_ACTIONS, + default_action="alert_host_list", + action=selected_action, + body=body, + ) async def incident_list( context: ToolContext, - action: str = "search", + action: str = "incident_search", condition: dict[str, Any] | None = None, page: dict[str, Any] | None = None, incident_id: str | None = None, @@ -1339,9 +1344,9 @@ async def incident_list( sort_order: str | None = None, ) -> ToolResult: del context - selected_action = _normalize_action(action, "search") + selected_action = _normalize_action(action, "incident_search") body = _compose_payload(condition=condition, page=page) - if selected_action == "search": + if selected_action == "incident_search": condition_dict = body.setdefault("condition", {}) _set_if_present(condition_dict, "time_from", time_from) _set_if_present(condition_dict, "time_to", time_to) @@ -1354,7 +1359,7 @@ async def incident_list( _set_if_present(duration, "begin_duration", begin_duration) _set_if_present(duration, "end_duration", end_duration) _set_keyword_fuzzy(condition_dict, keyword, INCIDENT_SEARCH_FUZZY_FIELDS) - elif selected_action == "alert_search": + elif selected_action == "attack_alert_list": condition_dict = body.setdefault("condition", {}) _set_if_present(condition_dict, "time_from", time_from) _set_if_present(condition_dict, "time_to", time_to) @@ -1372,17 +1377,17 @@ async def incident_list( _set_if_present(body, "time_from", time_from) _set_if_present(body, "time_to", time_to) _set_if_present(body, "include_risk", include_risk) - if selected_action == "timeline": + if selected_action == "attack_timeline": body["show_attack"] = True if show_attack is None else show_attack if attacker is not None: body["attacker"] = list(attacker) _set_page_overrides(body, cur_page=cur_page, page_size=page_size, sort_by=sort_by, sort_order=sort_order) validation_map: dict[str, tuple[str, ...]] = { "top_attacked_entity": ("incident_id",), - "result": ("incident_id",), - "timeline": ("incident_id",), - "alert_search": ("condition.id", "page"), - "result_distribution": ("incident_id",), + "attack_success": ("incident_id",), + "attack_timeline": ("incident_id",), + "attack_alert_list": ("condition.id", "page"), + "attack_result_distribution": ("incident_id",), "attacker_ip_list": ("condition.incident_id",), "attacker_ip_detail": ("incident_id", "attacker"), } @@ -1391,7 +1396,12 @@ async def incident_list( validation_error = _validate_required_body_fields(selected_action, body, *required_fields) if validation_error: return validation_error - return await _run_action_json_tool(INCIDENT_ACTIONS, default_action="search", action=selected_action, body=body) + return await _run_action_json_tool( + INCIDENT_ACTIONS, + default_action="incident_search", + action=selected_action, + body=body, + ) async def service_asset_list( diff --git a/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_incident_list.yaml b/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_incident_list.yaml deleted file mode 100644 index b724a7106..000000000 --- a/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_incident_list.yaml +++ /dev/null @@ -1,109 +0,0 @@ -name: tdp_incident_list -description: > - 查询 TDP 智能聚合相关接口。默认返回事件列表(注意不是告警),也可通过 action 切换到时间线、 - 结果分布、攻击实体、攻击者 IP 明细等子接口。优先使用顶层语义化参数,不要手写 - `condition.id`、`condition.duration`、`condition.fuzzy` 这类嵌套字段;只有在显式参数未覆盖时才使用 `condition` / `page`。 -category: custom -enabled: true -requires_confirmation: false -provider: tdp_api - -inputSchema: - type: object - properties: - action: - type: string - description: > - 智能聚合子接口动作。`search` 会自动补齐 condition.time_from、 - condition.time_to、duration 和分页默认值;`top_attacked_entity`、 - `result`、`timeline`、`result_distribution` 需要传 - `incident_id`;`alert_search` 需要传 `alert_ids` 或 `condition.id`, - 且应同时提供 `page`;`attacker_ip_list` 需要传 `condition.incident_id`; - `attacker_ip_detail` 需要传 `incident_id` 和 `attacker`。 - enum: [search, top_attacked_entity, result, timeline, alert_search, result_distribution, attacker_ip_list, attacker_ip_detail] - default: search - incident_id: - type: string - description: 事件 ID,适用于 `top_attacked_entity`、`result`、`timeline`、`result_distribution`、`attacker_ip_detail`;应先从 `search` 返回结果中获取。 - time_from: - type: integer - description: 开始时间戳,Unix 秒级时间戳,按动作写入根级或 `condition.time_from`。可以使用python datetime动态计算。 - time_to: - type: integer - description: 结束时间戳,Unix 秒级时间戳,按动作写入根级或 `condition.time_to`。可以使用python datetime动态计算。 - include_risk: - type: boolean - description: 是否包含风险告警;`timeline` 等动作写入根级,`alert_search` 写入 `condition.include_risk`。 - show_attack: - type: boolean - description: > - 是否展示攻击过程,适用于 `timeline`。handler 默认传 `true`; - 若显式传 `false`,TDP 可能返回 `show_attack is false`。 - include_action: - type: boolean - description: 是否包含事件告警,适用于 `alert_search`,会写入 `condition.include_action`。 - alert_ids: - type: array - items: - type: string - description: 告警 ID 列表,适用于 `alert_search`;会转换写入 `condition.id`。 - attacker: - type: array - items: - type: string - description: 攻击者 IP 列表,适用于 `attacker_ip_detail`;通常先从 `attacker_ip_list` 返回结果中获取。 - severity: - type: array - items: - type: integer - enum: [0, 1, 2, 3, 4] - description: 严重级别列表,适用于 `search`。 - phase: - type: array - items: - type: string - description: 攻击阶段列表,适用于 `search`,例如 `recon`、`exploit`、`control`、`attack_out`、`post_exploit`。 - result: - type: array - items: - type: string - enum: [success, failed, unknown] - description: 攻击结果列表,适用于 `search`。 - is_target_attack: - type: boolean - description: 是否只看针对性攻击事件,适用于 `search`。 - begin_duration: - type: integer - description: 持续时间起始小时数,适用于 `search`,会写入 `condition.duration.begin_duration`。 - end_duration: - type: integer - description: 持续时间结束小时数,适用于 `search`,会写入 `condition.duration.end_duration`。 - keyword: - type: string - description: > - 关键词搜索,适用于 `search`。handler 会自动组装 - `condition.fuzzy.fieldlist=["attacker_ip","host_ip","attack_name","attack_tool","incident_id"]`。 - cur_page: - type: integer - description: 页码,适用于 `search`、`alert_search`、`attacker_ip_list`。 - page_size: - type: integer - description: 每页条数,适用于 `search`、`alert_search`、`attacker_ip_list`。 - sort_by: - type: string - description: 排序字段,适用于列表类 action。 - sort_order: - type: string - enum: [asc, desc] - description: 排序方向,适用于列表类 action。 - condition: - type: object - description: 高级兼容参数。仅在顶层参数未覆盖目标字段时使用。 - page: - type: object - description: 高级兼容分页参数。`alert_search` 如需复杂排序可直接透传。 - required: [] -handler: - type: script - script_file: tdp.handler.py - function: incident_list diff --git a/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_host_threat_list.yaml b/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_threat_alert_host.yaml similarity index 67% rename from .flocks/plugins/tools/device/tdp_v3_3_10/tdp_host_threat_list.yaml rename to .flocks/plugins/tools/device/tdp_v3_3_10/tdp_threat_alert_host.yaml index 84829590f..ca11f5091 100644 --- a/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_host_threat_list.yaml +++ b/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_threat_alert_host.yaml @@ -1,7 +1,7 @@ -name: tdp_host_threat_list +name: tdp_threat_alert_host description: > - 查询 TDP 告警主机相关数据。`summary` 返回告警主机列表,适合按严重级别、威胁性质、威胁类型、业务组和关键词筛选;`events` - 返回某台告警主机下的威胁事件列表,通常需要先从 `summary` 结果里拿到 `assets_machine` 再继续查询。优先使用顶层语义化参数,不要手写 + 查询 TDP 告警主机相关数据。`alert_host_list` 返回告警主机列表;`host_threat_list` + 返回指定告警主机的威胁列表,通常需要先从 `alert_host_list` 结果里取得 `assets_machine`。优先使用顶层语义化参数,不要手写 `condition` / `page`,只有在需要透传尚未覆盖的底层字段时才使用这两个高级兼容参数。 category: custom enabled: true @@ -14,10 +14,10 @@ inputSchema: action: type: string description: > - 告警主机子接口动作。`summary` 会自动补齐默认时间范围、全部告警主机的威胁类型、空的威胁特征和默认分页; - `events` 用于查看单台主机下的威胁事件,要求提供 `asset_machine`,级联场景可额外传 `device_id`。 - enum: [summary, events] - default: summary + `alert_host_list` 查询告警主机列表,可按严重级别、威胁性质、威胁类型、业务组和关键词筛选; + `host_threat_list` 查询指定 `asset_machine` 的威胁列表,级联场景可额外传 `device_id`。 + enum: [alert_host_list, host_threat_list] + default: alert_host_list time_from: type: integer description: 开始时间戳,Unix 秒级时间戳,会写入 `condition.time_from`。可以使用python datetime动态计算。 @@ -26,7 +26,7 @@ inputSchema: description: 结束时间戳,Unix 秒级时间戳,会写入 `condition.time_to`。可以使用python datetime动态计算。 asset_machine: type: string - description: 资产机器标识,`events` 动作必填;应从 `summary` 结果中的主机标识字段获取。 + description: 资产机器标识,`host_threat_list` 必填;应从 `alert_host_list` 结果中的主机标识字段获取。 device_id: type: string description: 设备 ID,级联平台场景使用;非级联平台通常可省略。 @@ -52,7 +52,7 @@ inputSchema: type: array items: type: string - description: 威胁类型列表。`summary` 默认已覆盖全部常见威胁类型;仅在需要缩小范围时传入。 + description: 威胁类型列表。`alert_host_list` 默认已覆盖全部常见威胁类型;仅在需要缩小范围时传入。 disposal_status: type: array items: @@ -64,21 +64,21 @@ inputSchema: type: array items: type: string - description: 主机类型列表,适用于 `summary`,常见值对应服务器或终端视角。 + description: 主机类型列表,适用于 `alert_host_list`,常见值对应服务器或终端视角。 assets_group: type: array items: type: integer - description: 业务组 ID 列表,适用于 `summary`。 + description: 业务组 ID 列表,适用于 `alert_host_list`。 host_type: type: array items: type: string - description: 资产类型列表,适用于 `summary`。 + description: 资产类型列表,适用于 `alert_host_list`。 keyword: type: string description: > - 关键词搜索,适用于 `summary` 和 `events`。handler 会自动组装固定的 + 关键词搜索,适用于 `alert_host_list` 和 `host_threat_list`。handler 会自动组装固定的 `condition.fuzzy.fieldlist=["threat.name","external_ip","machine","assets.name","data","machine_name"]`。 cur_page: type: integer @@ -88,7 +88,7 @@ inputSchema: description: 每页条数,默认 20。 sort_by: type: string - description: 排序字段。`summary` 默认按 `severity`,`events` 默认按 `max_severity`。 + description: 排序字段。`alert_host_list` 默认按 `severity`,`host_threat_list` 默认按 `max_severity`。 sort_order: type: string enum: [asc, desc] diff --git a/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_threat_inbound_attack.yaml b/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_threat_external_attack.yaml similarity index 98% rename from .flocks/plugins/tools/device/tdp_v3_3_10/tdp_threat_inbound_attack.yaml rename to .flocks/plugins/tools/device/tdp_v3_3_10/tdp_threat_external_attack.yaml index 3f1e11611..b982f2bb5 100644 --- a/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_threat_inbound_attack.yaml +++ b/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_threat_external_attack.yaml @@ -1,4 +1,4 @@ -name: tdp_threat_inbound_attack +name: tdp_threat_external_attack description: > 查询 TDP 外部攻击相关数据,当前提供外部攻击严重性分布统计。优先使用顶层语义化参数, 例如严重级别、攻击结果、级联资产组和关键词;`condition` 仅作为高级兼容入口。 diff --git a/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_threat_intelligent_aggregation.yaml b/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_threat_intelligent_aggregation.yaml new file mode 100644 index 000000000..889c6f9f6 --- /dev/null +++ b/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_threat_intelligent_aggregation.yaml @@ -0,0 +1,109 @@ +name: tdp_threat_intelligent_aggregation +description: > + 查询 TDP“威胁-智能聚合”中的聚合攻击事件及其详情。默认查询智能聚合后的攻击事件,也可查询指定聚合事件的 + 攻击成功统计、攻击时间线、攻击过程告警、结果分布、受攻击实体和攻击者 IP 信息。本 tool 不用于查询 + “威胁-实时监控”中的威胁事件列表;该意图应使用 `tdp_threat_monitor_list`。优先使用顶层语义化参数,不要手写 + `condition.id`、`condition.duration`、`condition.fuzzy` 这类嵌套字段;只有在显式参数未覆盖时才使用 `condition` / `page`。 +category: custom +enabled: true +requires_confirmation: false +provider: tdp_api + +inputSchema: + type: object + properties: + action: + type: string + description: > + `incident_search` 查询智能聚合后的攻击事件列表;`top_attacked_entity` 查询指定聚合事件 TOP 受攻击域名或主机; + `attack_success` 查询指定聚合事件的攻击成功统计;`attack_timeline` 查询指定聚合事件的攻击过程时间线; + `attack_alert_list` 根据攻击过程返回的告警 ID 查询对应告警详情;`attack_result_distribution` 查询指定聚合事件中 + 成功、失败、尝试的数量分布;`attacker_ip_list` 查询指定聚合事件的攻击来源 IP; + `attacker_ip_detail` 查询指定聚合事件中指定攻击 IP 的情报和活跃时间段。 + enum: [incident_search, top_attacked_entity, attack_success, attack_timeline, attack_alert_list, attack_result_distribution, attacker_ip_list, attacker_ip_detail] + default: incident_search + incident_id: + type: string + description: 智能聚合事件 ID,适用于除 `incident_search` 和 `attack_alert_list` 外的详情动作;应先从 `incident_search` 返回结果中获取。 + time_from: + type: integer + description: 开始时间戳,Unix 秒级时间戳,按动作写入根级或 `condition.time_from`。可以使用python datetime动态计算。 + time_to: + type: integer + description: 结束时间戳,Unix 秒级时间戳,按动作写入根级或 `condition.time_to`。可以使用python datetime动态计算。 + include_risk: + type: boolean + description: 是否包含风险告警;`attack_timeline` 写入根级,`attack_alert_list` 写入 `condition.include_risk`。 + show_attack: + type: boolean + description: > + 是否展示攻击过程,适用于 `attack_timeline`。handler 默认传 `true`; + 若显式传 `false`,TDP 可能返回 `show_attack is false`。 + include_action: + type: boolean + description: 是否包含敏感行为,适用于 `attack_alert_list`,会写入 `condition.include_action`。 + alert_ids: + type: array + items: + type: string + description: 攻击过程返回的告警 ID 列表,适用于 `attack_alert_list`;会转换写入 `condition.id`。 + attacker: + type: array + items: + type: string + description: 攻击者 IP 列表,适用于 `attacker_ip_detail`;通常先从 `attacker_ip_list` 返回结果中获取。 + severity: + type: array + items: + type: integer + enum: [0, 1, 2, 3, 4] + description: 严重级别列表,适用于 `incident_search`。 + phase: + type: array + items: + type: string + description: 攻击阶段列表,适用于 `incident_search`,例如 `recon`、`exploit`、`control`、`attack_out`、`post_exploit`。 + result: + type: array + items: + type: string + enum: [success, failed, unknown] + description: 攻击结果列表,适用于 `incident_search`。 + is_target_attack: + type: boolean + description: 是否只看针对性攻击事件,适用于 `incident_search`。 + begin_duration: + type: integer + description: 持续时间起始小时数,适用于 `incident_search`,会写入 `condition.duration.begin_duration`。 + end_duration: + type: integer + description: 持续时间结束小时数,适用于 `incident_search`,会写入 `condition.duration.end_duration`。 + keyword: + type: string + description: > + 关键词搜索,适用于 `incident_search`。handler 会自动组装 + `condition.fuzzy.fieldlist=["attacker_ip","host_ip","attack_name","attack_tool","incident_id"]`。 + cur_page: + type: integer + description: 页码,适用于 `incident_search`、`attack_alert_list`、`attacker_ip_list`。 + page_size: + type: integer + description: 每页条数,适用于 `incident_search`、`attack_alert_list`、`attacker_ip_list`。 + sort_by: + type: string + description: 排序字段,适用于列表类 action。 + sort_order: + type: string + enum: [asc, desc] + description: 排序方向,适用于列表类 action。 + condition: + type: object + description: 高级兼容参数。仅在顶层参数未覆盖目标字段时使用。 + page: + type: object + description: 高级兼容分页参数。`attack_alert_list` 如需复杂排序可直接透传。 + required: [] +handler: + type: script + script_file: tdp.handler.py + function: incident_list diff --git a/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_threat_monitor_list.yaml b/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_threat_monitor_list.yaml index 2830e3a1f..0409fa813 100644 --- a/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_threat_monitor_list.yaml +++ b/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_threat_monitor_list.yaml @@ -1,6 +1,6 @@ name: tdp_threat_monitor_list description: > - 查询 TDP 威胁-实时监控列表,对应正式 API `/api/v1/monitor/threat/list`。 + 查询 TDP 威胁事件列表,即“威胁-实时监控”中的威胁实时监控列表,对应正式 API `/api/v1/monitor/threat/list`。 查询时间范围不得超过 24 小时;未传时间时 handler 动态使用最近 24 小时。 `sql` 是 TDP 过滤表达式,不是包含 SELECT/FROM 的完整 SQL。 category: custom diff --git a/flocks/channel/base.py b/flocks/channel/base.py index 67e96148c..ffd79dc4e 100644 --- a/flocks/channel/base.py +++ b/flocks/channel/base.py @@ -39,6 +39,7 @@ class ChannelCapabilities: reactions: bool = False edit: bool = False rich_text: bool = False + self_managed_connection: bool = False @dataclass @@ -86,6 +87,10 @@ class DeliveryResult: retryable: bool = False +class NonRetryableChannelError(RuntimeError): + """Raised when reconnecting cannot fix a channel startup failure.""" + + @dataclass class ChannelStatus: """Runtime health status of a channel connection.""" diff --git a/flocks/channel/builtin/slack/__init__.py b/flocks/channel/builtin/slack/__init__.py new file mode 100644 index 000000000..bc4986df2 --- /dev/null +++ b/flocks/channel/builtin/slack/__init__.py @@ -0,0 +1,5 @@ +"""Slack channel integration.""" + +from flocks.channel.builtin.slack.channel import SlackChannel + +__all__ = ["SlackChannel"] diff --git a/flocks/channel/builtin/slack/channel.py b/flocks/channel/builtin/slack/channel.py new file mode 100644 index 000000000..200c169cc --- /dev/null +++ b/flocks/channel/builtin/slack/channel.py @@ -0,0 +1,388 @@ +"""Slack ChannelPlugin using Socket Mode.""" + +from __future__ import annotations + +import asyncio +from collections import OrderedDict +from typing import Any, Awaitable, Callable, Optional + +from flocks.channel.base import ( + ChannelCapabilities, + ChannelMeta, + ChannelPlugin, + ChatType, + DeliveryResult, + InboundMessage, + NonRetryableChannelError, + OutboundContext, +) +from flocks.utils.log import Log + +from .config import ( + normalize_target, + resolve_app_token, + resolve_bot_token, + resolve_home_channel, + should_reply_broadcast, + should_reply_in_thread, +) +from .format import markdown_to_slack_mrkdwn +from .inbound import build_inbound_message + +try: + from slack_bolt.async_app import AsyncApp + from slack_bolt.adapter.socket_mode.async_handler import AsyncSocketModeHandler + + SLACK_AVAILABLE = True +except ImportError: # pragma: no cover - exercised by validate_config tests + AsyncApp = None # type: ignore[assignment] + AsyncSocketModeHandler = None # type: ignore[assignment] + SLACK_AVAILABLE = False + + +log = Log.create(service="channel.slack") + + +_PERMANENT_SLACK_ERROR_CODES = { + "invalid_auth", + "not_authed", + "no_auth", + "token_revoked", + "account_inactive", + "not_allowed_token_type", + "missing_scope", +} + + +def _is_token_reference(value: str) -> bool: + return value.startswith("{secret:") or value.startswith("{env:") + + +def _slack_response_get(response: Any, key: str, default: str = "") -> str: + getter = getattr(response, "get", None) + if callable(getter): + return str(getter(key, default) or default) + data = getattr(response, "data", None) + if isinstance(data, dict): + return str(data.get(key) or default) + return default + + +def _slack_error_code(exc: Exception) -> str: + response = getattr(exc, "response", None) + data = getattr(response, "data", None) + if isinstance(data, dict): + return str(data.get("error") or "").strip() + if isinstance(response, dict): + return str(response.get("error") or "").strip() + + message = str(exc).lower().replace("-", "_") + for code in _PERMANENT_SLACK_ERROR_CODES: + if code in message: + return code + if "invalid app token" in message: + return "invalid_auth" + return "" + + +def _is_permanent_slack_error(exc: Exception) -> bool: + return _slack_error_code(exc) in _PERMANENT_SLACK_ERROR_CODES + + +def _format_slack_start_error(exc: Exception, *, phase: str) -> str: + code = _slack_error_code(exc) + detail = f"Slack error={code}" if code else str(exc) + + if phase == "bot_auth": + if code == "missing_scope": + return ( + "Slack Bot Token 权限不足:请在 Slack 开发者管理后台的 OAuth & Permissions " + "确认 Bot Token 具备 Manifest 中的 bot scopes,重新安装 App 后复制 " + "Bot User OAuth Token(xoxb- 开头)。" + ) + return ( + "Slack Bot Token 验证失败:请在 Slack 开发者管理后台的 OAuth & Permissions " + "复制 Bot User OAuth Token(xoxb- 开头),确认 App 已安装到 workspace 后重新保存。" + f" 原始错误:{detail}" + ) + + if code == "missing_scope": + return ( + "Slack App Token 权限不足:请在 Basic Information > App-Level Tokens " + "创建或更新具备 connections:write scope 的 App-Level Token(xapp- 开头)。" + ) + if code in {"invalid_auth", "not_authed", "no_auth", "token_revoked", "account_inactive", "not_allowed_token_type"}: + return ( + "Slack App Token 验证失败:请在 Basic Information > App-Level Tokens " + "复制 App-Level Token(xapp- 开头),并确认它包含 connections:write scope。" + f" 原始错误:{detail}" + ) + return f"Slack Socket Mode 连接失败:{detail}" + + +class SlackChannel(ChannelPlugin): + """Slack bot channel — Socket Mode inbound and Web API outbound.""" + + MAX_MESSAGE_LENGTH = 39000 + + def __init__(self) -> None: + super().__init__() + self._app: Any = None + self._handler: Any = None + self._socket_task: Optional[asyncio.Task] = None + self._bot_user_id: Optional[str] = None + self._known_thread_ids: OrderedDict[str, None] = OrderedDict() + self._known_thread_ids_max = 5000 + + def meta(self) -> ChannelMeta: + return ChannelMeta(id="slack", label="Slack", aliases=["sl"], order=50) + + def capabilities(self) -> ChannelCapabilities: + return ChannelCapabilities( + chat_types=[ChatType.DIRECT, ChatType.GROUP, ChatType.CHANNEL], + media=False, + threads=True, + reactions=False, + edit=False, + rich_text=True, + self_managed_connection=True, + ) + + def validate_config(self, config: dict) -> Optional[str]: + if not SLACK_AVAILABLE: + return "Missing Python dependency: slack-bolt" + bot_token = resolve_bot_token(config) + app_token = resolve_app_token(config) + if not bot_token: + return "Missing required config: botToken" + if not app_token: + return "Missing required config: appToken" + if not _is_token_reference(bot_token) and not bot_token.startswith("xoxb-"): + return ( + "Slack Bot Token 必须是 Bot User OAuth Token(xoxb- 开头)," + "不能使用 User Token(xoxp-)或其他 token。" + ) + if not _is_token_reference(app_token) and not app_token.startswith("xapp-"): + return "Slack App Token 必须是 App-Level Token(xapp- 开头)。" + return None + + @property + def text_chunk_limit(self) -> int: + return self.MAX_MESSAGE_LENGTH + + @property + def rate_limit(self) -> tuple[float, int]: + return (1.0, 5) + + def normalize_target(self, raw: str) -> Optional[str]: + target = normalize_target(raw, fallback=resolve_home_channel(self._config)) + return target or None + + def target_hint(self) -> str: + return " (C..., G..., or D...)" + + def format_message(self, text: str, format_hint: str = "markdown") -> str: + if format_hint == "plain": + return text + return markdown_to_slack_mrkdwn(text) + + async def start( + self, + config: dict, + on_message: Callable[[InboundMessage], Awaitable[None]], + abort_event: Optional[asyncio.Event] = None, + ) -> None: + self._config = config + self._on_message = on_message + + error = self.validate_config(config) + if error: + self.mark_disconnected(error) + log.error("slack.start.invalid_config", {"error": error}) + raise NonRetryableChannelError(error) + + bot_token = resolve_bot_token(config) + app_token = resolve_app_token(config) + assert AsyncApp is not None + assert AsyncSocketModeHandler is not None + + self._app = AsyncApp(token=bot_token) + abort = abort_event or asyncio.Event() + try: + try: + auth = await self._app.client.auth_test() + except Exception as exc: + message = _format_slack_start_error(exc, phase="bot_auth") + self.mark_disconnected(message) + if _is_permanent_slack_error(exc): + raise NonRetryableChannelError(message) from exc + raise RuntimeError(message) from exc + + self._bot_user_id = _slack_response_get(auth, "user_id") + if not _slack_response_get(auth, "bot_id"): + message = ( + "Slack Bot Token 验证失败:当前 token 认证为用户而不是 Bot。" + "请在 Slack 开发者管理后台 OAuth & Permissions 中复制 " + "Bot User OAuth Token(xoxb- 开头),不要使用 xoxp- User Token。" + ) + self.mark_disconnected(message) + raise NonRetryableChannelError(message) + team_id = _slack_response_get(auth, "team_id", "default") + + self._register_handlers() + await self._connect_socket_mode(app_token) + log.info("slack.connected", {"team_id": team_id, "bot_user_id": self._bot_user_id}) + await abort.wait() + except asyncio.CancelledError: + raise + except NonRetryableChannelError: + raise + except Exception as exc: + message = str(exc) + if self.status.connected or self.status.last_error != message: + self.mark_disconnected(message) + log.warning("slack.socket.stopped", {"error": message}) + raise + finally: + await self.stop() + + async def _connect_socket_mode(self, app_token: str) -> None: + """Connect Socket Mode once and mark connected only after success.""" + assert AsyncSocketModeHandler is not None + self._handler = AsyncSocketModeHandler(self._app, app_token) + timeout_seconds = float(self._config.get("socketConnectTimeoutSeconds") or 15.0) + try: + await asyncio.wait_for( + self._handler.connect_async(), + timeout=max(timeout_seconds, 1.0), + ) + except asyncio.TimeoutError as exc: + message = f"Slack Socket Mode connection timed out after {timeout_seconds:.1f}s" + self.mark_disconnected(message) + raise TimeoutError(message) from exc + except Exception as exc: + message = _format_slack_start_error(exc, phase="socket_mode") + self.mark_disconnected(message) + if _is_permanent_slack_error(exc): + raise NonRetryableChannelError(message) from exc + raise RuntimeError(message) from exc + self.mark_connected() + + async def stop(self) -> None: + handler = self._handler + self._handler = None + if handler is not None: + try: + await handler.close_async() + except Exception as exc: + log.warning("slack.close.failed", {"error": str(exc)}) + + task = self._socket_task + self._socket_task = None + if task is not None and not task.done(): + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + except Exception: + pass + + self.mark_disconnected() + + async def send_text(self, ctx: OutboundContext) -> DeliveryResult: + if not self._app: + return DeliveryResult( + channel_id="slack", + message_id="", + success=False, + error="Slack channel is not connected", + retryable=True, + ) + + target = normalize_target(ctx.to, fallback=resolve_home_channel(self._config)) + if not target: + return DeliveryResult( + channel_id="slack", + message_id="", + success=False, + error="Slack send requires 'to' (channel ID C..., private channel G..., or DM D...)", + ) + + try: + payload: dict[str, Any] = { + "channel": target, + "text": ctx.text, + "mrkdwn": ctx.format_hint != "plain", + } + thread_ts = self._resolve_thread_ts(ctx) + if thread_ts: + payload["thread_ts"] = thread_ts + if should_reply_broadcast(self._config): + payload["reply_broadcast"] = True + + result = await self._app.client.chat_postMessage(**payload) + message_id = str(result.get("ts") or "") + if message_id: + self._remember_thread(message_id) + if thread_ts: + self._remember_thread(thread_ts) + self.record_message() + return DeliveryResult( + channel_id="slack", + message_id=message_id, + chat_id=target, + success=True, + ) + except Exception as exc: + message = str(exc) + retryable = any(term in message.lower() for term in ("timeout", "rate", "temporarily", "connection")) + return DeliveryResult( + channel_id="slack", + message_id="", + success=False, + error=f"Slack send failed: {message}", + retryable=retryable, + ) + + def _register_handlers(self) -> None: + if not self._app: + return + + @self._app.event("message") + async def handle_message_event(event, say): + await self._handle_slack_event(event) + + @self._app.event("app_mention") + async def handle_app_mention_event(event, say): + await self._handle_slack_event(event) + + async def _handle_slack_event(self, event: dict[str, Any]) -> None: + if not self._on_message: + return + inbound = build_inbound_message( + event, + bot_user_id=self._bot_user_id, + config=self._config, + known_thread_ids=set(self._known_thread_ids.keys()), + ) + if inbound is None: + return + self.record_message() + await self._on_message(inbound) + + def _resolve_thread_ts(self, ctx: OutboundContext) -> Optional[str]: + if ctx.thread_id: + return ctx.thread_id + if should_reply_in_thread(self._config): + return ctx.reply_to_id + return None + + def _remember_thread(self, thread_ts: str) -> None: + if not thread_ts: + return + self._known_thread_ids[thread_ts] = None + self._known_thread_ids.move_to_end(thread_ts) + if len(self._known_thread_ids) > self._known_thread_ids_max: + while len(self._known_thread_ids) > self._known_thread_ids_max // 2: + self._known_thread_ids.popitem(last=False) diff --git a/flocks/channel/builtin/slack/config.py b/flocks/channel/builtin/slack/config.py new file mode 100644 index 000000000..7029d752d --- /dev/null +++ b/flocks/channel/builtin/slack/config.py @@ -0,0 +1,75 @@ +"""Configuration helpers for the Slack channel.""" + +from __future__ import annotations + +import re +from typing import Any, Optional + + +_TARGET_PREFIX_RE = re.compile(r"^(?:slack|channel|dm|user):", re.IGNORECASE) + + +def coerce_str(value: Any) -> str: + if value is None: + return "" + return str(value).strip() + + +def coerce_bool(value: Any, default: bool = False) -> bool: + if value is None: + return default + if isinstance(value, bool): + return value + if isinstance(value, (int, float)): + return bool(value) + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in {"1", "true", "yes", "y", "on"}: + return True + if normalized in {"0", "false", "no", "n", "off"}: + return False + return default + + +def strip_target_prefix(raw: str) -> str: + value = raw.strip() + while value: + next_value = _TARGET_PREFIX_RE.sub("", value, count=1).strip() + if next_value == value: + return value + value = next_value + return value + + +def resolve_bot_token(config: dict[str, Any]) -> str: + return coerce_str(config.get("botToken") or config.get("slackBotToken")) + + +def resolve_app_token(config: dict[str, Any]) -> str: + return coerce_str(config.get("appToken") or config.get("slackAppToken")) + + +def resolve_home_channel(config: dict[str, Any]) -> str: + return coerce_str(config.get("homeChannel") or config.get("home_channel")) + + +def should_reply_in_thread(config: dict[str, Any]) -> bool: + return coerce_bool(config.get("replyInThread"), True) + + +def should_reply_broadcast(config: dict[str, Any]) -> bool: + return coerce_bool(config.get("replyBroadcast"), False) + + +def allow_bot_messages(config: dict[str, Any]) -> str: + value = coerce_str(config.get("allowBots")).lower() + if value in {"none", "mentions", "all"}: + return value + return "none" + + +def normalize_target(raw: str, *, fallback: Optional[str] = None) -> str: + target = strip_target_prefix(raw or "") + if target: + return target + return fallback or "" diff --git a/flocks/channel/builtin/slack/format.py b/flocks/channel/builtin/slack/format.py new file mode 100644 index 000000000..7c6c77c59 --- /dev/null +++ b/flocks/channel/builtin/slack/format.py @@ -0,0 +1,44 @@ +"""Markdown to Slack mrkdwn helpers.""" + +from __future__ import annotations + +import html +import re + + +_LINK_RE = re.compile(r"\[([^\]]+)\]\((https?://[^)\s]+)\)") + + +def markdown_to_slack_mrkdwn(text: str) -> str: + """Convert common Markdown to Slack mrkdwn without touching code fences.""" + if not text: + return "" + + placeholders: list[str] = [] + + def protect(match: re.Match[str]) -> str: + placeholders.append(match.group(0)) + return f"\x01SLACK_CODE_{len(placeholders) - 1}\x01" + + result = re.sub(r"```.*?```", protect, text, flags=re.DOTALL) + result = re.sub(r"`[^`\n]+`", protect, result) + + result = html.unescape(result) + result = _LINK_RE.sub(r"<\2|\1>", result) + result = re.sub(r"\*\*(.+?)\*\*", r"*\1*", result, flags=re.DOTALL) + result = re.sub(r"__([^_\n]+)__", r"*\1*", result) + result = re.sub(r"~~(.+?)~~", r"~\1~", result, flags=re.DOTALL) + result = re.sub(r"^#{1,6}\s+(.+)$", r"*\1*", result, flags=re.MULTILINE) + + for index, fragment in enumerate(placeholders): + result = result.replace(f"\x01SLACK_CODE_{index}\x01", fragment) + + return result + + +def strip_bot_mention(text: str, bot_user_id: str) -> str: + if not bot_user_id: + return text.strip() + pattern = re.compile(rf"<@{re.escape(bot_user_id)}(?:\|[^>]+)?>") + cleaned = pattern.sub(" ", text) + return re.sub(r"\s+", " ", cleaned).strip() diff --git a/flocks/channel/builtin/slack/inbound.py b/flocks/channel/builtin/slack/inbound.py new file mode 100644 index 000000000..51ac8f901 --- /dev/null +++ b/flocks/channel/builtin/slack/inbound.py @@ -0,0 +1,296 @@ +"""Inbound Slack event parsing.""" + +from __future__ import annotations + +import json +from typing import Any, Optional + +from flocks.channel.base import ChatType, InboundMessage + +from .config import allow_bot_messages +from .format import strip_bot_mention + + +def _join_parts(parts: list[str]) -> str: + return "\n\n".join(part for part in (p.strip() for p in parts) if part) + + +def _dedupe_part(part: str, existing_text: str) -> str: + candidate = part.strip() + existing = existing_text.strip() + if not candidate: + return "" + if not existing: + return candidate + if candidate == existing or candidate in existing: + return "" + if candidate.startswith(existing): + return candidate[len(existing):].strip() + return candidate + + +def _slack_text_object(value: Any) -> str: + if isinstance(value, str): + return value.strip() + if isinstance(value, dict): + text = value.get("text") + if isinstance(text, str): + return text.strip() + return "" + + +def _rich_text_element_text(element: Any) -> str: + if not isinstance(element, dict): + return "" + + element_type = str(element.get("type") or "") + if element_type == "text": + return str(element.get("text") or "") + if element_type == "user": + return f"<@{element.get('user_id')}>" + if element_type == "channel": + return f"<#{element.get('channel_id')}>" + if element_type == "usergroup": + return f"" + if element_type == "broadcast": + return f"" + if element_type == "emoji": + return f":{element.get('name')}:" + if element_type == "link": + text = str(element.get("text") or "").strip() + url = str(element.get("url") or "").strip() + return f"{text} ({url})" if text and url else text or url + if element_type == "date": + return str(element.get("fallback") or element.get("timestamp") or "") + + children = element.get("elements") + if isinstance(children, list): + child_text = "".join(_rich_text_element_text(child) for child in children).strip() + if element_type == "rich_text_preformatted" and child_text: + return f"```\n{child_text}\n```" + if element_type == "rich_text_quote" and child_text: + return "\n".join(f"> {line}" for line in child_text.splitlines()) + return child_text + return "" + + +def _extract_blocks_text(blocks: Any) -> str: + if not isinstance(blocks, list): + return "" + + parts: list[str] = [] + for block in blocks: + if not isinstance(block, dict): + continue + + block_type = str(block.get("type") or "") + if block_type == "rich_text": + elements = block.get("elements") + if isinstance(elements, list): + parts.append(_join_parts([ + _rich_text_element_text(element) + for element in elements + ])) + continue + + for key in ("text", "title"): + value = _slack_text_object(block.get(key)) + if value: + parts.append(value) + + fields = block.get("fields") + if isinstance(fields, list): + parts.extend(_slack_text_object(field) for field in fields) + + elements = block.get("elements") + if isinstance(elements, list): + parts.extend(_slack_text_object(element) for element in elements) + + return _join_parts(parts) + + +def _extract_attachments_text(attachments: Any) -> str: + if not isinstance(attachments, list): + return "" + + parts: list[str] = [] + for attachment in attachments: + if not isinstance(attachment, dict): + continue + + for key in ("pretext", "title", "text", "fallback"): + value = _slack_text_object(attachment.get(key)) + if value: + parts.append(value) + + fields = attachment.get("fields") + if isinstance(fields, list): + for field in fields: + if not isinstance(field, dict): + continue + title = _slack_text_object(field.get("title")) + value = _slack_text_object(field.get("value")) + if title and value: + parts.append(f"{title}: {value}") + elif value: + parts.append(value) + + blocks = _extract_blocks_text(attachment.get("blocks")) + if blocks: + parts.append(blocks) + + return _join_parts(parts) + + +def _extract_files_text(files: Any) -> str: + if not isinstance(files, list) or not files: + return "" + + names = [ + str(item.get("name") or item.get("title") or item.get("id") or "file") + for item in files + if isinstance(item, dict) + ] + if names: + return "[Slack files: " + ", ".join(names) + "]" + return "" + + +def extract_text(event: dict[str, Any]) -> str: + parts: list[str] = [] + combined = "" + text = event.get("text") + if isinstance(text, str) and text.strip(): + combined = text.strip() + parts.append(combined) + + blocks = _dedupe_part(_extract_blocks_text(event.get("blocks")), combined) + if blocks: + parts.append(blocks) + combined = _join_parts(parts) + + attachments = _dedupe_part(_extract_attachments_text(event.get("attachments")), combined) + if attachments: + parts.append(attachments) + combined = _join_parts(parts) + + files = _dedupe_part(_extract_files_text(event.get("files")), combined) + if files: + parts.append(files) + + rendered = _join_parts(parts) + if rendered: + return rendered + + if event.get("blocks") or event.get("attachments"): + try: + return "[Slack structured message]\n" + json.dumps( + { + "blocks": event.get("blocks"), + "attachments": event.get("attachments"), + }, + ensure_ascii=False, + separators=(",", ":"), + ) + except (TypeError, ValueError): + return "[Slack structured message]" + + return "" + + +def is_own_or_ignored_bot_message( + event: dict[str, Any], + *, + bot_user_id: Optional[str], + config: dict[str, Any], +) -> bool: + user_id = str(event.get("user") or event.get("bot_id") or "") + if bot_user_id and user_id == bot_user_id: + return True + + if not (event.get("bot_id") or event.get("subtype") == "bot_message"): + return False + + policy = allow_bot_messages(config) + if policy == "all": + return False + if policy == "mentions": + return not bool(bot_user_id and f"<@{bot_user_id}>" in str(event.get("text") or "")) + return True + + +def build_inbound_message( + event: dict[str, Any], + *, + bot_user_id: Optional[str], + config: dict[str, Any], + known_thread_ids: set[str], +) -> Optional[InboundMessage]: + """Convert a Slack message/app_mention event into a Flocks InboundMessage.""" + subtype = event.get("subtype") + if subtype in {"message_changed", "message_deleted"}: + return None + + if is_own_or_ignored_bot_message(event, bot_user_id=bot_user_id, config=config): + return None + + channel_id = str(event.get("channel") or "") + user_id = str(event.get("user") or event.get("bot_id") or "") + ts = str(event.get("ts") or event.get("event_ts") or "") + if not channel_id or not user_id or not ts: + return None + + text = extract_text(event) + if not text: + return None + + channel_type = str(event.get("channel_type") or "").lower() + if not channel_type and channel_id.startswith("D"): + channel_type = "im" + + if channel_type == "im": + chat_type = ChatType.DIRECT + elif channel_type == "mpim": + chat_type = ChatType.GROUP + else: + # Slack public/private channels are not one-to-one chats. Treat them + # as channel surfaces while preserving group-trigger behavior in the + # dispatcher (all non-DIRECT chats use the same trigger policy). + chat_type = ChatType.CHANNEL + thread_ts = str(event.get("thread_ts") or "") + is_thread_reply = bool(thread_ts and thread_ts != ts) + mentioned = bool(bot_user_id and f"<@{bot_user_id}>" in text) + continues_bot_thread = bool( + is_thread_reply + and ( + thread_ts in known_thread_ids + or (bot_user_id and str(event.get("parent_user_id") or "") == bot_user_id) + ) + ) + mention_text = strip_bot_mention(text, bot_user_id or "") if mentioned else "" + + if chat_type == ChatType.DIRECT: + mentioned = False + mention_text = "" + elif continues_bot_thread: + mentioned = True + if not mention_text: + mention_text = text + + sender_name = str(event.get("username") or event.get("user_name") or "") or None + + return InboundMessage( + channel_id="slack", + account_id=str(event.get("team") or event.get("team_id") or "default"), + message_id=ts, + sender_id=user_id, + sender_name=sender_name, + chat_id=channel_id, + chat_type=chat_type, + text=text, + reply_to_id=str(event.get("parent_user_id") or "") or None, + thread_id=thread_ts or None, + mentioned=mentioned, + mention_text=mention_text, + raw=event, + ) diff --git a/flocks/channel/builtin/slack/manifest.py b/flocks/channel/builtin/slack/manifest.py new file mode 100644 index 000000000..56aca831b --- /dev/null +++ b/flocks/channel/builtin/slack/manifest.py @@ -0,0 +1,76 @@ +"""Slack app manifest helpers. + +The shape mirrors the Hermes Slack gateway setup flow: users create a Slack +app from a manifest, install it, then paste the Bot User OAuth token and +App-level token into the channel config. +""" + +from __future__ import annotations + + +def build_slack_app_manifest( + *, + app_name: str = "Flocks", + description: str = "Connect Flocks agents to Slack via Socket Mode.", +) -> dict: + """Return a Slack app manifest suitable for the Flocks Slack channel.""" + bot_scopes = [ + "app_mentions:read", + "channels:history", + "channels:read", + "chat:write", + "groups:history", + "groups:read", + "im:history", + "im:read", + "im:write", + "mpim:history", + "mpim:read", + "users:read", + ] + bot_events = [ + "app_mention", + "message.channels", + "message.groups", + "message.im", + "message.mpim", + ] + + return { + "_metadata": { + "major_version": 1, + "minor_version": 1, + }, + "display_information": { + "name": app_name[:35], + "description": description[:140], + "background_color": "#0f172a", + }, + "features": { + "app_home": { + "home_tab_enabled": False, + "messages_tab_enabled": True, + "messages_tab_read_only_enabled": False, + }, + "bot_user": { + "display_name": app_name[:80], + "always_online": True, + }, + }, + "oauth_config": { + "scopes": { + "bot": bot_scopes, + }, + }, + "settings": { + "event_subscriptions": { + "bot_events": bot_events, + }, + "interactivity": { + "is_enabled": False, + }, + "org_deploy_enabled": False, + "socket_mode_enabled": True, + "token_rotation_enabled": False, + }, + } diff --git a/flocks/channel/gateway/manager.py b/flocks/channel/gateway/manager.py index 2d268560a..fa1eed803 100644 --- a/flocks/channel/gateway/manager.py +++ b/flocks/channel/gateway/manager.py @@ -12,7 +12,7 @@ import time from typing import Callable, Optional -from flocks.channel.base import ChannelPlugin, ChannelStatus +from flocks.channel.base import ChannelPlugin, ChannelStatus, NonRetryableChannelError from flocks.channel.inbound.dispatcher import InboundDispatcher from flocks.channel.registry import ChannelRegistry, default_registry from flocks.utils.log import Log @@ -281,8 +281,11 @@ async def _run_with_reconnect( await abort_event.wait() break - # Long-running start() (WebSocket / polling mode) - await self._mark_connected(plugin, channel_id) + # Long-running start() (WebSocket / polling mode). Some active + # channels only know they are connected after a platform + # handshake; let those plugins publish their own status. + if not plugin.capabilities().self_managed_connection: + await self._mark_connected(plugin, channel_id) await start_task if abort_event.is_set(): @@ -299,6 +302,14 @@ async def _run_with_reconnect( except asyncio.CancelledError: break + except NonRetryableChannelError as e: + self._record_error(plugin, channel_id, e) + log.error("gateway.non_retryable", { + "channel": channel_id, + "error": str(e), + "attempt": attempt + 1, + }) + break except Exception as e: self._record_error(plugin, channel_id, e) @@ -361,14 +372,16 @@ def _record_error( channel_id: str, error: Exception, ) -> None: - plugin.mark_disconnected(error=str(error)) + message = str(error) + if plugin.status.connected or plugin.status.last_error != message: + plugin.mark_disconnected(error=message) try: from flocks.channel.events import ChannelDisconnected from flocks.bus.bus import Bus asyncio.ensure_future( Bus.publish(ChannelDisconnected, { "channel_id": channel_id, - "reason": str(error), + "reason": message, }) ) except Exception: diff --git a/flocks/channel/registry.py b/flocks/channel/registry.py index e3a138438..2a8116ff9 100644 --- a/flocks/channel/registry.py +++ b/flocks/channel/registry.py @@ -93,6 +93,7 @@ def _register_builtin_channels(self) -> None: from flocks.channel.builtin.dingtalk.channel import DingTalkChannel from flocks.channel.builtin.email.channel import EmailChannel from flocks.channel.builtin.feishu.channel import FeishuChannel + from flocks.channel.builtin.slack.channel import SlackChannel from flocks.channel.builtin.telegram.channel import TelegramChannel from flocks.channel.builtin.wecom.channel import WeComChannel from flocks.channel.builtin.whatsapp.channel import WhatsAppChannel @@ -103,6 +104,7 @@ def _register_builtin_channels(self) -> None: self.register(WhatsAppChannel()) self.register(DingTalkChannel()) self.register(WeixinChannel()) + self.register(SlackChannel()) self.register(EmailChannel()) def _register_plugin_extension_point(self) -> None: diff --git a/flocks/cli/service_manager.py b/flocks/cli/service_manager.py index 46134d52f..debe4cad9 100644 --- a/flocks/cli/service_manager.py +++ b/flocks/cli/service_manager.py @@ -50,7 +50,7 @@ LOG_TRIM_CHUNK_BYTES = 1024 * 1024 WEBUI_DIRECT_BACKEND_URLS_ENV = "FLOCKS_WEBUI_DIRECT_BACKEND_URLS" DEFAULT_FLOCKS_CONSOLE_BASE_URL = "https://portalflocks.threatbook.cn" -DEFAULT_VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS = "portalflocks.threatbook.cn" +DEFAULT_VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS = "127.0.0.1,localhost,portalflocks.threatbook.cn" MISSING_PORT_OWNER_TOOLS_WARNING = ( "未检测到 lsof 或 fuser,无法解析端口占用 PID;将退回到 bind 检查。" "可尝试安装:apt/yum install lsof -y" diff --git a/flocks/hub/catalog.py b/flocks/hub/catalog.py index d82e001f5..87a45ce51 100644 --- a/flocks/hub/catalog.py +++ b/flocks/hub/catalog.py @@ -740,6 +740,23 @@ def _version_tuple(value: str) -> tuple[int, ...]: return tuple(int(part) for part in parts) if parts else (0,) +def _catalog_install_state( + install_path: Optional[Path], + record: Optional[local.InstalledPluginRecord], + available_version: str, +) -> tuple[str, Optional[str]]: + if install_path is None: + return "available", None + if record is None: + # An inferred install has no trustworthy package version because Hub + # manifests are not copied into the install directory. Reconcile it + # through the update path instead of assuming it is already current. + return "updateAvailable", None + if _version_tuple(record.version) < _version_tuple(available_version): + return "updateAvailable", record.version + return "installed", record.version + + def _os_compatible(manifest: HubPluginManifest) -> bool: allowed = {item.lower() for item in manifest.compatibility.os} if not allowed: @@ -751,15 +768,18 @@ def _os_compatible(manifest: HubPluginManifest) -> bool: def _entry_from_manifest(manifest: HubPluginManifest) -> HubCatalogEntry: record = local.get_record(manifest.type, manifest.id) - install_path = _resolve_install_path(manifest.type, manifest.id, record, local.infer_local_installs()) - - state = "available" - installed_version: Optional[str] = None - if install_path: - installed_version = record.version if record else manifest.version - state = "installed" - if _version_tuple(installed_version) < _version_tuple(manifest.version): - state = "updateAvailable" + install_path, record = _resolve_install_path( + manifest.type, + manifest.id, + record, + local.infer_local_installs(), + ) + + state, installed_version = _catalog_install_state( + install_path, + record, + manifest.version, + ) if not _os_compatible(manifest): state = "incompatible" @@ -792,15 +812,16 @@ def _resolve_install_path( plugin_id: str, record: Optional[local.InstalledPluginRecord], inferred_installs: Optional[dict[tuple[PluginType, str], Path]] = None, -) -> Optional[Path]: +) -> tuple[Optional[Path], Optional[local.InstalledPluginRecord]]: if record and record.installPath: path = Path(record.installPath) if local.has_install_payload(plugin_type, path): - return path + return path, record local.remove_installed_record(plugin_type, plugin_id) + record = None if inferred_installs is not None: - return inferred_installs.get((plugin_type, plugin_id)) - return local.infer_local_install(plugin_type, plugin_id) + return inferred_installs.get((plugin_type, plugin_id)), record + return local.infer_local_install(plugin_type, plugin_id), record def _entry_from_index( @@ -809,15 +830,18 @@ def _entry_from_index( inferred_installs: dict[tuple[PluginType, str], Path], ) -> HubCatalogEntry: record = records.get(f"{item.type}:{item.id}") - install_path = _resolve_install_path(item.type, item.id, record, inferred_installs) + install_path, record = _resolve_install_path( + item.type, + item.id, + record, + inferred_installs, + ) - state = "available" - installed_version: Optional[str] = None - if install_path: - installed_version = record.version if record else item.version - state = "installed" - if _version_tuple(installed_version) < _version_tuple(item.version): - state = "updateAvailable" + state, installed_version = _catalog_install_state( + install_path, + record, + item.version, + ) return HubCatalogEntry( id=item.id, @@ -875,11 +899,10 @@ def _entry_from_bundled_tool( """Catalog entry for a tool bundled inside flockshub. State is computed exactly like :func:`_entry_from_index`: - * ``installed`` — the user installed it under - ``~/.flocks/plugins/tools///`` - (record or on-disk payload found). - * ``updateAvailable`` — installed copy is older than the bundled - version. + * ``installed`` — an installed record confirms the local copy is + current. + * ``updateAvailable`` — the installed copy is older, or an unrecorded + on-disk payload needs reconciliation. * ``available`` — bundled in flockshub, no local install. The runtime ``ToolRegistry`` only picks up installed tools, so an "available" entry @@ -889,15 +912,18 @@ def _entry_from_bundled_tool( * ``incompatible`` — manifest declares an incompatible OS. """ record = records.get(f"{manifest.type}:{manifest.id}") - install_path = _resolve_install_path(manifest.type, manifest.id, record, inferred_installs) - - state = "available" - installed_version: Optional[str] = None - if install_path: - installed_version = record.version if record else manifest.version - state = "installed" - if _version_tuple(installed_version) < _version_tuple(manifest.version): - state = "updateAvailable" + install_path, record = _resolve_install_path( + manifest.type, + manifest.id, + record, + inferred_installs, + ) + + state, installed_version = _catalog_install_state( + install_path, + record, + manifest.version, + ) if not _os_compatible(manifest): state = "incompatible" diff --git a/flocks/hub/installer.py b/flocks/hub/installer.py index bcbbf2407..07b9a30b0 100644 --- a/flocks/hub/installer.py +++ b/flocks/hub/installer.py @@ -90,21 +90,54 @@ def _resolve_install_destination( return local.install_dir(plugin_type, plugin_id, scope) -def _copy_package(src: Path, dst: Path) -> None: +def _remove_path(path: Path) -> None: + if path.is_dir() and not path.is_symlink(): + shutil.rmtree(path) + elif path.exists() or path.is_symlink(): + path.unlink() + + +def _replace_prepared_path(prepared: Path, dst: Path) -> Path | None: + backup: Path | None = None + if dst.exists() or dst.is_symlink(): + backup = dst.parent / f".{dst.name}.bak" + _remove_path(backup) + dst.replace(backup) + try: + prepared.replace(dst) + except Exception: + if backup is not None and (backup.exists() or backup.is_symlink()): + backup.replace(dst) + raise + return backup + + +def _commit_replacement(backup: Path | None) -> None: + if backup is None: + return + try: + _remove_path(backup) + except OSError: + pass + + +def _rollback_replacement(dst: Path, backup: Path | None) -> None: + _remove_path(dst) + if backup is not None and (backup.exists() or backup.is_symlink()): + backup.replace(dst) + + +def _copy_package(src: Path, dst: Path, *, retain_backup: bool = False) -> Path | None: parent = dst.parent parent.mkdir(parents=True, exist_ok=True) tmp = Path(tempfile.mkdtemp(prefix=f".{dst.name}.", dir=str(parent))) try: _copy_package_contents(src, tmp) - backup = None - if dst.exists(): - backup = parent / f".{dst.name}.bak" - if backup.exists(): - shutil.rmtree(backup) - dst.replace(backup) - tmp.replace(dst) - if backup and backup.exists(): - shutil.rmtree(backup) + backup = _replace_prepared_path(tmp, dst) + if not retain_backup: + _commit_replacement(backup) + return None + return backup except Exception: if tmp.exists(): shutil.rmtree(tmp, ignore_errors=True) @@ -122,32 +155,26 @@ def _copy_package_contents(src: Path, dst: Path) -> None: shutil.copy2(item, target) -def _replace_dir(src: Path, dst: Path) -> None: - parent = dst.parent - backup = None - if dst.exists(): - backup = parent / f".{dst.name}.bak" - if backup.exists(): - shutil.rmtree(backup) - dst.replace(backup) - src.replace(dst) - if backup and backup.exists(): - shutil.rmtree(backup) - - def _contracts_access_dir(plugin_id: str, scope: str) -> Path: return local.install_root("webui", scope).parent / "access" / plugin_id -def _copy_attached_access_contracts(plugin_type: PluginType, plugin_id: str, src: Path, scope: str) -> Path | None: +def _copy_attached_access_contracts( + plugin_type: PluginType, + plugin_id: str, + src: Path, + scope: str, + *, + retain_backup: bool = False, +) -> tuple[Path, Path | None] | None: if plugin_type != "webui": return None access_src = src / "access" if not access_src.is_dir(): return None access_dst = _contracts_access_dir(plugin_id, scope) - _copy_package(access_src, access_dst) - return access_dst + backup = _copy_package(access_src, access_dst, retain_backup=retain_backup) + return access_dst, backup def _remove_attached_access_contracts(plugin_type: PluginType, plugin_id: str, scope: str) -> None: @@ -182,21 +209,34 @@ def _build_webui_pages(plugin_id: str, install_dir: Path) -> None: raise RuntimeError(f"Failed to build WebUI page bundle for {plugin_id}/{page.id}{detail}") -def _copy_webui_package_with_build(plugin_id: str, src: Path, dst: Path) -> None: +def _copy_webui_package_with_build( + plugin_id: str, + src: Path, + dst: Path, + *, + retain_backup: bool = False, +) -> Path | None: parent = dst.parent parent.mkdir(parents=True, exist_ok=True) tmp = Path(tempfile.mkdtemp(prefix=f".{dst.name}.", dir=str(parent))) try: _copy_package_contents(src, tmp) _build_webui_pages(plugin_id, tmp) - _replace_dir(tmp, dst) + backup = _replace_prepared_path(tmp, dst) + if not retain_backup: + _commit_replacement(backup) + return None + return backup except Exception: if tmp.exists(): shutil.rmtree(tmp, ignore_errors=True) raise -async def _refresh_runtime(plugin_type: PluginType) -> None: +async def _refresh_runtime( + plugin_type: PluginType, + changed_path: Path | None = None, +) -> None: if plugin_type == "skill": from flocks.skill.skill import Skill @@ -223,7 +263,13 @@ async def _refresh_runtime(plugin_type: PluginType) -> None: from flocks.tool.registry import ToolRegistry await ToolRegistry.init_async() - await asyncio.to_thread(ToolRegistry.refresh_plugin_tools) + if changed_path is None: + await asyncio.to_thread(ToolRegistry.refresh_plugin_tools) + else: + await asyncio.to_thread( + ToolRegistry.refresh_plugin_tools, + changed_path=changed_path, + ) clear_device_template_cache() # Drop the descriptor cache so freshly installed/uninstalled # API plugins surface in ``_load_provider_yaml_metadata`` (and @@ -331,16 +377,6 @@ async def _rollback_component_ref_installs( continue -def _rollback_install_path(plugin_type: PluginType, plugin_id: str, install_path: Path, scope: str) -> None: - if ".flocks/plugins" in install_path.as_posix(): - if install_path.is_dir(): - shutil.rmtree(install_path, ignore_errors=True) - elif install_path.exists(): - install_path.unlink() - _remove_attached_access_contracts(plugin_type, plugin_id, scope) - local.remove_installed_record(plugin_type, plugin_id) - - def _is_project_install_path(plugin_type: PluginType, install_path: Path) -> bool: try: project_root = local.install_root(plugin_type, "project").resolve() @@ -496,16 +532,31 @@ async def install_plugin( validate_package(src, manifest) dst = _resolve_install_destination(plugin_type, plugin_id, src, scope) component_key = f"component:{plugin_id}" - component_had_install = plugin_type == "component" and local.infer_local_install(plugin_type, plugin_id) is not None component_ref_installs: list[tuple[PluginType, str]] = [] + previous_record = local.get_record(plugin_type, plugin_id) + package_backup: Path | None = None + package_replaced = False + access_replacement: tuple[Path, Path | None] | None = None try: if plugin_type == "component": component_ref_installs = await _install_component_refs(manifest, scope=scope, progress=progress) if plugin_type == "webui": - _copy_webui_package_with_build(plugin_id, src, dst) + package_backup = _copy_webui_package_with_build( + plugin_id, + src, + dst, + retain_backup=True, + ) else: - _copy_package(src, dst) - _copy_attached_access_contracts(plugin_type, plugin_id, src, scope) + package_backup = _copy_package(src, dst, retain_backup=True) + package_replaced = True + access_replacement = _copy_attached_access_contracts( + plugin_type, + plugin_id, + src, + scope, + retain_backup=True, + ) record = local.make_record( plugin_type=plugin_type, plugin_id=plugin_id, @@ -518,15 +569,30 @@ async def install_plugin( ) local.save_installed_record(record) clear_catalog_caches() - await _refresh_runtime(plugin_type) + await _refresh_runtime(plugin_type, dst) if plugin_type == "component": await _emit_component_progress(progress, manifest, "complete", record=record, message="Installed") + if access_replacement is not None: + _commit_replacement(access_replacement[1]) + _commit_replacement(package_backup) return record except Exception: + if access_replacement is not None: + _rollback_replacement(*access_replacement) + if package_replaced: + _rollback_replacement(dst, package_backup) + if previous_record is None: + local.remove_installed_record(plugin_type, plugin_id) + else: + local.save_installed_record(previous_record) + clear_catalog_caches() if plugin_type == "component": - if not component_had_install: - _rollback_install_path(plugin_type, plugin_id, dst, scope) await _rollback_component_ref_installs(component_ref_installs, component_key) + if package_replaced: + try: + await _refresh_runtime(plugin_type, dst) + except Exception: + pass raise @@ -584,13 +650,24 @@ async def uninstall_plugin(plugin_type: PluginType, plugin_id: str) -> bool: record = local.get_record(plugin_type, plugin_id) install_path = Path(record.installPath) if record and record.installPath else local.infer_local_install(plugin_type, plugin_id) if install_path is None or not install_path.exists(): + changed_path = install_path + if changed_path is None and record is not None: + try: + changed_path = _resolve_install_destination( + plugin_type, + plugin_id, + plugin_root(plugin_type, plugin_id), + record.scope, + ) + except Exception: + changed_path = local.install_dir(plugin_type, plugin_id, record.scope) children_removed = await _uninstall_component_refs(manifest) if manifest is not None else False had_record = record is not None local.remove_installed_record(plugin_type, plugin_id) clear_catalog_caches() _clear_device_template_cache_if_needed(plugin_type) if children_removed or had_record: - await _refresh_runtime(plugin_type) + await _refresh_runtime(plugin_type, changed_path) return children_removed or had_record project_root = local.install_root(plugin_type, "project").resolve() resolved_install_path = install_path.resolve() @@ -618,5 +695,5 @@ async def uninstall_plugin(plugin_type: PluginType, plugin_id: str) -> bool: local.remove_installed_record(plugin_type, plugin_id) clear_catalog_caches() _cleanup_orphan_api_services(orphan_keys) - await _refresh_runtime(plugin_type) + await _refresh_runtime(plugin_type, install_path) return True diff --git a/flocks/hub/security.py b/flocks/hub/security.py index c630fdf41..3707add24 100644 --- a/flocks/hub/security.py +++ b/flocks/hub/security.py @@ -23,6 +23,30 @@ def _sha256(path: Path) -> str: return f"sha256:{digest.hexdigest()}" +def _validate_python_syntax(package_dir: Path, manifest: HubPluginManifest) -> None: + python_files = { + package_dir / entrypoint + for entrypoint in manifest.entrypoints + if Path(entrypoint).suffix == ".py" + } + if manifest.type in {"tool", "device"}: + python_files.update( + path + for path in package_dir.rglob("*.py") + if not any(part in SKIP_NAMES for part in path.relative_to(package_dir).parts) + ) + + for path in sorted(python_files): + if not path.is_file(): + continue + relative_path = path.relative_to(package_dir).as_posix() + try: + source = path.read_text(encoding="utf-8") + compile(source, str(path), "exec") + except (SyntaxError, UnicodeDecodeError) as exc: + raise ValueError(f"Invalid Python source in package: {relative_path}: {exc}") from exc + + def validate_package(package_dir: Path, manifest: HubPluginManifest) -> None: base = package_dir.resolve() if not base.is_dir(): @@ -41,6 +65,8 @@ def validate_package(package_dir: Path, manifest: HubPluginManifest) -> None: if not entry.exists(): raise ValueError(f"Missing entrypoint: {entrypoint}") + _validate_python_syntax(base, manifest) + for rel_path, expected in manifest.checksums.items(): if not expected: continue diff --git a/flocks/security/channel_secrets.py b/flocks/security/channel_secrets.py index 48e0f48cf..99feb4107 100644 --- a/flocks/security/channel_secrets.py +++ b/flocks/security/channel_secrets.py @@ -18,6 +18,9 @@ SENSITIVE_FIELD_NAMES: Set[str] = { "appSecret", "botToken", + "appToken", + "slackAppToken", + "slackBotToken", "secret", "clientSecret", "apiKey", diff --git a/flocks/server/routes/channel.py b/flocks/server/routes/channel.py index 02349aaff..dfa0facc1 100644 --- a/flocks/server/routes/channel.py +++ b/flocks/server/routes/channel.py @@ -4,10 +4,11 @@ from __future__ import annotations +import json from typing import Optional from fastapi import APIRouter, HTTPException, Request -from fastapi.responses import JSONResponse +from fastapi.responses import JSONResponse, Response from pydantic import BaseModel from flocks.channel.gateway.manager import default_manager @@ -212,6 +213,27 @@ async def list_channels(): ] +@router.get("/slack/manifest") +async def slack_manifest(): + """Return a Slack app manifest for creating the Flocks Slack app.""" + from flocks.channel.builtin.slack.manifest import build_slack_app_manifest + + return build_slack_app_manifest() + + +@router.get("/slack/manifest/download") +async def slack_manifest_download(): + """Download a Slack app manifest for creating the Flocks Slack app.""" + from flocks.channel.builtin.slack.manifest import build_slack_app_manifest + + payload = json.dumps(build_slack_app_manifest(), indent=2, ensure_ascii=False) + return Response( + content=payload + "\n", + media_type="application/json", + headers={"Content-Disposition": 'attachment; filename="flocks-slack-manifest.json"'}, + ) + + @router.post("/{channel_id}/record-inbound") async def record_inbound(channel_id: str): """Notify the gateway that a message was received on this channel. diff --git a/flocks/server/routes/config.py b/flocks/server/routes/config.py index 7f403f92c..64f920891 100644 --- a/flocks/server/routes/config.py +++ b/flocks/server/routes/config.py @@ -16,6 +16,7 @@ } """ +import json import re import xml.etree.ElementTree as ET from pathlib import Path @@ -35,6 +36,51 @@ log = Log.create(service="routes.config") +def _apply_channel_allow_from_deletions(config_data: Dict[str, Any]) -> None: + """Remove persisted channel allowFrom when PATCH explicitly sends null.""" + channels = config_data.get("channels") + if not isinstance(channels, dict): + return + + channel_ids = [ + channel_id + for channel_id, channel_cfg in channels.items() + if isinstance(channel_cfg, dict) + and "allowFrom" in channel_cfg + and channel_cfg.get("allowFrom") is None + ] + if not channel_ids: + return + + config_file = Config.get_config_file() + if not config_file.exists(): + return + + try: + raw_config = json.loads(config_file.read_text(encoding="utf-8")) + except Exception as exc: + log.warning("config.channel_allow_from_delete_failed", {"error": str(exc)}) + return + + raw_channels = raw_config.get("channels") + if not isinstance(raw_channels, dict): + return + + changed = False + for channel_id in channel_ids: + raw_channel = raw_channels.get(channel_id) + if isinstance(raw_channel, dict) and "allowFrom" in raw_channel: + del raw_channel["allowFrom"] + changed = True + + if changed: + config_file.parent.mkdir(parents=True, exist_ok=True) + config_file.write_text( + json.dumps(raw_config, indent=2, ensure_ascii=False), + encoding="utf-8", + ) + + def _build_model_from_config( provider_id: str, model_id: str, @@ -554,6 +600,8 @@ async def update_config(config_data: Dict[str, Any]) -> Dict[str, Any]: flocks.json, so that plaintext secrets never land in that file. """ try: + _apply_channel_allow_from_deletions(config_data) + # Extract channel sensitive fields into .secret.json before persisting if "channels" in config_data and isinstance(config_data.get("channels"), dict): from flocks.security.channel_secrets import extract_channel_secrets diff --git a/flocks/server/routes/provider.py b/flocks/server/routes/provider.py index 79e4d7e37..f05768049 100644 --- a/flocks/server/routes/provider.py +++ b/flocks/server/routes/provider.py @@ -13,13 +13,15 @@ import time from pathlib import Path from typing import Any, Dict, List, Optional -from fastapi import APIRouter, Body, Depends, HTTPException, Query, status +from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request, Response, status from pydantic import BaseModel, Field, ConfigDict +from flocks.audit import emit_audit_event +from flocks.auth.context import AuthUser from flocks.utils.log import Log from flocks.provider.provider import Provider, ModelInfo as ProviderModelInfo from flocks.security.secrets import SecretManager -from flocks.server.auth import require_admin +from flocks.server.auth import get_request_ip, get_request_user_agent, require_admin from flocks.config.config import Config from flocks.config.config_writer import ConfigWriter from flocks.storage.storage import Storage @@ -1656,6 +1658,43 @@ class ProviderCredentialResponse(BaseModel): has_credential: bool +def _load_llm_provider_credentials( + provider_id: str, + *, + reveal_api_key: bool = False, +) -> ProviderCredentialResponse: + from flocks.security import get_secret_manager + + secrets = get_secret_manager() + + secret_id = f"{provider_id}_llm_key" + api_key = secrets.get(secret_id) + if not api_key: + secret_id = f"{provider_id}_api_key" + api_key = secrets.get(secret_id) + if not api_key: + api_key = _get_inline_provider_api_key(provider_id) + + base_url = None + raw_provider = ConfigWriter.get_provider_raw(provider_id) + if raw_provider: + options = raw_provider.get("options", {}) + base_url = options.get("baseURL") or options.get("base_url") + if not base_url: + base_url = secrets.get(f"{provider_id}_base_url") + + is_placeholder = _is_placeholder_api_key(api_key) + ui_api_key = None if is_placeholder else api_key + + return ProviderCredentialResponse( + secret_id=secret_id if api_key else None, + api_key=ui_api_key if reveal_api_key else None, + api_key_masked=SecretManager.mask(ui_api_key) if ui_api_key else None, + base_url=base_url, + has_credential=bool(api_key), + ) + + @router.get( "/{provider_id}/credentials", response_model=ProviderCredentialResponse, @@ -1673,51 +1712,53 @@ async def get_provider_credentials( For API services use GET /{provider_id}/service-credentials instead. """ - from flocks.security import get_secret_manager - try: - secrets = get_secret_manager() - - # LLM provider convention: _llm_key first, fall back to legacy _api_key - secret_id = f"{provider_id}_llm_key" - api_key = secrets.get(secret_id) - if not api_key: - secret_id = f"{provider_id}_api_key" - api_key = secrets.get(secret_id) - if not api_key: - api_key = _get_inline_provider_api_key(provider_id) + return _load_llm_provider_credentials(provider_id) + except Exception as e: + log.error("provider.credentials.get.error", {"provider_id": provider_id, "error": str(e)}) + raise HTTPException(status_code=500, detail=str(e)) - # base_url from flocks.json raw (not resolved) - base_url = None - raw_provider = ConfigWriter.get_provider_raw(provider_id) - if raw_provider: - options = raw_provider.get("options", {}) - base_url = options.get("baseURL") or options.get("base_url") - # Fallback: check legacy .secret.json entry (migration may not have run yet) - if not base_url: - base_url = secrets.get(f"{provider_id}_base_url") - # When the stored value is the no-auth sentinel, hide it from the - # response so the UI doesn't pre-fill the password input with the - # literal string "not-needed". We still report ``has_credential=True`` - # because a credential record DOES exist; the user just chose to leave - # the key blank for an internal/no-auth gateway. - is_placeholder = _is_placeholder_api_key(api_key) - ui_api_key = None if is_placeholder else api_key +@router.post( + "/{provider_id}/credentials/reveal", + response_model=ProviderCredentialResponse, + summary="Reveal provider credentials", + description="Reveal credential information for a registered LLM provider to an administrator.", +) +async def reveal_provider_credentials( + provider_id: str, + request: Request, + response: Response, + admin: AuthUser = Depends(require_admin), +): + response.headers["Cache-Control"] = "no-store" + response.headers["Pragma"] = "no-cache" - return ProviderCredentialResponse( - secret_id=secret_id if api_key else None, - api_key=None, - api_key_masked=( - SecretManager.mask(ui_api_key) - if ui_api_key - else None - ), - base_url=base_url, - has_credential=bool(api_key), - ) + try: + credentials = _load_llm_provider_credentials(provider_id, reveal_api_key=True) + try: + await emit_audit_event( + "provider.credentials_reveal", + { + "action": "credentials_reveal", + "actor_id": admin.id, + "actor_name": admin.username, + "user_id": admin.id, + "username": admin.username, + "provider_id": provider_id, + "secret_id": credentials.secret_id, + "ip": get_request_ip(request), + "user_agent": get_request_user_agent(request), + }, + ) + except Exception as audit_error: + log.warn( + "provider.credentials.reveal.audit_failed", + {"provider_id": provider_id, "error": str(audit_error)}, + ) + return credentials except Exception as e: - log.error("provider.credentials.get.error", {"provider_id": provider_id, "error": str(e)}) + log.error("provider.credentials.reveal.error", {"provider_id": provider_id, "error": str(e)}) raise HTTPException(status_code=500, detail=str(e)) diff --git a/flocks/tool/channel/channel_message.py b/flocks/tool/channel/channel_message.py index 6a908e8e1..cfed0b452 100644 --- a/flocks/tool/channel/channel_message.py +++ b/flocks/tool/channel/channel_message.py @@ -2,7 +2,7 @@ channel_message tool — sends a message to the messaging channel bound to a given session. Looks up the SessionBinding for the given session_id to automatically resolve -the target channel (WeCom / Weixin / Feishu / DingTalk / Telegram / WhatsApp / Email) +the target channel (WeCom / Weixin / Feishu / DingTalk / Telegram / WhatsApp / Email / Slack) and chat_id, so the caller does not need to specify them manually. @@ -29,6 +29,7 @@ "telegram": ["telegram", "tg", "tele"], "whatsapp": ["whatsapp", "wa"], "email": ["email", "mail", "邮件", "imap", "smtp"], + "slack": ["slack", "sl"], } @@ -136,7 +137,7 @@ async def _http_session_send( description=( "Send a message to the messaging channel bound to a session. " "Channel types: WeCom/企业微信=wecom, Weixin/微信=weixin, Feishu=feishu, DingTalk=dingtalk, " - "Telegram=telegram, WhatsApp=whatsapp, Email/邮件=email. " + "Telegram=telegram, WhatsApp=whatsapp, Email/邮件=email, Slack=slack. " "Resolves the target channel and chat automatically from session_id. " "Use channel_type to target a specific channel when the session has multiple bindings." ), @@ -166,6 +167,7 @@ async def _http_session_send( "telegram", "whatsapp", "email", + "slack", "企微", "企业微信", "微信", @@ -175,7 +177,7 @@ async def _http_session_send( ], description=( "Target channel: wecom=企业微信, weixin=微信, feishu=飞书, dingtalk=钉钉, " - "telegram=Telegram, whatsapp=WhatsApp, or email=邮件. " + "telegram=Telegram, whatsapp=WhatsApp, email=邮件, or slack=Slack. " "Chinese aliases are accepted. " "If omitted and the session has only one binding, that channel is used automatically. " "If omitted and the session has multiple bindings, the message is sent to all of them." diff --git a/flocks/tool/channel/im_send_message.py b/flocks/tool/channel/im_send_message.py index 834218ada..83d1af039 100644 --- a/flocks/tool/channel/im_send_message.py +++ b/flocks/tool/channel/im_send_message.py @@ -23,6 +23,7 @@ "telegram": ["telegram", "tg", "tele"], "whatsapp": ["whatsapp", "wa"], "email": ["email", "mail", "邮件", "imap", "smtp"], + "slack": ["slack", "sl"], } @@ -204,10 +205,10 @@ async def _resolve_target( name="im_send_message", description=( "Resolve a messaging channel target session and optionally send a message. " - "Use this for WeCom/企业微信, Weixin/微信, Feishu, DingTalk, Telegram, WhatsApp, Email/邮件, " + "Use this for WeCom/企业微信, Weixin/微信, Feishu, DingTalk, Telegram, WhatsApp, Email/邮件, Slack, " "or custom channel sessions when the user asks to send a message through a connected channel. " "Use channel_type=wecom for 企业微信, channel_type=weixin for 微信, channel_type=telegram for Telegram, " - "channel_type=whatsapp for WhatsApp, and channel_type=email for 邮件. " + "channel_type=whatsapp for WhatsApp, channel_type=email for 邮件, and channel_type=slack for Slack. " "If session_id is omitted, it uses the current channel session when available, otherwise asks the user to pick one." ), category=ToolCategory.SYSTEM, @@ -230,7 +231,7 @@ async def _resolve_target( required=False, description=( "Optional channel filter, such as wecom=企业微信, weixin=微信, " - "feishu, dingtalk, telegram, whatsapp, email=邮件, or a custom channel id." + "feishu, dingtalk, telegram, whatsapp, email=邮件, slack, or a custom channel id." ), ), ToolParameter( diff --git a/flocks/tool/registry.py b/flocks/tool/registry.py index d348a8387..d2bff0d7a 100644 --- a/flocks/tool/registry.py +++ b/flocks/tool/registry.py @@ -607,6 +607,9 @@ class ToolRegistry: _dynamic_modules: Dict[str, str] = {} _dynamic_tools_by_module: Dict[str, List[str]] = {} _plugin_tool_names: List[str] = [] + # Source-level plugin failures observed by the last load. They remain + # diagnostic only because the plugin loader already isolates each source. + _plugin_load_errors: List[str] = [] _revision: int = 0 _failure_state: Dict[str, Dict[str, Any]] = {} _failure_disable_threshold: int = 3 @@ -1052,7 +1055,7 @@ def init(cls) -> None: cls._register_builtin_tools() cls._register_dynamic_tools() cls._register_plugin_extension_point() - cls._load_plugin_tools() + cls._plugin_load_errors = cls._load_plugin_tools() cls._initialized = True log.debug("tool_registry.initialized", {"count": len(cls._tools)}) finally: @@ -1064,7 +1067,7 @@ async def init_async(cls) -> None: await asyncio.to_thread(cls.init) @classmethod - def _load_plugin_tools(cls, errors: Optional[List[str]] = None) -> None: + def _load_plugin_tools(cls, errors: Optional[List[str]] = None) -> List[str]: """Load plugin tools from both user-level and project-level plugin dirs on init. Without this, YAML/Python plugin tools only appear after an explicit @@ -1077,18 +1080,23 @@ def _load_plugin_tools(cls, errors: Optional[List[str]] = None) -> None: Tracks which tool names were added so that ``refresh_plugin_tools`` can accurately unregister stale entries (regardless of the ``ToolInfo.source`` value). + + Returns every source-level load error for diagnostics and scoped Hub + refresh decisions. """ before = set(cls._tools.keys()) + load_errors: List[str] = [] try: from flocks.plugin import PluginLoader - load_errors = PluginLoader.load_extension("TOOLS", load_entry_points=True) - if errors is not None: - errors.extend(load_errors) + reported_errors = PluginLoader.load_extension("TOOLS", load_entry_points=True) + if reported_errors: + load_errors.extend(reported_errors) except Exception as e: log.warn("tool_registry.plugin_load_failed", {"error": str(e)}) - if errors is not None: - errors.append(f"plugin loader: {e}") + load_errors.append(f"plugin loader: {e}") + if errors is not None: + errors.extend(load_errors) after = set(cls._tools.keys()) new_plugin_tools = sorted(after - before) python_tool_sources: Dict[str, Path] = {} @@ -1131,8 +1139,14 @@ def _load_plugin_tools(cls, errors: Optional[List[str]] = None) -> None: except ValueError: tool.info.native = True cls._plugin_tool_names = sorted(set(new_plugin_tools) | python_plugin_names) - if errors: - return + if errors is not None and load_errors: + return load_errors + cls._finalize_plugin_tools_load() + return load_errors + + @classmethod + def _finalize_plugin_tools_load(cls) -> None: + """Apply configuration overlays after an accepted plugin load.""" cls._bootstrap_user_api_services() # Defence-in-depth: ``register()`` is the canonical writer for # ``_enabled_defaults`` but this catches any tool that landed in @@ -1612,39 +1626,80 @@ def start_watcher(cls) -> None: cls._watcher.start() @classmethod - def refresh_plugin_tools(cls) -> List[str]: + def refresh_plugin_tools(cls, changed_path: Optional[Path] = None) -> List[str]: """Reload plugin tools (YAML + Python) from disk. Unregisters stale plugin tools first so that deleted files are - correctly removed from the registry. + correctly removed from the registry. When a Hub operation supplies + ``changed_path``, source failures outside that path are isolated and + do not block the operation; failures inside it still roll back. """ cls._ensure_initialized() with cls._refresh_lock: tools_before = cls._tools.copy() defaults_before = cls._enabled_defaults.copy() plugin_names_before = list(cls._plugin_tool_names) + plugin_errors_before = list(cls._plugin_load_errors) + enabled_before = { + name: tool.info.enabled + for name, tool in tools_before.items() + } + revision_before = cls._revision load_errors: List[str] = [] - try: - cls._unregister_plugin_tools() - cls._load_plugin_tools(load_errors) - except Exception: + def _restore_snapshot() -> None: + for name, enabled in enabled_before.items(): + tool = tools_before.get(name) + if tool is not None: + tool.info.enabled = enabled cls._tools.clear() cls._tools.update(tools_before) cls._enabled_defaults.clear() cls._enabled_defaults.update(defaults_before) cls._plugin_tool_names = plugin_names_before - raise - if load_errors: - cls._tools.clear() - cls._tools.update(tools_before) - cls._enabled_defaults.clear() - cls._enabled_defaults.update(defaults_before) - cls._plugin_tool_names = plugin_names_before - raise ToolRefreshError("plugin", load_errors) + cls._plugin_load_errors = plugin_errors_before + cls._revision = revision_before - cls._bump_revision("plugin_refresh") - return cls.all_tool_ids() + try: + cls._unregister_plugin_tools() + cls._load_plugin_tools(load_errors) + changed_root = changed_path.resolve() if changed_path is not None else None + fatal_errors: List[str] = [] + for error in load_errors: + if changed_root is None or error.startswith( + ( + "plugin loader:", + "extension point not found:", + "entry point scan:", + ) + ): + fatal_errors.append(error) + continue + if error.startswith("entry point "): + continue + source_text, separator, _detail = error.partition(": ") + if not separator: + fatal_errors.append(error) + continue + source_path = Path(source_text) + if not source_path.is_absolute(): + fatal_errors.append(error) + continue + source_path = source_path.resolve() + if source_path == changed_root or changed_root in source_path.parents: + fatal_errors.append(error) + if fatal_errors: + raise ToolRefreshError("plugin", fatal_errors) + + if load_errors: + cls._finalize_plugin_tools_load() + + cls._plugin_load_errors = load_errors + cls._bump_revision("plugin_refresh") + return cls.all_tool_ids() + except Exception: + _restore_snapshot() + raise @classmethod def refresh_dynamic_tools(cls) -> List[str]: diff --git a/pyproject.toml b/pyproject.toml index 783712fde..0bb573215 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -71,6 +71,9 @@ dependencies = [ "lark-oapi>=1.3.0", # DingTalk Stream Mode (WebSocket long-connection) "dingtalk-stream>=0.20", + # Slack Socket Mode + "slack-bolt>=1.26.0", + "slack-sdk>=3.38.0", "python-socks>=2.8.1", "pytz>=2026.1.post1", # docs parser diff --git a/scripts/validate_flockshub.py b/scripts/validate_flockshub.py index 5aa356aaf..7068f7cdc 100644 --- a/scripts/validate_flockshub.py +++ b/scripts/validate_flockshub.py @@ -80,6 +80,30 @@ def main() -> int: if not (package_dir / entrypoint).exists(): fail(f"Missing entrypoint {entrypoint} in {manifest_rel}") + python_files = { + package_dir / entrypoint + for entrypoint in manifest.get("entrypoints", []) + if Path(entrypoint).suffix == ".py" + } + if plugin_type in {"tool", "device"}: + python_files.update( + path + for path in package_dir.rglob("*.py") + if not any( + part in {".git", "__pycache__"} + for part in path.relative_to(package_dir).parts + ) + ) + for path in sorted(python_files): + if not path.is_file(): + continue + try: + source = path.read_text(encoding="utf-8") + compile(source, str(path), "exec") + except (SyntaxError, UnicodeDecodeError) as exc: + relative_path = path.relative_to(package_dir).as_posix() + fail(f"Invalid Python source {relative_path} in {manifest_rel}: {exc}") + for path in package_dir.rglob("*"): rel = path.relative_to(package_dir).as_posix() ensure_relative(rel) diff --git a/tests/channel/test_channel.py b/tests/channel/test_channel.py index ca43b75fb..8a75758aa 100644 --- a/tests/channel/test_channel.py +++ b/tests/channel/test_channel.py @@ -32,6 +32,7 @@ ChatType, DeliveryResult, InboundMessage, + NonRetryableChannelError, OutboundContext, ) from flocks.config.config import ChannelAccountConfig, ChannelConfig, ConfigInfo @@ -1407,6 +1408,70 @@ def test_record_error_updates_status(self): assert plugin.status.last_error == "fail" assert plugin.status.error_count == 1 + async def test_run_with_reconnect_stops_on_non_retryable_error(self): + from flocks.channel.gateway.manager import GatewayManager + + manager = GatewayManager() + plugin = _StubChannel() + attempts = 0 + abort_event = asyncio.Event() + + async def _fail_start(config, on_message, abort_event=None): + nonlocal attempts + attempts += 1 + raise NonRetryableChannelError("bad credentials") + + plugin.start = _fail_start # type: ignore[method-assign] + + await manager._run_with_reconnect( + channel_id="stub", + plugin=plugin, + config={}, + on_message=AsyncMock(), + abort_event=abort_event, + ) + + assert attempts == 1 + assert plugin.status.connected is False + assert plugin.status.last_error == "bad credentials" + assert plugin.status.error_count == 1 + + async def test_run_with_reconnect_does_not_pre_mark_self_managed_connection(self): + from flocks.channel.gateway.manager import GatewayManager + + class _SelfManagedChannel(_StubChannel): + def capabilities(self) -> ChannelCapabilities: + return ChannelCapabilities( + chat_types=[ChatType.DIRECT], + self_managed_connection=True, + ) + + manager = GatewayManager() + plugin = _SelfManagedChannel() + started = asyncio.Event() + abort_event = asyncio.Event() + + async def _start(config, on_message, abort_event=None): + started.set() + await asyncio.sleep(0.75) + abort_event.set() + raise RuntimeError("handshake failed") + + plugin.start = _start # type: ignore[method-assign] + + await manager._run_with_reconnect( + channel_id="stub", + plugin=plugin, + config={}, + on_message=AsyncMock(), + abort_event=abort_event, + ) + + assert started.is_set() + assert plugin.status.connected is False + assert plugin.status.last_error == "handshake failed" + assert plugin.status.error_count == 1 + async def test_stop_all_drains_cancelled_tasks(self, monkeypatch): from flocks.channel.gateway.manager import GatewayManager from flocks.channel.registry import ChannelRegistry diff --git a/tests/channel/test_e2e_file_roundtrip.py b/tests/channel/test_e2e_file_roundtrip.py index f35441146..ab4a71e6b 100644 --- a/tests/channel/test_e2e_file_roundtrip.py +++ b/tests/channel/test_e2e_file_roundtrip.py @@ -342,8 +342,8 @@ async def fake_get_http_client(): # _download_file to use the server directly. async def fake_get_file_path(*, bot_token, api_base, file_id, timeout): return server.file_id_to_path[file_id], file_id - async def fake_download(*, bot_token, file_path, max_bytes, timeout): - return server.get(f"https://api.telegram.org/file/bot{bot_token}/{file_path}")._body + async def fake_download(*, download_base, file_path, max_bytes, timeout): + return server.get(f"{download_base.rstrip('/')}/{file_path}")._body monkeypatch.setattr(inbound_media, "_get_file_path", fake_get_file_path) monkeypatch.setattr(inbound_media, "_download_file", fake_download) diff --git a/tests/channel/test_slack.py b/tests/channel/test_slack.py new file mode 100644 index 000000000..35f46798c --- /dev/null +++ b/tests/channel/test_slack.py @@ -0,0 +1,579 @@ +from __future__ import annotations + +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from flocks.channel.base import ChatType, NonRetryableChannelError, OutboundContext +import flocks.channel.builtin.slack.channel as slack_mod +from flocks.channel.builtin.slack.channel import SlackChannel +from flocks.channel.builtin.slack.format import markdown_to_slack_mrkdwn +from flocks.channel.builtin.slack.inbound import build_inbound_message +from flocks.channel.builtin.slack.manifest import build_slack_app_manifest + + +def test_plugin_exports_slack_channel(): + plugin = SlackChannel() + assert plugin.meta().id == "slack" + assert plugin.meta().label == "Slack" + assert "sl" in plugin.meta().aliases + assert plugin.capabilities().media is False + assert plugin.capabilities().rich_text is True + assert plugin.capabilities().self_managed_connection is True + + +def test_slack_manifest_matches_socket_mode_setup_needs(): + manifest = build_slack_app_manifest() + + assert manifest["settings"]["socket_mode_enabled"] is True + scopes = set(manifest["oauth_config"]["scopes"]["bot"]) + assert { + "app_mentions:read", + "channels:history", + "channels:read", + "chat:write", + "groups:history", + "groups:read", + "im:history", + "im:read", + "im:write", + "mpim:history", + "mpim:read", + "users:read", + }.issubset(scopes) + events = set(manifest["settings"]["event_subscriptions"]["bot_events"]) + assert { + "app_mention", + "message.channels", + "message.groups", + "message.im", + "message.mpim", + }.issubset(events) + + +def test_validate_config_requires_tokens(monkeypatch): + monkeypatch.setattr(slack_mod, "SLACK_AVAILABLE", True) + plugin = SlackChannel() + + assert "botToken" in (plugin.validate_config({}) or "") + assert "appToken" in (plugin.validate_config({"botToken": "xoxb-1"}) or "") + assert plugin.validate_config({"botToken": "xoxb-1", "appToken": "xapp-1"}) is None + + +def test_validate_config_rejects_non_slack_token_prefixes(monkeypatch): + monkeypatch.setattr(slack_mod, "SLACK_AVAILABLE", True) + plugin = SlackChannel() + + assert "xoxb-" in ( + plugin.validate_config({"botToken": "xoxp-user", "appToken": "xapp-1"}) or "" + ) + assert "xapp-" in ( + plugin.validate_config({"botToken": "xoxb-1", "appToken": "xoxb-wrong"}) or "" + ) + assert plugin.validate_config( + {"botToken": "{secret:slack_bot}", "appToken": "{secret:slack_app}"} + ) is None + + +def test_validate_config_reports_missing_dependency(monkeypatch): + monkeypatch.setattr(slack_mod, "SLACK_AVAILABLE", False) + plugin = SlackChannel() + + assert "slack-bolt" in (plugin.validate_config({"botToken": "x", "appToken": "y"}) or "") + + +def test_markdown_to_slack_mrkdwn_preserves_code_and_links(): + text = "# Title\n\n**bold** and [OpenAI](https://openai.com)\n\n```py\n**not bold**\n```" + + rendered = markdown_to_slack_mrkdwn(text) + + assert "*Title*" in rendered + assert "*bold*" in rendered + assert "" in rendered + assert "```py\n**not bold**\n```" in rendered + + +def test_build_inbound_direct_message(): + msg = build_inbound_message( + { + "channel": "D123", + "channel_type": "im", + "user": "U123", + "ts": "171.1", + "text": "hello", + "team": "T1", + }, + bot_user_id="UBOT", + config={}, + known_thread_ids=set(), + ) + + assert msg is not None + assert msg.channel_id == "slack" + assert msg.account_id == "T1" + assert msg.chat_type == ChatType.DIRECT + assert msg.chat_id == "D123" + assert msg.message_id == "171.1" + assert msg.text == "hello" + assert msg.mentioned is False + assert msg.mention_text == "" + + +def test_build_inbound_group_mention_strips_bot_mention(): + msg = build_inbound_message( + { + "channel": "C123", + "channel_type": "channel", + "user": "U123", + "ts": "171.2", + "text": "<@UBOT> summarize this", + }, + bot_user_id="UBOT", + config={}, + known_thread_ids=set(), + ) + + assert msg is not None + assert msg.chat_type == ChatType.CHANNEL + assert msg.mentioned is True + assert msg.mention_text == "summarize this" + + +def test_build_inbound_extracts_rich_text_blocks(): + msg = build_inbound_message( + { + "channel": "C123", + "channel_type": "channel", + "user": "U123", + "ts": "171.22", + "blocks": [ + { + "type": "rich_text", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + {"type": "user", "user_id": "UBOT"}, + {"type": "text", "text": " summarize "}, + {"type": "link", "text": "this", "url": "https://example.com"}, + ], + } + ], + } + ], + }, + bot_user_id="UBOT", + config={}, + known_thread_ids=set(), + ) + + assert msg is not None + assert "<@UBOT> summarize this (https://example.com)" in msg.text + assert msg.mentioned is True + assert "summarize this" in msg.mention_text + + +def test_build_inbound_merges_blocks_when_plain_text_exists(): + msg = build_inbound_message( + { + "channel": "C123", + "channel_type": "channel", + "user": "U123", + "ts": "171.24", + "text": "<@UBOT> please inspect", + "blocks": [ + { + "type": "rich_text", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + {"type": "user", "user_id": "UBOT"}, + {"type": "text", "text": " please inspect"}, + ], + }, + { + "type": "rich_text_quote", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + {"type": "text", "text": "quoted outage context"}, + ], + } + ], + }, + ], + } + ], + }, + bot_user_id="UBOT", + config={}, + known_thread_ids=set(), + ) + + assert msg is not None + assert "please inspect" in msg.text + assert "quoted outage context" in msg.text + assert "quoted outage context" in msg.mention_text + + +def test_build_inbound_appends_attachments_and_file_names(): + msg = build_inbound_message( + { + "channel": "C123", + "channel_type": "channel", + "user": "U123", + "ts": "171.23", + "text": "<@UBOT> review alert", + "attachments": [ + { + "title": "Alert context", + "fields": [ + {"title": "Severity", "value": "high"}, + ], + } + ], + "files": [ + {"name": "screenshot.png"}, + ], + }, + bot_user_id="UBOT", + config={}, + known_thread_ids=set(), + ) + + assert msg is not None + assert "review alert" in msg.text + assert "Alert context" in msg.text + assert "Severity: high" in msg.text + assert "[Slack files: screenshot.png]" in msg.text + + +def test_build_inbound_group_dm_uses_group_chat_type(): + msg = build_inbound_message( + { + "channel": "G123", + "channel_type": "mpim", + "user": "U123", + "ts": "171.21", + "text": "<@UBOT> summarize this", + }, + bot_user_id="UBOT", + config={}, + known_thread_ids=set(), + ) + + assert msg is not None + assert msg.chat_type == ChatType.GROUP + assert msg.mentioned is True + + +def test_build_inbound_thread_reply_to_known_bot_thread_triggers_without_mention(): + msg = build_inbound_message( + { + "channel": "C123", + "channel_type": "channel", + "user": "U123", + "ts": "171.3", + "thread_ts": "171.2", + "text": "continue", + }, + bot_user_id="UBOT", + config={}, + known_thread_ids={"171.2"}, + ) + + assert msg is not None + assert msg.mentioned is True + assert msg.mention_text == "continue" + assert msg.thread_id == "171.2" + + +def test_build_inbound_thread_reply_to_parent_bot_user_triggers_after_restart(): + msg = build_inbound_message( + { + "channel": "C123", + "channel_type": "channel", + "user": "U123", + "ts": "171.4", + "thread_ts": "171.2", + "parent_user_id": "UBOT", + "text": "continue after restart", + }, + bot_user_id="UBOT", + config={}, + known_thread_ids=set(), + ) + + assert msg is not None + assert msg.mentioned is True + assert msg.mention_text == "continue after restart" + assert msg.thread_id == "171.2" + + +def test_build_inbound_allowed_bot_message_without_user_uses_bot_id(): + msg = build_inbound_message( + { + "channel": "C123", + "channel_type": "channel", + "bot_id": "B_OTHER", + "subtype": "bot_message", + "ts": "171.5", + "text": "automation says hi", + }, + bot_user_id="UBOT", + config={"allowBots": "all"}, + known_thread_ids=set(), + ) + + assert msg is not None + assert msg.sender_id == "B_OTHER" + assert msg.text == "automation says hi" + + +def test_build_inbound_bot_message_mentions_policy_requires_bot_mention(): + blocked = build_inbound_message( + { + "channel": "C123", + "channel_type": "channel", + "bot_id": "B_OTHER", + "subtype": "bot_message", + "ts": "171.6", + "text": "automation says hi", + }, + bot_user_id="UBOT", + config={"allowBots": "mentions"}, + known_thread_ids=set(), + ) + allowed = build_inbound_message( + { + "channel": "C123", + "channel_type": "channel", + "bot_id": "B_OTHER", + "subtype": "bot_message", + "ts": "171.7", + "text": "<@UBOT> automation says hi", + }, + bot_user_id="UBOT", + config={"allowBots": "mentions"}, + known_thread_ids=set(), + ) + + assert blocked is None + assert allowed is not None + assert allowed.sender_id == "B_OTHER" + assert allowed.mention_text == "automation says hi" + + +def test_build_inbound_ignores_own_and_edit_messages(): + own = build_inbound_message( + {"channel": "C1", "user": "UBOT", "ts": "1", "text": "echo"}, + bot_user_id="UBOT", + config={}, + known_thread_ids=set(), + ) + edited = build_inbound_message( + {"channel": "C1", "user": "U1", "ts": "2", "text": "x", "subtype": "message_changed"}, + bot_user_id="UBOT", + config={}, + known_thread_ids=set(), + ) + + assert own is None + assert edited is None + + +@pytest.mark.asyncio +async def test_send_text_posts_thread_reply_and_remembers_thread(): + plugin = SlackChannel() + plugin._config = {"replyInThread": True, "replyBroadcast": True} + fake_client = SimpleNamespace( + chat_postMessage=AsyncMock(return_value={"ts": "172.1"}) + ) + plugin._app = SimpleNamespace(client=fake_client) + + result = await plugin.send_text( + OutboundContext( + channel_id="slack", + to="slack:C123", + text="hello", + reply_to_id="171.1", + ) + ) + + assert result.success is True + assert result.message_id == "172.1" + fake_client.chat_postMessage.assert_awaited_once_with( + channel="C123", + text="hello", + mrkdwn=True, + thread_ts="171.1", + reply_broadcast=True, + ) + assert "171.1" in plugin._known_thread_ids + assert "172.1" in plugin._known_thread_ids + + +@pytest.mark.asyncio +async def test_connect_socket_mode_marks_connected_after_connect(monkeypatch): + plugin = SlackChannel() + plugin._config = {"socketConnectTimeoutSeconds": 1} + plugin._app = SimpleNamespace() + + class FakeHandler: + def __init__(self, app, token): + self.app = app + self.token = token + self.connected = False + + async def connect_async(self): + self.connected = True + + async def close_async(self): + pass + + monkeypatch.setattr(slack_mod, "AsyncSocketModeHandler", FakeHandler) + + await plugin._connect_socket_mode("xapp-ok") + + assert plugin.status.connected is True + assert plugin._handler.connected is True + + +@pytest.mark.asyncio +async def test_connect_socket_mode_failure_marks_disconnected(monkeypatch): + plugin = SlackChannel() + plugin._config = {"socketConnectTimeoutSeconds": 1} + plugin._app = SimpleNamespace() + + class FakeHandler: + def __init__(self, app, token): + pass + + async def connect_async(self): + raise RuntimeError("invalid app token") + + monkeypatch.setattr(slack_mod, "AsyncSocketModeHandler", FakeHandler) + + with pytest.raises(NonRetryableChannelError): + await plugin._connect_socket_mode("xapp-bad") + + assert plugin.status.connected is False + assert "Slack App Token" in (plugin.status.last_error or "") + assert "xapp-" in (plugin.status.last_error or "") + + +@pytest.mark.asyncio +async def test_connect_socket_mode_slack_response_error_is_actionable(monkeypatch): + plugin = SlackChannel() + plugin._config = {"socketConnectTimeoutSeconds": 1} + plugin._app = SimpleNamespace() + + class FakeSlackError(Exception): + def __init__(self): + super().__init__("Slack API error") + self.response = SimpleNamespace(data={"error": "missing_scope"}) + + class FakeHandler: + def __init__(self, app, token): + pass + + async def connect_async(self): + raise FakeSlackError() + + monkeypatch.setattr(slack_mod, "AsyncSocketModeHandler", FakeHandler) + + with pytest.raises(NonRetryableChannelError): + await plugin._connect_socket_mode("xapp-missing-scope") + + assert "connections:write" in (plugin.status.last_error or "") + + +@pytest.mark.asyncio +async def test_connect_socket_mode_timeout_marks_disconnected(monkeypatch): + plugin = SlackChannel() + plugin._config = {"socketConnectTimeoutSeconds": 1} + plugin._app = SimpleNamespace() + + class FakeHandler: + def __init__(self, app, token): + pass + + async def connect_async(self): + await asyncio.sleep(2) + + monkeypatch.setattr(slack_mod, "AsyncSocketModeHandler", FakeHandler) + + with pytest.raises(TimeoutError): + await plugin._connect_socket_mode("xapp-slow") + + assert plugin.status.connected is False + assert "timed out" in (plugin.status.last_error or "") + + +@pytest.mark.asyncio +async def test_start_invalid_config_raises_non_retryable(monkeypatch): + monkeypatch.setattr(slack_mod, "SLACK_AVAILABLE", True) + plugin = SlackChannel() + + with pytest.raises(NonRetryableChannelError): + await plugin.start({}, AsyncMock()) + + assert plugin.status.connected is False + assert "botToken" in (plugin.status.last_error or "") + + +@pytest.mark.asyncio +async def test_start_rejects_user_token_auth_identity(monkeypatch): + monkeypatch.setattr(slack_mod, "SLACK_AVAILABLE", True) + + class FakeClient: + async def auth_test(self): + return { + "user_id": "U_HUMAN", + "team_id": "T1", + } + + class FakeApp: + def __init__(self, token): + self.token = token + self.client = FakeClient() + + monkeypatch.setattr(slack_mod, "AsyncApp", FakeApp) + plugin = SlackChannel() + + with pytest.raises(NonRetryableChannelError): + await plugin.start({"botToken": "xoxb-looks-valid", "appToken": "xapp-1"}, AsyncMock()) + + assert plugin.status.connected is False + assert "Bot User OAuth Token" in (plugin.status.last_error or "") + assert "xoxp-" in (plugin.status.last_error or "") + + +@pytest.mark.asyncio +async def test_handle_slack_event_dispatches_inbound_message(): + plugin = SlackChannel() + plugin._config = {} + plugin._bot_user_id = "UBOT" + dispatched = [] + + async def on_message(msg): + dispatched.append(msg) + + plugin._on_message = on_message + + await plugin._handle_slack_event( + { + "channel": "C123", + "channel_type": "channel", + "user": "U123", + "ts": "171.2", + "text": "<@UBOT> ping", + } + ) + + assert len(dispatched) == 1 + assert dispatched[0].mention_text == "ping" diff --git a/tests/cli/test_service_manager.py b/tests/cli/test_service_manager.py index d75c1cac0..b88bd0bf2 100644 --- a/tests/cli/test_service_manager.py +++ b/tests/cli/test_service_manager.py @@ -1487,6 +1487,10 @@ def test_build_frontend_env_sets_portal_defaults_when_env_missing(monkeypatch) - env["__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS"] == service_manager.DEFAULT_VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS ) + allowed_hosts = env["__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS"].split(",") + assert "127.0.0.1" in allowed_hosts + assert "localhost" in allowed_hosts + assert "portalflocks.threatbook.cn" in allowed_hosts def test_build_frontend_env_allows_overriding_portal_defaults(monkeypatch) -> None: diff --git a/tests/hub/test_bundled_tools.py b/tests/hub/test_bundled_tools.py index e2fe2c483..93cec8f5d 100644 --- a/tests/hub/test_bundled_tools.py +++ b/tests/hub/test_bundled_tools.py @@ -134,7 +134,10 @@ def _write_bundled_tool( @pytest.mark.asyncio -async def test_tool_runtime_refresh_clears_device_template_cache(monkeypatch): +async def test_tool_runtime_refresh_scopes_errors_and_clears_device_template_cache( + monkeypatch, + tmp_path, +): from flocks.config import api_versioning from flocks.tool.device import plugin_index from flocks.tool.registry import ToolRegistry @@ -144,7 +147,9 @@ async def test_tool_runtime_refresh_clears_device_template_cache(monkeypatch): monkeypatch.setattr( ToolRegistry, "refresh_plugin_tools", - classmethod(lambda cls: calls.append("refresh")), + classmethod( + lambda cls, changed_path=None: calls.append(f"refresh:{changed_path}") + ), ) monkeypatch.setattr( api_versioning, @@ -153,15 +158,17 @@ async def test_tool_runtime_refresh_clears_device_template_cache(monkeypatch): ) plugin_index._template_cache = [] - await _refresh_runtime("device") + changed_path = tmp_path / "device" + await _refresh_runtime("device", changed_path) assert plugin_index._template_cache is None - assert calls == ["init", "refresh", "discover:True"] + assert calls == ["init", f"refresh:{changed_path}", "discover:True"] @pytest.mark.asyncio async def test_uninstall_missing_tool_record_clears_device_template_cache(isolated_hub, monkeypatch): calls: list[str] = [] + missing_path = isolated_hub["home"] / ".flocks" / "plugins" / "tools" / "api" / "ghost_tool" local.save_installed_record( InstalledPluginRecord( id="ghost_tool", @@ -169,7 +176,7 @@ async def test_uninstall_missing_tool_record_clears_device_template_cache(isolat version="1.0", source="bundled", installedAt=1, - installPath=str(isolated_hub["home"] / ".flocks" / "plugins" / "tools" / "api" / "ghost_tool"), + installPath=str(missing_path), ) ) monkeypatch.setattr("flocks.hub.installer.clear_catalog_caches", lambda: calls.append("catalog")) @@ -178,8 +185,8 @@ async def test_uninstall_missing_tool_record_clears_device_template_cache(isolat lambda plugin_type: calls.append(f"device:{plugin_type}"), ) - async def fake_refresh_runtime(plugin_type): - calls.append(f"refresh:{plugin_type}") + async def fake_refresh_runtime(plugin_type, changed_path=None): + calls.append(f"refresh:{plugin_type}:{changed_path}") monkeypatch.setattr("flocks.hub.installer._refresh_runtime", fake_refresh_runtime) @@ -187,7 +194,37 @@ async def fake_refresh_runtime(plugin_type): assert removed is True assert local.get_record("tool", "ghost_tool") is None - assert calls == ["catalog", "device:tool", "refresh:tool"] + assert calls == ["catalog", "device:tool", f"refresh:tool:{missing_path}"] + + +@pytest.mark.asyncio +async def test_uninstall_missing_tool_record_without_path_uses_canonical_path( + isolated_hub, + monkeypatch, +): + local.save_installed_record( + InstalledPluginRecord( + id="ghost_tool", + type="tool", + version="1.0", + source="bundled", + installedAt=1, + installPath=None, + ) + ) + refresh_paths: list[Path | None] = [] + + async def fake_refresh_runtime(_plugin_type, changed_path=None): + refresh_paths.append(changed_path) + + monkeypatch.setattr("flocks.hub.installer._refresh_runtime", fake_refresh_runtime) + + removed = await uninstall_plugin("tool", "ghost_tool") + + assert removed is True + assert refresh_paths == [ + isolated_hub["home"] / ".flocks" / "plugins" / "tools" / "ghost_tool" + ] class TestBundledToolRoots: @@ -777,6 +814,23 @@ def test_path_escape_still_rejected(self, tmp_path): with pytest.raises(ValueError, match="escapes"): validate_package(package, self._manifest()) + def test_python_syntax_error_is_rejected_before_install(self, tmp_path): + package = tmp_path / "demo" + package.mkdir() + (package / "broken.handler.py").write_text("def broken(:\n", encoding="utf-8") + + with pytest.raises(ValueError, match=r"Invalid Python source.*broken\.handler\.py"): + validate_package(package, self._manifest()) + + def test_nested_python_syntax_error_is_rejected_before_install(self, tmp_path): + package = tmp_path / "demo" + scripts = package / "scripts" + scripts.mkdir(parents=True) + (scripts / "broken.py").write_text("def broken(:\n", encoding="utf-8") + + with pytest.raises(ValueError, match=r"Invalid Python source.*scripts/broken\.py"): + validate_package(package, self._manifest()) + # --------------------------------------------------------------------------- # API-service summary name disambiguation diff --git a/tests/hub/test_hub_catalog.py b/tests/hub/test_hub_catalog.py index f26ec863e..bdb479b2e 100644 --- a/tests/hub/test_hub_catalog.py +++ b/tests/hub/test_hub_catalog.py @@ -9,7 +9,7 @@ from flocks.hub import local from flocks.hub.catalog import list_catalog, load_manifest, load_taxonomy from flocks.hub.files import file_tree, read_file_content -from flocks.hub.installer import install_plugin, uninstall_plugin +from flocks.hub.installer import install_plugin, uninstall_plugin, update_plugin from flocks.plugin.loader import PluginLoader @@ -114,6 +114,162 @@ def test_soc_workspace_component_exposes_chinese_name(): assert entries["soc-workspace"].nameCn == "SOC 工作区场景套件" +async def test_chaitin_safeline_repair_release_replaces_broken_handler( + isolated_hub_env, + monkeypatch: pytest.MonkeyPatch, +): + async def noop_refresh(_plugin_type, _changed_path=None): + return None + + monkeypatch.setattr("flocks.hub.installer._refresh_runtime", noop_refresh) + plugin_id = "chaitin_safeline_waf_v1_0_0" + install_dir = ( + isolated_hub_env["home"] + / ".flocks" + / "plugins" + / "tools" + / "device" + / plugin_id + ) + install_dir.mkdir(parents=True) + legacy_handler = install_dir / "chaitin_leichi_waf.handler.py" + legacy_handler.write_text("def broken(:\n", encoding="utf-8") + + entries = {entry.id: entry for entry in list_catalog(plugin_type="device")} + assert entries[plugin_id].state == "updateAvailable" + assert entries[plugin_id].version == "1.0.1" + assert entries[plugin_id].installedVersion is None + + record = await update_plugin("device", plugin_id) + + repaired_handler = install_dir / "chaitin_safeline_waf.handler.py" + assert record.version == "1.0.1" + assert local.get_record("device", plugin_id) == record + assert not legacy_handler.exists() + compile(repaired_handler.read_text(encoding="utf-8"), str(repaired_handler), "exec") + + +async def test_hub_install_runtime_failure_rolls_back_new_plugin( + isolated_hub_env, + monkeypatch: pytest.MonkeyPatch, +): + async def fail_refresh(_plugin_type, _changed_path=None): + raise RuntimeError("import failed") + + monkeypatch.setattr("flocks.hub.installer._refresh_runtime", fail_refresh) + install_dir = ( + isolated_hub_env["home"] + / ".flocks" + / "plugins" + / "tools" + / "python" + / "soc_workspace_query" + ) + + with pytest.raises(RuntimeError, match="import failed"): + await install_plugin("tool", "soc_workspace_query") + + assert not install_dir.exists() + assert local.get_record("tool", "soc_workspace_query") is None + + +async def test_hub_update_runtime_failure_restores_previous_plugin( + isolated_hub_env, + monkeypatch: pytest.MonkeyPatch, +): + install_dir = ( + isolated_hub_env["home"] + / ".flocks" + / "plugins" + / "tools" + / "python" + / "soc_workspace_query" + ) + install_dir.mkdir(parents=True) + old_handler = install_dir / "old_tool.py" + old_handler.write_text("OLD = True\n", encoding="utf-8") + previous_record = local.make_record( + plugin_type="tool", + plugin_id="soc_workspace_query", + version="0.9.0", + source="bundled:old", + install_path=install_dir, + scope="global", + ) + local.save_installed_record(previous_record) + refresh_calls = 0 + + async def fail_then_recover(_plugin_type, _changed_path=None): + nonlocal refresh_calls + refresh_calls += 1 + if refresh_calls == 1: + raise RuntimeError("import failed") + + monkeypatch.setattr("flocks.hub.installer._refresh_runtime", fail_then_recover) + + with pytest.raises(RuntimeError, match="import failed"): + await update_plugin("tool", "soc_workspace_query") + + assert refresh_calls == 2 + assert old_handler.read_text(encoding="utf-8") == "OLD = True\n" + assert not (install_dir / "soc_workspace_query.py").exists() + assert local.get_record("tool", "soc_workspace_query") == previous_record + + +async def test_hub_webui_runtime_failure_rolls_back_package_and_access_contracts( + isolated_hub_env, + monkeypatch: pytest.MonkeyPatch, +): + async def fail_refresh(_plugin_type, _changed_path=None): + raise RuntimeError("reconcile failed") + + monkeypatch.setattr("flocks.hub.installer._refresh_runtime", fail_refresh) + _patch_webui_bundle_build(monkeypatch) + home_plugins = isolated_hub_env["home"] / ".flocks" / "plugins" + + with pytest.raises(RuntimeError, match="reconcile failed"): + await install_plugin("webui", "soc_ui") + + assert not (home_plugins / "contracts" / "webui" / "soc_ui").exists() + assert not (home_plugins / "contracts" / "access" / "soc_ui").exists() + assert local.get_record("webui", "soc_ui") is None + + +def test_catalog_ignores_stale_record_when_legacy_install_is_inferred( + isolated_hub_env, +): + plugin_id = "chaitin_safeline_waf_v1_0_0" + legacy_dir = ( + isolated_hub_env["home"] + / ".flocks" + / "plugins" + / "tools" + / "device" + / plugin_id + ) + legacy_dir.mkdir(parents=True) + (legacy_dir / "legacy.yaml").write_text( + "name: legacy\nhandler:\n type: http\n", + encoding="utf-8", + ) + stale_record = local.make_record( + plugin_type="device", + plugin_id=plugin_id, + version="1.0.1", + source="bundled:stale", + install_path=isolated_hub_env["home"] / "missing", + scope="global", + ) + local.save_installed_record(stale_record) + + entries = {entry.id: entry for entry in list_catalog(plugin_type="device")} + + assert entries[plugin_id].state == "updateAvailable" + assert entries[plugin_id].installedVersion is None + assert entries[plugin_id].installPath == str(legacy_dir) + assert local.get_record("device", plugin_id) is None + + def test_pentest_agents_are_listed_in_agent_catalog(): entries = list_catalog(plugin_type="agent") ids = {entry.id for entry in entries} @@ -214,7 +370,7 @@ async def test_hub_installs_pentest_subagent(isolated_hub_env): async def test_hub_installs_soc_webui_package(isolated_hub_env, monkeypatch: pytest.MonkeyPatch): - async def noop_refresh(_plugin_type): + async def noop_refresh(_plugin_type, _changed_path=None): return None monkeypatch.setattr("flocks.hub.installer._refresh_runtime", noop_refresh) @@ -246,7 +402,7 @@ async def test_hub_webui_install_fails_when_bundle_build_fails( isolated_hub_env, monkeypatch: pytest.MonkeyPatch, ): - async def noop_refresh(_plugin_type): + async def noop_refresh(_plugin_type, _changed_path=None): return None def fail_build(_self, _page_id: str): @@ -269,7 +425,7 @@ async def test_hub_installed_soc_webui_registers_alert_access_contract( isolated_hub_env, monkeypatch: pytest.MonkeyPatch, ): - async def noop_refresh(_plugin_type): + async def noop_refresh(_plugin_type, _changed_path=None): return None monkeypatch.setattr("flocks.hub.installer._refresh_runtime", noop_refresh) @@ -296,7 +452,7 @@ async def test_hub_installed_soc_webui_serves_alert_access_operation( isolated_hub_env, monkeypatch: pytest.MonkeyPatch, ): - async def noop_refresh(_plugin_type): + async def noop_refresh(_plugin_type, _changed_path=None): return None monkeypatch.setattr("flocks.hub.installer._refresh_runtime", noop_refresh) @@ -392,7 +548,7 @@ async def noop_refresh(_plugin_type): async def test_hub_installs_soc_workspace_component_children(isolated_hub_env, monkeypatch: pytest.MonkeyPatch): - async def noop_refresh(_plugin_type): + async def noop_refresh(_plugin_type, _changed_path=None): return None monkeypatch.setattr("flocks.hub.installer._refresh_runtime", noop_refresh) @@ -421,7 +577,7 @@ async def noop_refresh(_plugin_type): async def test_hub_component_uninstall_preserves_existing_children(isolated_hub_env, monkeypatch: pytest.MonkeyPatch): - async def noop_refresh(_plugin_type): + async def noop_refresh(_plugin_type, _changed_path=None): return None monkeypatch.setattr("flocks.hub.installer._refresh_runtime", noop_refresh) @@ -448,7 +604,7 @@ async def noop_refresh(_plugin_type): async def test_hub_component_adopts_existing_soc_workspace_tool(isolated_hub_env, monkeypatch: pytest.MonkeyPatch): - async def noop_refresh(_plugin_type): + async def noop_refresh(_plugin_type, _changed_path=None): return None monkeypatch.setattr("flocks.hub.installer._refresh_runtime", noop_refresh) @@ -473,7 +629,7 @@ async def noop_refresh(_plugin_type): async def test_hub_component_uninstall_cleans_adoptable_legacy_tool_record(isolated_hub_env, monkeypatch: pytest.MonkeyPatch): - async def noop_refresh(_plugin_type): + async def noop_refresh(_plugin_type, _changed_path=None): return None monkeypatch.setattr("flocks.hub.installer._refresh_runtime", noop_refresh) @@ -496,7 +652,7 @@ async def noop_refresh(_plugin_type): async def test_hub_component_uninstall_cleans_legacy_unrecorded_webui(isolated_hub_env, monkeypatch: pytest.MonkeyPatch): - async def noop_refresh(_plugin_type): + async def noop_refresh(_plugin_type, _changed_path=None): return None monkeypatch.setattr("flocks.hub.installer._refresh_runtime", noop_refresh) @@ -521,7 +677,7 @@ async def noop_refresh(_plugin_type): async def test_hub_component_install_failure_rolls_back_children(isolated_hub_env, monkeypatch: pytest.MonkeyPatch): - async def fail_tool_refresh(plugin_type): + async def fail_tool_refresh(plugin_type, _changed_path=None): if plugin_type == "tool": raise RuntimeError("tool refresh failed") return None @@ -548,7 +704,7 @@ async def fail_tool_refresh(plugin_type): async def test_hub_component_uninstall_cleans_orphan_children(isolated_hub_env, monkeypatch: pytest.MonkeyPatch): - async def noop_refresh(_plugin_type): + async def noop_refresh(_plugin_type, _changed_path=None): return None monkeypatch.setattr("flocks.hub.installer._refresh_runtime", noop_refresh) @@ -582,7 +738,7 @@ async def noop_refresh(_plugin_type): async def test_hub_uninstalls_python_tool_without_record(isolated_hub_env, monkeypatch: pytest.MonkeyPatch): - async def noop_refresh(_plugin_type): + async def noop_refresh(_plugin_type, _changed_path=None): return None monkeypatch.setattr("flocks.hub.installer._refresh_runtime", noop_refresh) @@ -758,7 +914,7 @@ def test_hub_component_install_stream_reports_child_progress(isolated_hub_env, m from flocks.server.auth import require_admin from flocks.server.routes.hub import router - async def noop_refresh(_plugin_type): + async def noop_refresh(_plugin_type, _changed_path=None): return None monkeypatch.setattr("flocks.hub.installer._refresh_runtime", noop_refresh) diff --git a/tests/server/routes/test_global_mutation_auth.py b/tests/server/routes/test_global_mutation_auth.py index 2c2bd8d69..40cdb9f67 100644 --- a/tests/server/routes/test_global_mutation_auth.py +++ b/tests/server/routes/test_global_mutation_auth.py @@ -40,6 +40,7 @@ def test_provider_global_configuration_credentials_and_tests_require_admin(): client.post("/api/provider/example/test"), client.put("/api/provider/example", json={}), client.get("/api/provider/example/credentials"), + client.post("/api/provider/example/credentials/reveal"), client.post("/api/provider/example/credentials", json={"api_key": "secret"}), client.delete("/api/provider/example/credentials"), client.get("/api/provider/example/service-credentials"), @@ -53,7 +54,9 @@ def test_provider_global_configuration_credentials_and_tests_require_admin(): assert [response.status_code for response in responses] == [403] * len(responses) -def test_admin_credential_routes_return_only_masked_secrets(monkeypatch: pytest.MonkeyPatch): +def test_admin_reveal_returns_raw_llm_key_with_audit_and_no_store( + monkeypatch: pytest.MonkeyPatch, +): from flocks.server.routes import provider as provider_routes raw_llm_key = "sk-raw-llm-secret-1234" @@ -63,7 +66,9 @@ def test_admin_credential_routes_return_only_masked_secrets(monkeypatch: pytest. "llm-example_llm_key": raw_llm_key, "service-example_api_key": raw_service_key, }.get(key) + audit = AsyncMock() monkeypatch.setattr("flocks.security.get_secret_manager", lambda: secrets) + monkeypatch.setattr(provider_routes, "emit_audit_event", audit) monkeypatch.setattr( provider_routes.ConfigWriter, "get_provider_raw", @@ -100,20 +105,33 @@ def test_admin_credential_routes_return_only_masked_secrets(monkeypatch: pytest. client = _user_client(provider_routes.router, prefix="/api/provider", role="admin") llm_response = client.get("/api/provider/llm-example/credentials") + reveal_response = client.post("/api/provider/llm-example/credentials/reveal") service_response = client.get("/api/provider/service-example/service-credentials") assert llm_response.status_code == 200 + assert reveal_response.status_code == 200 assert service_response.status_code == 200 llm_payload = llm_response.json() + reveal_payload = reveal_response.json() service_payload = service_response.json() assert llm_payload["api_key"] is None assert llm_payload["api_key_masked"] + assert reveal_payload["api_key"] == raw_llm_key + assert reveal_response.headers["Cache-Control"] == "no-store" + assert reveal_response.headers["Pragma"] == "no-cache" assert service_payload["api_key"] is None assert service_payload["api_key_masked"] assert service_payload["fields"]["api_key"] != raw_service_key assert service_payload["fields"]["base_url"] == "https://service.example.test" assert raw_llm_key not in llm_response.text + assert raw_llm_key in reveal_response.text assert raw_service_key not in service_response.text + audit.assert_awaited_once() + event_type, audit_payload = audit.await_args.args + assert event_type == "provider.credentials_reveal" + assert audit_payload["provider_id"] == "llm-example" + assert audit_payload["username"] == "admin-test" + assert raw_llm_key not in repr(audit_payload) def test_admin_provider_update_never_writes_raw_api_key_to_storage(monkeypatch: pytest.MonkeyPatch): diff --git a/tests/server/routes/test_remaining_routes.py b/tests/server/routes/test_remaining_routes.py index ce681b707..c312b2cc6 100644 --- a/tests/server/routes/test_remaining_routes.py +++ b/tests/server/routes/test_remaining_routes.py @@ -5,6 +5,7 @@ from __future__ import annotations import asyncio +import json from pathlib import Path import pytest @@ -508,6 +509,38 @@ async def test_config_has_expected_top_level_keys(self, client: AsyncClient): f"No expected keys found. Got: {list(data.keys())}" ) + def test_null_allow_from_deletes_existing_channel_field( + self, + tmp_path, + monkeypatch, + ): + """channels..allowFrom=null removes the persisted allowlist.""" + from flocks.server.routes import config as config_routes + + config_file = tmp_path / "config" / "flocks.json" + config_file.parent.mkdir(parents=True) + config_file.write_text( + json.dumps( + { + "channels": { + "slack": { + "enabled": False, + "allowFrom": ["U123"], + } + } + } + ), + encoding="utf-8", + ) + monkeypatch.setattr(config_routes.Config, "get_config_file", lambda: config_file) + + config_routes._apply_channel_allow_from_deletions( + {"channels": {"slack": {"enabled": False, "allowFrom": None}}} + ) + + saved = json.loads(config_file.read_text(encoding="utf-8")) + assert "allowFrom" not in saved["channels"]["slack"] + @pytest.mark.asyncio async def test_ui_display_defaults_and_updates( self, diff --git a/tests/tool/test_channel_message.py b/tests/tool/test_channel_message.py index ed815ef57..fe772981e 100644 --- a/tests/tool/test_channel_message.py +++ b/tests/tool/test_channel_message.py @@ -26,6 +26,11 @@ def test_channel_message_normalizes_wecom_aliases() -> None: assert _normalize_channel_type("wxwork") == "wecom" +def test_channel_message_normalizes_slack_aliases() -> None: + assert _normalize_channel_type("slack") == "slack" + assert _normalize_channel_type("sl") == "slack" + + def test_channel_message_normalizes_telegram_whatsapp_email_aliases() -> None: assert _normalize_channel_type("telegram") == "telegram" assert _normalize_channel_type("tg") == "telegram" @@ -52,6 +57,7 @@ def test_channel_message_schema_includes_builtin_channels() -> None: "telegram", "whatsapp", "email", + "slack", "邮件", ): assert value in channel_enum diff --git a/tests/tool/test_im_send_message.py b/tests/tool/test_im_send_message.py index 42e41a674..f582d3d41 100644 --- a/tests/tool/test_im_send_message.py +++ b/tests/tool/test_im_send_message.py @@ -51,6 +51,11 @@ def test_im_send_message_normalizes_wecom_aliases() -> None: assert _normalize_channel_type("wxwork") == "wecom" +def test_im_send_message_normalizes_slack_aliases() -> None: + assert _normalize_channel_type("slack") == "slack" + assert _normalize_channel_type("sl") == "slack" + + def test_im_send_message_normalizes_telegram_whatsapp_email_aliases() -> None: assert _normalize_channel_type("telegram") == "telegram" assert _normalize_channel_type("tg") == "telegram" @@ -68,7 +73,7 @@ def test_im_send_message_schema_mentions_extended_builtin_channels() -> None: assert schema is not None channel_description = schema.properties["channel_type"]["description"] tool_description = ToolRegistry.get("im_send_message").info.description - for channel in ("telegram", "whatsapp", "email"): + for channel in ("telegram", "whatsapp", "email", "slack"): assert channel in channel_description.lower() assert channel in tool_description.lower() diff --git a/tests/tool/test_tdp_api_tools.py b/tests/tool/test_tdp_api_tools.py index 232ae407e..4d80f5988 100644 --- a/tests/tool/test_tdp_api_tools.py +++ b/tests/tool/test_tdp_api_tools.py @@ -186,8 +186,8 @@ async def test_tdp_dashboard_status_uses_non_deprecated_threat_topic_path(): @pytest.mark.asyncio -async def test_tdp_incident_list_can_use_secret_manager_credentials_and_default_filters(): - tool = _load_tool("tdp_incident_list.yaml") +async def test_tdp_threat_intelligent_aggregation_can_use_secret_manager_credentials_and_default_filters(): + tool = _load_tool("tdp_threat_intelligent_aggregation.yaml") mock_secret_manager = MagicMock() mock_secret_manager.get.side_effect = lambda key: { "tdp_api_key": "secret-api", @@ -212,7 +212,7 @@ async def test_tdp_incident_list_can_use_secret_manager_credentials_and_default_ assert method == "POST" assert request_url == "https://tdp.internal/api/v1/incident/search" condition = request_kwargs["json"]["condition"] - assert condition["duration"] == {"begin_duration": 0, "end_duration": 24} + assert condition["duration"] == {"begin_duration": 0} assert condition["time_from"] < condition["time_to"] @@ -252,8 +252,8 @@ async def test_tdp_machine_asset_list_can_switch_to_web_app_framework_action(): @pytest.mark.asyncio -async def test_tdp_threat_inbound_attack_uses_severity_distribution_endpoint(): - tool = _load_tool("tdp_threat_inbound_attack.yaml") +async def test_tdp_threat_external_attack_uses_severity_distribution_endpoint(): + tool = _load_tool("tdp_threat_external_attack.yaml") fake_session = _FakeSession([_FakeResponse(json_payload={"response_code": 0, "data": [{"key": "4", "value": 2}]})]) with ( diff --git a/tests/tool/test_tdp_skyeye_api_plugins.py b/tests/tool/test_tdp_skyeye_api_plugins.py index d3cf573f6..19013757c 100644 --- a/tests/tool/test_tdp_skyeye_api_plugins.py +++ b/tests/tool/test_tdp_skyeye_api_plugins.py @@ -28,23 +28,23 @@ def _built_json_payload(mock_run): return api_name, path, body_builder(body) -async def test_tdp_incident_timeline_requires_incident_id(): +async def test_tdp_attack_timeline_requires_incident_id(): module = _load_module("test_tdp_handler_incident", _TDP_HANDLER) - result = await module.incident_list(_ctx(), action="timeline") + result = await module.incident_list(_ctx(), action="attack_timeline") assert result.success is False assert "incident_id" in result.error -async def test_tdp_incident_timeline_defaults_show_attack_true(): +async def test_tdp_attack_timeline_defaults_show_attack_true(): module = _load_module("test_tdp_handler_incident_timeline_show_attack", _TDP_HANDLER) mock_result = ToolResult(success=True, output={"status": 200}) with patch.object(module, "_run_action_json_tool", AsyncMock(return_value=mock_result)) as mock_run: result = await module.incident_list( _ctx(), - action="timeline", + action="attack_timeline", incident_id="incident-1", time_from=1700000000, time_to=1700003600, @@ -56,30 +56,30 @@ async def test_tdp_incident_timeline_defaults_show_attack_true(): assert body["show_attack"] is True -async def test_tdp_incident_alert_search_requires_page(): +async def test_tdp_attack_alert_list_requires_page(): module = _load_module("test_tdp_handler_incident_alert_page", _TDP_HANDLER) - result = await module.incident_list(_ctx(), action="alert_search", alert_ids=["alert-1"]) + result = await module.incident_list(_ctx(), action="attack_alert_list", alert_ids=["alert-1"]) assert result.success is False assert "page" in result.error -async def test_tdp_alert_host_events_requires_asset_machine(): +async def test_tdp_host_threat_list_requires_asset_machine(): module = _load_module("test_tdp_handler_alert_host", _TDP_HANDLER) - result = await module.threat_host_list(_ctx(), action="events", condition={}) + result = await module.threat_host_list(_ctx(), action="host_threat_list", condition={}) assert result.success is False assert "condition.asset_machine" in result.error -async def test_tdp_alert_host_summary_uses_all_threat_types_defaults(): - module = _load_module("test_tdp_handler_alert_host_summary_defaults", _TDP_HANDLER) +async def test_tdp_alert_host_list_uses_all_threat_types_defaults(): + module = _load_module("test_tdp_handler_alert_host_list_defaults", _TDP_HANDLER) mock_result = ToolResult(success=True, output={"status": 200}) with patch.object(module, "_run_json_tool", AsyncMock(return_value=mock_result)) as mock_run: - result = await module.threat_host_list(_ctx(), action="summary") + result = await module.threat_host_list(_ctx(), action="alert_host_list") assert result.success is True mock_run.assert_awaited_once() @@ -304,14 +304,14 @@ async def test_tdp_policy_resolve_host_requires_assets_machine_status_and_sub_st assert "sub_status" in result_missing_sub_status.error -async def test_tdp_incident_alert_search_maps_explicit_params_to_condition(): +async def test_tdp_attack_alert_list_maps_explicit_params_to_condition(): module = _load_module("test_tdp_handler_incident_mapping", _TDP_HANDLER) mock_result = ToolResult(success=True, output={"status": 200}) with patch.object(module, "_run_action_json_tool", AsyncMock(return_value=mock_result)) as mock_run: result = await module.incident_list( _ctx(), - action="alert_search", + action="attack_alert_list", alert_ids=["alert-1"], include_risk=True, include_action=False, @@ -325,8 +325,8 @@ async def test_tdp_incident_alert_search_maps_explicit_params_to_condition(): default_action = mock_run.await_args.kwargs["default_action"] action = mock_run.await_args.kwargs["action"] body = mock_run.await_args.kwargs["body"] - assert default_action == "search" - assert action == "alert_search" + assert default_action == "incident_search" + assert action == "attack_alert_list" assert body["condition"]["id"] == ["alert-1"] assert body["condition"]["include_risk"] is True assert body["condition"]["include_action"] is False @@ -752,7 +752,7 @@ async def test_tdp_medium_query_tools_map_semantic_filters(): mock_run.reset_mock() result = await module.incident_list( _ctx(), - action="search", + action="incident_search", severity=[4], phase=["exploit"], result=["success"], @@ -821,13 +821,50 @@ async def test_skyeye_alarm_list_forwards_extended_filters(): assert params["limit"] == 10 -def test_tdp_incident_yaml_loads_with_provider(): - yaml_path = _WORKSPACE_ROOT / ".flocks/plugins/tools/device/tdp_v3_3_10/tdp_incident_list.yaml" +def test_tdp_threat_tool_names_match_document_feature_groups(): + tool_dir = _WORKSPACE_ROOT / ".flocks/plugins/tools/device/tdp_v3_3_10" + expected_names = [ + "tdp_threat_alert_host", + "tdp_threat_monitor_list", + "tdp_threat_external_attack", + "tdp_threat_intelligent_aggregation", + ] + for expected_name in expected_names: + yaml_path = tool_dir / f"{expected_name}.yaml" + raw = _read_yaml_raw(yaml_path) + tool = yaml_to_tool(raw, yaml_path) + assert tool.info.name == expected_name + assert tool.info.provider == "tdp_api_v3_3_10" + + +def test_tdp_threat_action_names_describe_returned_data(): + tool_dir = _WORKSPACE_ROOT / ".flocks/plugins/tools/device/tdp_v3_3_10" + alert_host = _read_yaml_raw(tool_dir / "tdp_threat_alert_host.yaml") + intelligent_aggregation = _read_yaml_raw(tool_dir / "tdp_threat_intelligent_aggregation.yaml") + + assert alert_host["inputSchema"]["properties"]["action"]["enum"] == [ + "alert_host_list", + "host_threat_list", + ] + assert intelligent_aggregation["inputSchema"]["properties"]["action"]["enum"] == [ + "incident_search", + "top_attacked_entity", + "attack_success", + "attack_timeline", + "attack_alert_list", + "attack_result_distribution", + "attacker_ip_list", + "attacker_ip_detail", + ] + + +def test_tdp_threat_intelligent_aggregation_promotes_semantic_fields(): + yaml_path = ( + _WORKSPACE_ROOT + / ".flocks/plugins/tools/device/tdp_v3_3_10/tdp_threat_intelligent_aggregation.yaml" + ) raw = _read_yaml_raw(yaml_path) - tool = yaml_to_tool(raw, yaml_path) - assert tool.info.name == "tdp_incident_list" - assert tool.info.provider == "tdp_api_v3_3_10" assert "body" not in raw["inputSchema"]["properties"] assert "condition" in raw["inputSchema"]["properties"] assert "severity" in raw["inputSchema"]["properties"] @@ -837,14 +874,14 @@ def test_tdp_incident_yaml_loads_with_provider(): def test_tdp_query_yaml_promotes_semantic_top_level_fields(): expected_fields = { - "tdp_host_threat_list.yaml": {"severity", "threat_characters", "keyword", "cur_page"}, + "tdp_threat_alert_host.yaml": {"severity", "threat_characters", "keyword", "cur_page"}, "tdp_vulnerability_list.yaml": {"severity", "status", "keyword", "sort_by"}, "tdp_interface_list.yaml": {"host", "methods", "privacy_tags", "keyword"}, "tdp_interface_risk_list.yaml": {"api_risk_type", "keyword", "sort_by"}, "tdp_login_weakpwd_list.yaml": {"data", "result", "app_class", "keyword"}, "tdp_assets_domain_list.yaml": {"domain_name_or_ip", "has_login_api", "second_level_domain"}, "tdp_privacy_diagram.yaml": {"itag", "methods", "fuzzy_url_host"}, - "tdp_threat_inbound_attack.yaml": {"severity", "result_list", "keyword"}, + "tdp_threat_external_attack.yaml": {"severity", "result_list", "keyword"}, "tdp_threat_monitor_list.yaml": {"time_from", "time_to", "sql", "assets_group", "cur_page"}, "tdp_machine_asset_list.yaml": {"time_from", "time_to", "service", "service_class", "application", "keyword"}, "tdp_mdr_alert_list.yaml": {"section_list", "threat_severity", "keyword"}, diff --git a/tests/tool/test_tool_refresh_outcomes.py b/tests/tool/test_tool_refresh_outcomes.py index 12f826f47..4efd7544b 100644 --- a/tests/tool/test_tool_refresh_outcomes.py +++ b/tests/tool/test_tool_refresh_outcomes.py @@ -2,6 +2,7 @@ from collections.abc import Iterator from contextlib import contextmanager +from pathlib import Path import threading import pytest @@ -34,6 +35,8 @@ def _isolated_registry() -> Iterator[None]: "tools": ToolRegistry._tools, "defaults": ToolRegistry._enabled_defaults, "plugin_names": ToolRegistry._plugin_tool_names, + "plugin_errors": ToolRegistry._plugin_load_errors, + "revision": ToolRegistry._revision, "dynamic_modules": ToolRegistry._dynamic_modules, "dynamic_tools": ToolRegistry._dynamic_tools_by_module, } @@ -42,6 +45,8 @@ def _isolated_registry() -> Iterator[None]: ToolRegistry._tools = {} ToolRegistry._enabled_defaults = {} ToolRegistry._plugin_tool_names = [] + ToolRegistry._plugin_load_errors = [] + ToolRegistry._revision = 0 ToolRegistry._dynamic_modules = {} ToolRegistry._dynamic_tools_by_module = {} yield @@ -50,11 +55,13 @@ def _isolated_registry() -> Iterator[None]: ToolRegistry._tools = state["tools"] ToolRegistry._enabled_defaults = state["defaults"] ToolRegistry._plugin_tool_names = state["plugin_names"] + ToolRegistry._plugin_load_errors = state["plugin_errors"] + ToolRegistry._revision = state["revision"] ToolRegistry._dynamic_modules = state["dynamic_modules"] ToolRegistry._dynamic_tools_by_module = state["dynamic_tools"] -def test_plugin_refresh_restores_last_known_good_on_load_error( +def test_plugin_refresh_restores_last_known_good_on_loader_failure( monkeypatch: pytest.MonkeyPatch, ): with _isolated_registry(): @@ -66,17 +73,257 @@ def _failed_load(cls, errors): replacement = _tool("partial_replacement", "plugin_py") cls._tools[replacement.info.name] = replacement cls._plugin_tool_names = [replacement.info.name] - errors.append("broken plugin") + errors.append("plugin loader: unavailable") monkeypatch.setattr(ToolRegistry, "_load_plugin_tools", classmethod(_failed_load)) - with pytest.raises(ToolRefreshError, match="broken plugin"): + with pytest.raises(ToolRefreshError, match="plugin loader: unavailable"): ToolRegistry.refresh_plugin_tools() assert ToolRegistry._tools == {previous.info.name: previous} assert ToolRegistry._plugin_tool_names == [previous.info.name] +def test_plugin_refresh_accepts_later_unrelated_syntax_error( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +): + from flocks.plugin import PluginLoader + + with _isolated_registry(): + project_dir = tmp_path / "project" + plugin_root = tmp_path / "plugins" + plugin_dir = plugin_root / "tools" / "device" / "legacy_device" + project_dir.mkdir() + plugin_dir.mkdir(parents=True) + + monkeypatch.chdir(project_dir) + monkeypatch.setattr(PluginLoader, "_plugin_root", plugin_root) + monkeypatch.setattr( + ToolRegistry, + "_finalize_plugin_tools_load", + classmethod(lambda cls: None), + ) + monkeypatch.setattr(ToolRegistry, "_bump_revision", lambda _reason: None) + extension_points_before = PluginLoader._extension_points.copy() + try: + ToolRegistry._register_plugin_extension_point() + startup_errors = ToolRegistry._load_plugin_tools() + assert startup_errors == [] + ToolRegistry._plugin_load_errors = startup_errors + + (plugin_dir / "broken.handler.py").write_text( + "def broken(:\n", + encoding="utf-8", + ) + + (plugin_root / "tools" / "newly_installed.py").write_text( + "async def handle(ctx):\n" + " return None\n\n" + "TOOLS = [{\n" + " 'name': 'newly_installed',\n" + " 'description': 'newly installed tool',\n" + " 'handler': handle,\n" + "}]\n", + encoding="utf-8", + ) + + refreshed = ToolRegistry.refresh_plugin_tools( + changed_path=plugin_root / "tools" / "newly_installed.py", + ) + + assert "newly_installed" in refreshed + assert ToolRegistry.get("newly_installed") is not None + assert any("SyntaxError" in error for error in ToolRegistry._plugin_load_errors) + finally: + PluginLoader.clear_extension_points() + PluginLoader._extension_points.update(extension_points_before) + + +def test_registry_init_records_plugin_load_errors( + monkeypatch: pytest.MonkeyPatch, +): + with _isolated_registry(): + ToolRegistry._initialized = False + monkeypatch.setattr( + ToolRegistry, + "_register_builtin_tools", + classmethod(lambda cls: None), + ) + monkeypatch.setattr( + ToolRegistry, + "_register_dynamic_tools", + classmethod(lambda cls, errors=None: None), + ) + monkeypatch.setattr( + ToolRegistry, + "_register_plugin_extension_point", + classmethod(lambda cls: None), + ) + monkeypatch.setattr( + ToolRegistry, + "_load_plugin_tools", + classmethod(lambda cls: ["broken legacy plugin"]), + ) + + ToolRegistry.init() + + assert ToolRegistry._plugin_load_errors == ["broken legacy plugin"] + + +def test_plugin_refresh_commits_valid_sources_alongside_new_source_error( + monkeypatch: pytest.MonkeyPatch, +): + with _isolated_registry(): + previous = _tool("previous_plugin", "plugin_py") + ToolRegistry._tools[previous.info.name] = previous + ToolRegistry._plugin_tool_names = [previous.info.name] + ToolRegistry._plugin_load_errors = ["/plugins/legacy.py: broken legacy plugin"] + + def _load_with_new_error(cls, errors): + replacement = _tool("partial_replacement", "plugin_py") + cls._tools[replacement.info.name] = replacement + cls._plugin_tool_names = [replacement.info.name] + errors.extend( + [ + "/plugins/legacy.py: broken legacy plugin", + "/plugins/unrelated.py: new broken plugin", + "entry point legacy_package: RuntimeError: broken", + ] + ) + + monkeypatch.setattr( + ToolRegistry, + "_load_plugin_tools", + classmethod(_load_with_new_error), + ) + + refreshed = ToolRegistry.refresh_plugin_tools( + changed_path=Path("/plugins/newly_installed.py"), + ) + + assert refreshed == ["partial_replacement"] + assert set(ToolRegistry._tools) == {"partial_replacement"} + assert ToolRegistry._plugin_tool_names == ["partial_replacement"] + assert ToolRegistry._plugin_load_errors == [ + "/plugins/legacy.py: broken legacy plugin", + "/plugins/unrelated.py: new broken plugin", + "entry point legacy_package: RuntimeError: broken", + ] + + +def test_plugin_refresh_rejects_error_inside_changed_path( + monkeypatch: pytest.MonkeyPatch, +): + with _isolated_registry(): + previous = _tool("previous_plugin", "plugin_py") + ToolRegistry._tools[previous.info.name] = previous + ToolRegistry._plugin_tool_names = [previous.info.name] + + def _load_target_error(cls, errors): + errors.append("/plugins/new/broken.py: ImportError: missing dependency") + + monkeypatch.setattr( + ToolRegistry, + "_load_plugin_tools", + classmethod(_load_target_error), + ) + + with pytest.raises(ToolRefreshError, match="missing dependency"): + ToolRegistry.refresh_plugin_tools(changed_path=Path("/plugins/new")) + + assert ToolRegistry._tools == {previous.info.name: previous} + assert ToolRegistry._plugin_tool_names == [previous.info.name] + + +def test_plugin_refresh_restores_snapshot_when_finalize_fails( + monkeypatch: pytest.MonkeyPatch, +): + with _isolated_registry(): + builtin = _tool("builtin", "builtin") + previous = _tool("previous_plugin", "plugin_py") + ToolRegistry._tools = { + builtin.info.name: builtin, + previous.info.name: previous, + } + ToolRegistry._enabled_defaults = { + builtin.info.name: True, + previous.info.name: True, + } + ToolRegistry._plugin_tool_names = [previous.info.name] + ToolRegistry._plugin_load_errors = ["old error"] + ToolRegistry._revision = 7 + + def _load_with_unrelated_error(cls, errors): + replacement = _tool("replacement", "plugin_py") + cls._tools[replacement.info.name] = replacement + cls._enabled_defaults[replacement.info.name] = True + cls._plugin_tool_names = [replacement.info.name] + errors.append("/plugins/unrelated.py: SyntaxError: broken") + + def _failed_finalize(cls): + cls._tools[builtin.info.name].info.enabled = False + raise RuntimeError("finalize failed") + + monkeypatch.setattr( + ToolRegistry, + "_load_plugin_tools", + classmethod(_load_with_unrelated_error), + ) + monkeypatch.setattr( + ToolRegistry, + "_finalize_plugin_tools_load", + classmethod(_failed_finalize), + ) + + with pytest.raises(RuntimeError, match="finalize failed"): + ToolRegistry.refresh_plugin_tools( + changed_path=Path("/plugins/target.py"), + ) + + assert ToolRegistry._tools == { + builtin.info.name: builtin, + previous.info.name: previous, + } + assert builtin.info.enabled is True + assert ToolRegistry._enabled_defaults == { + builtin.info.name: True, + previous.info.name: True, + } + assert ToolRegistry._plugin_tool_names == [previous.info.name] + assert ToolRegistry._plugin_load_errors == ["old error"] + assert ToolRegistry._revision == 7 + + +def test_plugin_refresh_rejects_entry_point_scan_failure( + monkeypatch: pytest.MonkeyPatch, +): + with _isolated_registry(): + previous = _tool("entrypoint_old", "plugin_py") + ToolRegistry._tools[previous.info.name] = previous + ToolRegistry._plugin_tool_names = [previous.info.name] + + def _load_with_scan_failure(cls, errors): + replacement = _tool("hub_target", "plugin_py") + cls._tools[replacement.info.name] = replacement + cls._plugin_tool_names = [replacement.info.name] + errors.append("entry point scan: RuntimeError: metadata unavailable") + + monkeypatch.setattr( + ToolRegistry, + "_load_plugin_tools", + classmethod(_load_with_scan_failure), + ) + + with pytest.raises(ToolRefreshError, match="metadata unavailable"): + ToolRegistry.refresh_plugin_tools( + changed_path=Path("/plugins/hub_target.py"), + ) + + assert ToolRegistry._tools == {previous.info.name: previous} + assert ToolRegistry._plugin_tool_names == [previous.info.name] + + def test_dynamic_refresh_restores_last_known_good_on_load_error( monkeypatch: pytest.MonkeyPatch, ): @@ -121,7 +368,7 @@ def test_refresh_transactions_are_serialized_across_stages( def _failed_plugin_load(cls, errors): plugin_load_entered.set() assert allow_plugin_failure.wait(timeout=2) - errors.append("broken plugin") + errors.append("plugin loader: unavailable") def _successful_dynamic_load(cls, _errors): dynamic_load_entered.set() @@ -199,7 +446,7 @@ def test_registry_readers_do_not_observe_an_in_progress_refresh( def _failed_plugin_load(cls, errors): load_entered.set() assert allow_failure.wait(timeout=2) - errors.append("broken plugin") + errors.append("plugin loader: unavailable") monkeypatch.setattr( ToolRegistry, diff --git a/tui/flocks/cli/cmd/tui/context/local.tsx b/tui/flocks/cli/cmd/tui/context/local.tsx index b0d569018..0cd77695b 100644 --- a/tui/flocks/cli/cmd/tui/context/local.tsx +++ b/tui/flocks/cli/cmd/tui/context/local.tsx @@ -12,6 +12,7 @@ import { Provider } from "@/provider/provider" import { useArgs } from "./args" import { useSDK } from "./sdk" import { RGBA } from "@opentui/core" +import { selectFallbackModel } from "./model-selection" export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ name: "Local", @@ -174,16 +175,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ } } - const provider = sync.data.provider[0] - if (!provider) return undefined - const defaultModel = sync.data.provider_default[provider.id] - const firstModel = Object.values(provider.models)[0] - const model = defaultModel ?? firstModel?.id - if (!model) return undefined - return { - providerID: provider.id, - modelID: model, - } + return selectFallbackModel(sync.data.provider, sync.data.provider_default) }) const currentModel = createMemo(() => { diff --git a/tui/flocks/cli/cmd/tui/context/model-selection.test.ts b/tui/flocks/cli/cmd/tui/context/model-selection.test.ts new file mode 100644 index 000000000..3830009e2 --- /dev/null +++ b/tui/flocks/cli/cmd/tui/context/model-selection.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from "bun:test" +import { selectFallbackModel } from "./model-selection" + +describe("selectFallbackModel", () => { + test("skips an empty provider when a later provider has a model", () => { + expect( + selectFallbackModel( + [ + { id: "openai", models: {} }, + { + id: "threatbook-cn-llm", + models: { + "minimax-m3": { id: "minimax-m3" }, + }, + }, + ], + { "threatbook-cn-llm": "minimax-m3" }, + ), + ).toEqual({ + providerID: "threatbook-cn-llm", + modelID: "minimax-m3", + }) + }) + + test("uses the provider default when it is available", () => { + expect( + selectFallbackModel( + [ + { + id: "openai", + models: { + "gpt-4.1": { id: "gpt-4.1" }, + "gpt-5": { id: "gpt-5" }, + }, + }, + ], + { openai: "gpt-5" }, + ), + ).toEqual({ providerID: "openai", modelID: "gpt-5" }) + }) + + test("uses the first model when the provider default is unavailable", () => { + expect( + selectFallbackModel( + [ + { + id: "openai", + models: { + "gpt-4.1": { id: "gpt-4.1" }, + }, + }, + ], + { openai: "removed-model" }, + ), + ).toEqual({ providerID: "openai", modelID: "gpt-4.1" }) + }) + + test("returns undefined when no provider has a model", () => { + expect(selectFallbackModel([{ id: "openai", models: {} }], {})).toBeUndefined() + }) +}) diff --git a/tui/flocks/cli/cmd/tui/context/model-selection.ts b/tui/flocks/cli/cmd/tui/context/model-selection.ts new file mode 100644 index 000000000..1c8ac86cc --- /dev/null +++ b/tui/flocks/cli/cmd/tui/context/model-selection.ts @@ -0,0 +1,21 @@ +type Model = { + id: string +} + +type Provider = { + id: string + models: Record +} + +export function selectFallbackModel(providers: Provider[], defaults: Record) { + const provider = providers.find((item) => Object.keys(item.models).length > 0) + if (!provider) return undefined + const defaultModel = defaults[provider.id] + const firstModel = Object.values(provider.models)[0] + const modelID = defaultModel && provider.models[defaultModel] ? defaultModel : firstModel?.id + if (!modelID) return undefined + return { + providerID: provider.id, + modelID, + } +} diff --git a/uv.lock b/uv.lock index a09013247..5e6380561 100644 --- a/uv.lock +++ b/uv.lock @@ -598,6 +598,8 @@ dependencies = [ { name = "pyyaml" }, { name = "requests" }, { name = "rich" }, + { name = "slack-bolt" }, + { name = "slack-sdk" }, { name = "sqlalchemy" }, { name = "sse-starlette" }, { name = "starlette" }, @@ -667,6 +669,8 @@ requires-dist = [ { name = "pyyaml", specifier = ">=6.0.1" }, { name = "requests", specifier = ">=2.32.5" }, { name = "rich", specifier = ">=13.7.0" }, + { name = "slack-bolt", specifier = ">=1.26.0" }, + { name = "slack-sdk", specifier = ">=3.38.0" }, { name = "sqlalchemy", specifier = ">=2.0.25" }, { name = "sse-starlette", specifier = ">=1.8.2" }, { name = "starlette", specifier = ">=1.0.1" }, @@ -2137,6 +2141,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "slack-bolt" +version = "1.29.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "slack-sdk" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/3c/2c/7434f5d8c52eafb1e702012e9f291b013bd05985a5b32bde568b2279a28a/slack_bolt-1.29.0.tar.gz", hash = "sha256:b6271ba0a9b71e319c86b40632e6cb6240aacd0433773615b76b890b9a574762" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/2f/cc/e5f78a80a1775a4cbb2cc41e8ef433602d5e755ff0d829bd43145015740a/slack_bolt-1.29.0-py2.py3-none-any.whl", hash = "sha256:1835b66b778158f3af0da77603aa18d7dfd82fd9b9a985e25c752f95050ab826" }, +] + +[[package]] +name = "slack-sdk" +version = "3.43.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/13/75/a4964eb771a0c74d79ee7a3bee6fb5d9718909dd1b675e80d62a6a0ad90a/slack_sdk-3.43.0.tar.gz", hash = "sha256:0553152e46c4259eb69f7464cdadc35ba4802ca10f9f5a849c92cf03d6c2ba07" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/e4/55/42141b8338d46323d5b3c6095201b044c670c20f898643b322ea9b1543a1/slack_sdk-3.43.0-py2.py3-none-any.whl", hash = "sha256:4b6557c65577fc172f685af218b811f9f3b4909e24cddd839ada09565f10c585" }, +] + [[package]] name = "smmap" version = "5.0.3" diff --git a/webui/public/channel-slack.png b/webui/public/channel-slack.png new file mode 100644 index 000000000..1cc40f368 Binary files /dev/null and b/webui/public/channel-slack.png differ diff --git a/webui/src/api/provider.test.ts b/webui/src/api/provider.test.ts index 1e2cda5ce..2a63e4f57 100644 --- a/webui/src/api/provider.test.ts +++ b/webui/src/api/provider.test.ts @@ -136,3 +136,19 @@ describe('providerAPI.listApiServices', () => { expect(response.data[0].name).toBe('Forced Service'); }); }); + +describe('providerAPI.revealCredentials', () => { + afterEach(() => { + vi.resetModules(); + mockPost.mockReset(); + }); + + it('uses the explicit credential reveal endpoint', async () => { + mockPost.mockResolvedValue({ data: { api_key: 'sk-revealed', has_credential: true } }); + + const { providerAPI } = await import('./provider'); + await providerAPI.revealCredentials('openai'); + + expect(mockPost).toHaveBeenCalledWith('/api/provider/openai/credentials/reveal'); + }); +}); diff --git a/webui/src/api/provider.ts b/webui/src/api/provider.ts index a35235f42..e0f3062ef 100644 --- a/webui/src/api/provider.ts +++ b/webui/src/api/provider.ts @@ -111,6 +111,9 @@ export const providerAPI = { getCredentials: (id: string) => client.get(`/api/provider/${id}/credentials`), + revealCredentials: (id: string) => + client.post(`/api/provider/${id}/credentials/reveal`), + setCredentials: (id: string, credentials: ProviderCredentialInput) => client.post<{ success: boolean; message: string }>(`/api/provider/${id}/credentials`, credentials), diff --git a/webui/src/locales/en-US/channel.json b/webui/src/locales/en-US/channel.json index 8f8ad1c40..8e17ae116 100644 --- a/webui/src/locales/en-US/channel.json +++ b/webui/src/locales/en-US/channel.json @@ -1,6 +1,6 @@ { "pageTitle": "Channels", - "pageDescription": "IM channel configuration (Feishu, WeCom, WeChat, DingTalk, Telegram, WhatsApp)", + "pageDescription": "IM channel configuration (Feishu, WeCom, WeChat, DingTalk, Telegram, WhatsApp, Slack)", "cardTitle": "Channel Configuration", "cardDesc": "Configure IM bot integrations to allow AI Agents to receive and send messages on Feishu, WeCom, and other platforms.", "tabs": { @@ -12,6 +12,7 @@ "wecom": "WeCom", "dingtalk": "DingTalk", "telegram": "Telegram", + "slack": "Slack", "email": "Email", "whatsapp": "WhatsApp", "weixin": "WeChat" @@ -40,7 +41,9 @@ "lastMessage": "Last Message", "reconnects": "Reconnects", "totalErrors": "Total Errors", - "running": "Running" + "running": "Running", + "failureReason": "Failure Reason", + "retryAfterFix": "Fix the configuration based on this reason, save it, then click “Restart Connection” to reconnect." }, "card": { "running": "Running", @@ -236,6 +239,56 @@ "dedupTtlSecondsHint": "Retention time for message deduplication records (default 86400s = 24h)", "optional": "Optional" }, + "slack": { + "setup": "Slack App Setup", + "setupDesc": "Create the Slack app from a manifest first, then paste the generated tokens below.", + "downloadManifest": "Download Slack Manifest", + "downloadManifestDesc": "Get an import-ready Slack app configuration file", + "downloadManifestFailed": "Failed to download Slack Manifest", + "openSlackApps": "Open Slack Developer Console", + "openSlackAppsDesc": "Create the app, review permissions, and copy tokens", + "stepCreateApp": "Open the Slack Developer Console, choose Create New App, then select From an app manifest and paste the downloaded manifest.", + "stepInstall": "Save the manifest, install the app to your workspace, and invite the bot to channels that should talk to Flocks.", + "stepTokens": "Copy the Bot User OAuth Token from OAuth & Permissions and create an App-Level Token with connections:write under Basic Information.", + "stepSaveEnable": "Paste both tokens here, save the config, then enable or restart the Slack channel.", + "manifestHint": "The manifest enables Socket Mode and subscribes to app_mention plus message events for DMs, group DMs, public channels, and private channels.", + "credentials": "Slack App Credentials", + "credentialsDesc": "Fill these in after creating and installing a Slack app with Socket Mode enabled. The Bot Token is under OAuth & Permissions; the App Token is under Basic Information > App-Level Tokens.", + "botTokenHint": "In the app’s OAuth & Permissions page, Install/Reinstall to Workspace, then copy the Bot User OAuth Token (starts with xoxb-).", + "appTokenHint": "In Basic Information > App-Level Tokens, click Generate Token and Scopes, add connections:write, then copy the App-Level Token (starts with xapp-).", + "homeChannel": "Home Channel", + "homeChannelHint": "Default Slack channel ID for proactive sends, for example C0123456789", + "homeChannelName": "Home Channel Name", + "homeChannelNameHint": "Optional display name shown in configuration and notes", + "behavior": "Message Behavior", + "behaviorDesc": "Configure message routing and who can trigger the Agent.", + "defaultAgent": "Default Agent", + "defaultAgentHint": "Default Agent ID used when no Agent is specified", + "groupTrigger": "Channel Trigger", + "groupTriggerHint": "When to trigger Agent response in Slack channels and group DMs", + "triggerMention": "Only when @mentioned", + "triggerAll": "All messages", + "allowFromEnabled": "Set Member Allowlist", + "allowFromEnabledHint": "Show allowed member ID settings when enabled. Leave disabled to avoid setting a Slack member allowlist.", + "allowFromEnabledLabel": "Set allowlist", + "allowFrom": "Allowed Member IDs", + "allowFromHint": "Slack member IDs allowed to trigger the Agent (leave empty to allow everyone)", + "allowFromPlaceholder": "Enter Slack member ID and press Enter", + "advanced": "Advanced Settings", + "advancedDesc": "Thread reply behavior and bot-message handling.", + "replyInThread": "Reply in Threads", + "replyInThreadHint": "When enabled, replies to channel messages stay in Slack threads.", + "replyInThreadLabel": "Reply in Slack thread", + "replyBroadcast": "Broadcast Thread Replies", + "replyBroadcastHint": "Also post the first thread reply into the parent channel.", + "replyBroadcastLabel": "Broadcast replies to channel", + "allowBots": "Bot Messages", + "allowBotsHint": "Controls whether messages from other bots can trigger the Agent.", + "allowBotsNone": "Ignore bot messages", + "allowBotsMentions": "Only bot messages that mention this bot", + "allowBotsAll": "Allow all bot messages", + "optional": "Optional" + }, "email": { "credentials": "Mailbox Credentials", "credentialsDesc": "Use a dedicated mailbox with IMAP enabled and an app password.", diff --git a/webui/src/locales/zh-CN/channel.json b/webui/src/locales/zh-CN/channel.json index 7c2940f8a..2de046cd3 100644 --- a/webui/src/locales/zh-CN/channel.json +++ b/webui/src/locales/zh-CN/channel.json @@ -1,6 +1,6 @@ { "pageTitle": "通道", - "pageDescription": "IM 通道连接配置(飞书、企业微信、微信、钉钉、Telegram、WhatsApp)", + "pageDescription": "IM 通道连接配置(飞书、企业微信、微信、钉钉、Telegram、WhatsApp、Slack)", "cardTitle": "通道配置", "cardDesc": "配置 IM 机器人接入,让 AI Agent 能够在飞书、企业微信等平台上接收和发送消息。", "tabs": { @@ -12,6 +12,7 @@ "wecom": "企业微信", "dingtalk": "钉钉", "telegram": "Telegram", + "slack": "Slack", "email": "邮件", "whatsapp": "WhatsApp", "weixin": "微信" @@ -40,7 +41,9 @@ "lastMessage": "最近消息", "reconnects": "重连次数", "totalErrors": "累计错误", - "running": "运行" + "running": "运行", + "failureReason": "失败原因", + "retryAfterFix": "请根据失败原因修正配置,保存后点击“重启连接”重新建立连接。" }, "card": { "running": "运行中", @@ -238,6 +241,56 @@ "dedupTtlSecondsHint": "消息去重记录的保留时间(默认 86400 秒 = 24 小时)", "optional": "选填" }, + "slack": { + "setup": "Slack 应用配置", + "setupDesc": "先用 Manifest 创建 Slack App,再把生成的 token 填到下方。", + "downloadManifest": "下载 Slack Manifest", + "downloadManifestDesc": "获取可直接导入的应用配置文件", + "downloadManifestFailed": "下载 Slack Manifest 失败", + "openSlackApps": "打开 slack 开发者管理后台", + "openSlackAppsDesc": "进入 Slack App 创建、权限和 Token 页面", + "stepCreateApp": "打开 slack 开发者管理后台,选择 Create New App,再选择 From an app manifest,粘贴下载的 Manifest。", + "stepInstall": "保存 Manifest,将 App 安装到 workspace,并把 Bot 邀请进需要接入 Flocks 的频道。", + "stepTokens": "从 OAuth & Permissions 复制 Bot User OAuth Token;在 Basic Information 创建带 connections:write 的 App-Level Token。", + "stepSaveEnable": "把两个 token 填到这里,保存配置,然后启用或重启 Slack 通道。", + "manifestHint": "Manifest 会启用 Socket Mode,并订阅 app_mention 以及私信、多人私信、公开频道、私有频道的 message 事件。", + "credentials": "Slack 应用凭证", + "credentialsDesc": "创建并安装启用 Socket Mode 的 Slack App 后填写。Bot Token 来自 OAuth & Permissions;App Token 来自 Basic Information 的 App-Level Tokens。", + "botTokenHint": "在 Slack 开发者管理后台进入应用的 OAuth & Permissions,先 Install/Reinstall to Workspace,然后复制 Bot User OAuth Token(xoxb- 开头)。", + "appTokenHint": "在应用的 Basic Information > App-Level Tokens 中点击 Generate Token and Scopes,添加 connections:write 后复制 App-Level Token(xapp- 开头)。", + "homeChannel": "默认频道", + "homeChannelHint": "主动发送消息时使用的默认 Slack 频道 ID,例如 C0123456789", + "homeChannelName": "默认频道名称", + "homeChannelNameHint": "选填,仅用于配置展示和备注", + "behavior": "消息行为", + "behaviorDesc": "配置消息路由和触发条件。", + "defaultAgent": "默认 Agent", + "defaultAgentHint": "未指定 Agent 时使用的默认 Agent ID", + "groupTrigger": "频道触发方式", + "groupTriggerHint": "Slack 频道和多人私信中什么情况下触发 Agent 响应", + "triggerMention": "仅 @ 提及时触发", + "triggerAll": "所有消息都触发", + "allowFromEnabled": "设置白名单", + "allowFromEnabledHint": "开启后显示允许的成员 ID 配置;关闭则不设置成员白名单。", + "allowFromEnabledLabel": "设置白名单", + "allowFrom": "允许的成员 ID", + "allowFromHint": "仅允许这些 Slack member ID 触发 Agent(留空则允许所有人)", + "allowFromPlaceholder": "输入 Slack member ID 后按回车添加", + "advanced": "高级设置", + "advancedDesc": "线程回复和 Bot 消息处理策略。", + "replyInThread": "线程回复", + "replyInThreadHint": "启用后,频道消息的回复会发送到 Slack thread 中。", + "replyInThreadLabel": "在 Slack thread 中回复", + "replyBroadcast": "广播线程回复", + "replyBroadcastHint": "将第一条 thread 回复同时广播到父频道。", + "replyBroadcastLabel": "将回复广播到频道", + "allowBots": "Bot 消息", + "allowBotsHint": "控制其他 Bot 发出的消息是否可以触发 Agent。", + "allowBotsNone": "忽略 Bot 消息", + "allowBotsMentions": "仅允许 @ 本 Bot 的 Bot 消息", + "allowBotsAll": "允许所有 Bot 消息", + "optional": "选填" + }, "email": { "credentials": "邮箱凭证", "credentialsDesc": "建议使用专用邮箱,并启用 IMAP 与应用专用密码。", diff --git a/webui/src/pages/Channel/index.tsx b/webui/src/pages/Channel/index.tsx index 101dc5dd8..14a9a1653 100644 --- a/webui/src/pages/Channel/index.tsx +++ b/webui/src/pages/Channel/index.tsx @@ -20,6 +20,7 @@ import { RefreshCw, Loader2, RotateCcw, + ExternalLink, } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import PageHeader from '@/components/common/PageHeader'; @@ -129,6 +130,20 @@ interface TelegramChannelConfig { streamingCoalesceMs?: number; } +interface SlackChannelConfig { + enabled: boolean; + botToken?: string; + appToken?: string; + homeChannel?: string; + homeChannelName?: string; + defaultAgent?: string; + groupTrigger?: string; + allowFrom?: string[]; + replyInThread?: boolean; + replyBroadcast?: boolean; + allowBots?: 'none' | 'mentions' | 'all'; +} + interface EmailChannelConfig { enabled: boolean; address?: string; @@ -287,6 +302,7 @@ type ChannelConfig = | WeComChannelConfig | DingTalkChannelConfig | TelegramChannelConfig + | SlackChannelConfig | EmailChannelConfig | WeixinChannelConfig | WhatsAppChannelConfig; @@ -336,6 +352,16 @@ function defaultTelegramConfig(): TelegramChannelConfig { }; } +function defaultSlackConfig(): SlackChannelConfig { + return { + enabled: false, + groupTrigger: 'mention', + replyInThread: true, + replyBroadcast: false, + allowBots: 'none', + }; +} + function defaultEmailConfig(): EmailChannelConfig { return { enabled: false, @@ -747,6 +773,7 @@ const CHANNEL_ICON_SRC: Record = { email: '/channel-email.png', weixin: '/channel-weixin.png', whatsapp: '/channel-whatsapp.png', + slack: '/channel-slack.png', }; const FEISHU_GUIDE_PDF_URL = '/feishu-bot-guide.pdf'; @@ -755,6 +782,7 @@ const WECOM_GUIDE_PDF_URL = '/wecom-bot-guide.pdf'; const WECOM_GUIDE_PDF_FILENAME = 'wecom-bot-guide.pdf'; const DINGTALK_GUIDE_PDF_URL = '/dingtalk-channel-guide.pdf'; const DINGTALK_GUIDE_PDF_FILENAME = 'dingtalk-channel-guide.pdf'; +const SLACK_APPS_URL = 'https://api.slack.com/apps'; function getChannelIcon(id: string, size: 'sm' | 'md' = 'sm') { const dim = size === 'md' ? 'w-10 h-10' : 'w-9 h-9'; @@ -924,8 +952,8 @@ function ConnectionStatusPanel({ status, config, channelId }: ConnectionStatusPa )} {status?.last_error && ( -

- {status.last_error} +

+ {t('connection.failureReason')}

)} @@ -935,10 +963,28 @@ function ConnectionStatusPanel({ status, config, channelId }: ConnectionStatusPa {channelId === 'dingtalk' && 'Stream'} {channelId === 'weixin' && 'Long-Poll'} {channelId === 'telegram' && ((config as TelegramChannelConfig).mode === 'webhook' ? 'Webhook' : 'Polling')} + {channelId === 'slack' && 'Socket Mode'} {channelId === 'email' && 'IMAP Polling'} + {status?.last_error && ( +
+
+ + {t('connection.failureReason')} +
+

+ {status.last_error} +

+ {!isConnected && ( +

+ {t('connection.retryAfterFix')} +

+ )} +
+ )} + {/* Metrics row */} {isEnabled && (
@@ -971,9 +1017,12 @@ function ChannelCard({ meta, config, status, isSelected, onClick }: ChannelCardP // status key present = gateway is tracking this channel const isInGateway = status !== undefined; const isConnected = status?.connected === true; + const hasError = Boolean(status?.last_error); const dotColor = isConnected ? 'bg-green-500' + : hasError + ? 'bg-red-500' : isInGateway ? 'bg-amber-400' : isEnabled @@ -982,6 +1031,8 @@ function ChannelCard({ meta, config, status, isSelected, onClick }: ChannelCardP const subText = isConnected ? t('card.running') + : hasError + ? t('connection.error') : isInGateway ? t('card.connecting') : isEnabled @@ -1688,6 +1739,211 @@ function TelegramPanel({ config, onChange, onRefresh }: TelegramPanelProps) { ); } +// ============================================================================ +// Slack Config Panel +// ============================================================================ + +function SlackPanel({ + config, + onChange, +}: { + config: SlackChannelConfig; + onChange: (c: SlackChannelConfig) => void; +}) { + const { t } = useTranslation('channel'); + const toast = useToast(); + const set = useCallback( + (key: K, value: SlackChannelConfig[K]) => + onChange({ ...config, [key]: value }), + [config, onChange] + ); + const allowFromEnabled = config.allowFrom !== undefined; + const toggleAllowFrom = useCallback( + (enabled: boolean) => { + onChange({ ...config, allowFrom: enabled ? (config.allowFrom ?? []) : undefined }); + }, + [config, onChange] + ); + const handleDownloadManifest = useCallback(async () => { + try { + const res = await client.get('/api/channel/slack/manifest/download', { + responseType: 'blob', + }); + const url = URL.createObjectURL(new Blob([res.data], { type: 'application/json' })); + const link = document.createElement('a'); + link.href = url; + link.download = 'flocks-slack-manifest.json'; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); + } catch (err: any) { + toast.error(t('slack.downloadManifestFailed'), err.message); + } + }, [t, toast]); + + return ( + <> +
+
+
+ + + + + + + + {t('slack.openSlackApps')} + {t('slack.openSlackAppsDesc')} + + + + +
+
    +
  1. + 1 + {t('slack.stepCreateApp')} +
  2. +
  3. + 2 + {t('slack.stepInstall')} +
  4. +
  5. + 3 + {t('slack.stepTokens')} +
  6. +
  7. + 4 + {t('slack.stepSaveEnable')} +
  8. +
+

+ * + {t('slack.manifestHint')} +

+
+
+ +
+ + set('botToken', v || undefined)} + placeholder="xoxb-..." + /> + + + set('appToken', v || undefined)} + placeholder="xapp-..." + /> + + + set('homeChannel', v || undefined)} + placeholder="C0123456789" + /> + + + set('homeChannelName', v || undefined)} + placeholder="general" + /> + +
+ +
+ + set('defaultAgent', v || undefined)} + placeholder={t('slack.optional')} + /> + + + set('allowBots', v as 'none' | 'mentions' | 'all')} + options={[ + { value: 'none', label: t('slack.allowBotsNone') }, + { value: 'mentions', label: t('slack.allowBotsMentions') }, + { value: 'all', label: t('slack.allowBotsAll') }, + ]} + /> + +
+ + ); +} + // ============================================================================ // Email Config Panel // ============================================================================ @@ -2791,8 +3047,8 @@ export default function ChannelPage() { configs[ch.id] = { ...defaultDingTalkConfig(), ...saved }; } else if (ch.id === 'telegram') { configs[ch.id] = { ...defaultTelegramConfig(), ...saved }; - } else if (ch.id === 'email') { - configs[ch.id] = { ...defaultEmailConfig(), ...saved }; + } else if (ch.id === 'slack') { + configs[ch.id] = { ...defaultSlackConfig(), ...saved }; } else if (ch.id === 'email') { configs[ch.id] = { ...defaultEmailConfig(), ...saved }; } else if (ch.id === 'whatsapp') { @@ -2863,7 +3119,7 @@ export default function ChannelPage() { const updatedChannels = { ...(fullConfig.channels ?? {}), ...Object.fromEntries( - Object.entries(channelConfigs).map(([id, cfg]) => [id, stripEmpty(cfg)]) + Object.entries(channelConfigs).map(([id, cfg]) => [id, stripChannelConfigForSave(id, cfg)]) ), }; @@ -3167,6 +3423,12 @@ export default function ChannelPage() { onRefresh={fetchAll} /> )} + {selectedId === 'slack' && ( + handleChannelConfigChange('slack', cfg)} + /> + )} {selectedId === 'email' && ( ): Record { } return result; } + +function stripChannelConfigForSave(channelId: string, cfg: Record): Record { + const result = stripEmpty(cfg); + + if (channelId === 'slack' && cfg.allowFrom === undefined) { + result.allowFrom = null; + } + + return result; +} diff --git a/webui/src/pages/Model/index.test.tsx b/webui/src/pages/Model/index.test.tsx index 1515819c6..cbd42e1d7 100644 --- a/webui/src/pages/Model/index.test.tsx +++ b/webui/src/pages/Model/index.test.tsx @@ -21,6 +21,7 @@ const mocks = vi.hoisted(() => ({ catalogList: vi.fn(), createProvider: vi.fn(), getCredentials: vi.fn(), + revealCredentials: vi.fn(), setCredentials: vi.fn(), testCredentials: vi.fn(), })); @@ -113,6 +114,7 @@ vi.mock('@/components/common/EntitySheet', () => ({ vi.mock('@/api/provider', () => ({ providerAPI: { getCredentials: mocks.getCredentials, + revealCredentials: mocks.revealCredentials, setCredentials: mocks.setCredentials, testCredentials: mocks.testCredentials, }, @@ -277,6 +279,15 @@ describe('ModelPage configure provider dialog', () => { has_credential: true, }, }); + mocks.revealCredentials.mockResolvedValue({ + data: { + secret_id: 'openai_llm_key', + api_key: 'sk-existing-secret-1234', + api_key_masked: 'sk-***1234', + base_url: 'https://old.example.com/v1', + has_credential: true, + }, + }); mocks.setCredentials.mockResolvedValue({ data: { success: true } }); mocks.testCredentials.mockResolvedValue({ data: { @@ -294,15 +305,18 @@ describe('ModelPage configure provider dialog', () => { renderWithRouter(); await user.click(await screen.findByTitle('Configure')); expect(await screen.findByTestId('entity-sheet')).toBeInTheDocument(); + expect(mocks.revealCredentials).toHaveBeenCalledWith('openai'); } - it('preserves an existing secret when saving a new base URL with a blank key', async () => { + it('displays and preserves the existing API key when saving a new base URL', async () => { const user = userEvent.setup(); await openConfigureDialog(user); - const apiKeyInput = screen.getByPlaceholderText('Leave blank to keep the existing API key'); - expect(apiKeyInput).toHaveValue(''); + const apiKeyInput = screen.getByDisplayValue('sk-existing-secret-1234'); + expect(apiKeyInput).toHaveAttribute('type', 'password'); expect(apiKeyInput).not.toHaveValue('sk-***1234'); + await user.click(screen.getByTitle('form.show')); + expect(apiKeyInput).toHaveAttribute('type', 'text'); const baseUrlInput = screen.getByPlaceholderText('https://api.example.com/v1'); await user.clear(baseUrlInput); @@ -317,7 +331,7 @@ describe('ModelPage configure provider dialog', () => { expect(payload).not.toHaveProperty('api_key'); }); - it('persists a base URL change without a key before testing the connection', async () => { + it('persists the displayed API key and a base URL change before testing the connection', async () => { const user = userEvent.setup(); await openConfigureDialog(user); mocks.setCredentials.mockClear(); @@ -338,4 +352,19 @@ describe('ModelPage configure provider dialog', () => { expect(mocks.testCredentials).toHaveBeenCalledWith('openai', 'gpt-4o'); }); }); + + it('submits a replacement API key when the displayed value changes', async () => { + const user = userEvent.setup(); + await openConfigureDialog(user); + + const apiKeyInput = screen.getByDisplayValue('sk-existing-secret-1234'); + await user.clear(apiKeyInput); + await user.type(apiKeyInput, 'sk-replacement-secret'); + await user.click(screen.getByRole('button', { name: 'Save' })); + + await waitFor(() => expect(mocks.setCredentials).toHaveBeenCalled()); + expect(mocks.setCredentials.mock.calls.at(-1)?.[1]).toEqual(expect.objectContaining({ + api_key: 'sk-replacement-secret', + })); + }); }); diff --git a/webui/src/pages/Model/index.tsx b/webui/src/pages/Model/index.tsx index 3a8e1b4c8..298bad220 100644 --- a/webui/src/pages/Model/index.tsx +++ b/webui/src/pages/Model/index.tsx @@ -505,7 +505,15 @@ export default function ModelPage() { onConfigure={async () => { await handleSelectProvider(provider); if (selectedProviderRef.current?.id === provider.id) { - setShowConfigDialog(true); + try { + const res = await providerAPI.revealCredentials(provider.id); + if (selectedProviderRef.current?.id === provider.id) { + setCredentials(res.data); + setShowConfigDialog(true); + } + } catch (err: any) { + toast.error(t('configFailed'), err.message); + } } }} /> @@ -554,7 +562,10 @@ export default function ModelPage() { provider={selectedProvider} existingCredentials={credentials} models={providerModels} - onClose={() => setShowConfigDialog(false)} + onClose={() => { + setShowConfigDialog(false); + setCredentials(null); + }} onConfigured={async () => { if (selectedProvider) { const res = await providerAPI.getCredentials(selectedProvider.id).catch(() => ({ data: null })); @@ -2192,10 +2203,7 @@ function ConfigureProviderDialog({ provider, existingCredentials, models, onClos const toast = useToast(); const { t } = useTranslation('model'); const hasExisting = existingCredentials?.has_credential ?? false; - // Credential reads intentionally never return the raw key. Keep the input - // empty and use hasExisting to express "leave blank to preserve"; in - // particular, never seed this field with api_key_masked. - const existingKey = ''; + const existingKey = existingCredentials?.api_key ?? ''; const existingBaseUrl = existingCredentials?.base_url ?? ''; const [baseUrl, setBaseUrl] = useState(existingCredentials?.base_url ?? ''); @@ -2273,6 +2281,7 @@ function ConfigureProviderDialog({ provider, existingCredentials, models, onClos const handleSubmit = async () => { const nextApiKey = apiKey.trim(); + const apiKeyChanged = nextApiKey !== existingKey.trim(); if (!nextApiKey && !hasExisting && !providerAllowsEmptyApiKey(provider.id)) { toast.warning('Please enter API Key'); return; @@ -2285,7 +2294,7 @@ function ConfigureProviderDialog({ provider, existingCredentials, models, onClos ? (providerName.trim() || undefined) : undefined, }; - if (nextApiKey) payload.api_key = nextApiKey; + if (nextApiKey && apiKeyChanged) payload.api_key = nextApiKey; await providerAPI.setCredentials(provider.id, payload); // Sync catalog model list: add newly selected, delete deselected @@ -2326,6 +2335,7 @@ function ConfigureProviderDialog({ provider, existingCredentials, models, onClos ); const nextApiKey = apiKey.trim(); + const apiKeyChanged = nextApiKey !== existingKey.trim(); if (!nextApiKey && !hasExisting && !providerAllowsEmptyApiKey(provider.id)) { toast.warning('Please enter API Key first'); return; @@ -2341,7 +2351,7 @@ function ConfigureProviderDialog({ provider, existingCredentials, models, onClos ? (providerName.trim() || undefined) : undefined, }; - if (nextApiKey) payload.api_key = nextApiKey; + if (nextApiKey && apiKeyChanged) payload.api_key = nextApiKey; await providerAPI.setCredentials(provider.id, payload); } catch (err: any) { toast.error(t('deleteFailed'), err.message); diff --git a/webui/src/types/index.ts b/webui/src/types/index.ts index e78dbfd28..84dd9a17f 100644 --- a/webui/src/types/index.ts +++ b/webui/src/types/index.ts @@ -397,10 +397,8 @@ export interface MCPCatalogStats { export interface ProviderCredentials { secret_id?: string | null; /** - * Write-only compatibility field. Credential reads never return the raw key; - * they return ``api_key: null`` plus ``api_key_masked`` and - * ``has_credential``. UI code must never submit the masked value as a new - * secret. Leave ``api_key`` out of an update to preserve the stored key. + * Raw API key returned only by the admin-only reveal endpoint. Ordinary LLM + * and API-service credential reads return null and expose only masked values. */ api_key?: string | null; api_key_masked?: string | null;