From 2b187846914a11ebff442caf9b2e55dd12033d89 Mon Sep 17 00:00:00 2001 From: chenjie Date: Mon, 21 Sep 2026 00:54:06 +0800 Subject: [PATCH 1/5] feat(plugins): add native groups and protect builtin defaults Persist group metadata through existing plugin definitions and native APIs, with derived navigation and personal view/order preferences. Assign nonempty groups to shipped definitions and enforce readonly values without locking user-configured service and device instances. Fix configuration preservation, workflow migration targets, native defaults and installer group retention; remove redundant UI behavior and add regression coverage. Co-Authored-By: Claude Code --- .flocks/mcp_list.json.example | 11 + .../plugins/agents/asset-survey/agent.yaml | 1 + .../agents/device-inspector/agent.yaml | 1 + .../agents/host-forensics-fast/agent.yaml | 1 + .../plugins/agents/host-forensics/agent.yaml | 1 + .../hrti_threat_intelligence/agent.yaml | 1 + .flocks/plugins/agents/ndr-analyst/agent.yaml | 1 + .../agents/phishing-detector/agent.yaml | 1 + .flocks/plugins/agents/ti-analyst/agent.yaml | 1 + .../agents/vul_threat_intelligence/agent.yaml | 1 + .flocks/plugins/skills/agent-builder/SKILL.md | 1 + .flocks/plugins/skills/browser-use/SKILL.md | 1 + .../skills/device-integration-guide/SKILL.md | 1 + .../skills/ndr-alert-analysis/SKILL.md | 1 + .flocks/plugins/skills/onboarding/SKILL.md | 1 + .flocks/plugins/skills/onesec-use/SKILL.md | 1 + .flocks/plugins/skills/onesig-use/SKILL.md | 1 + .flocks/plugins/skills/qingteng-use/SKILL.md | 1 + .../plugins/skills/sangfor-edr-use/SKILL.md | 1 + .../plugins/skills/sangfor-xdr-use/SKILL.md | 1 + .flocks/plugins/skills/skill-builder/SKILL.md | 1 + .../plugins/skills/skyeye-sensor-use/SKILL.md | 1 + .flocks/plugins/skills/skyeye-use/SKILL.md | 1 + .flocks/plugins/skills/tdp-use/SKILL.md | 1 + .flocks/plugins/skills/tool-builder/SKILL.md | 1 + .flocks/plugins/skills/web2cli/SKILL.md | 1 + .../skills/webui-page-builder/SKILL.md | 1 + .../plugins/skills/workflow-builder/SKILL.md | 1 + .../skills/workflow-config-guide/SKILL.md | 1 + .flocks/plugins/tools/api/fofa/_provider.yaml | 1 + .flocks/plugins/tools/api/fofa/host.yaml | 1 + .flocks/plugins/tools/api/fofa/info.yaml | 1 + .flocks/plugins/tools/api/fofa/search.yaml | 1 + .flocks/plugins/tools/api/fofa/stats.yaml | 1 + .../tools/api/greynoise/_provider.yaml | 1 + .../api/greynoise/community_ip_query.yaml | 1 + .../tools/api/greynoise/cve_lookup.yaml | 1 + .../tools/api/greynoise/gnql_query.yaml | 1 + .../tools/api/greynoise/gnql_stats.yaml | 1 + .../greynoise_enterprise_ip_query.yaml | 1 + .../tools/api/greynoise/ip_timeline.yaml | 1 + .flocks/plugins/tools/api/greynoise/tags.yaml | 1 + .../tools/api/threatbook-cn/_provider.yaml | 1 + .../threatbook_cn_file_upload.yaml | 1 + .../threatbook-cn/threatbook_cn_url_scan.yaml | 1 + .../tools/api/threatbook-io/_provider.yaml | 1 + .../threatbook_io_domain_query.yaml | 1 + .../threatbook_io_file_query.yaml | 1 + .../threatbook_io_file_upload.yaml | 1 + .../threatbook-io/threatbook_io_ip_query.yaml | 1 + .../threatbook_io_report_detail.yaml | 1 + .../threatbook_io_report_list.yaml | 1 + .../threatbook_io_url_query.yaml | 1 + .../threatbook_io_vulnerability_query.yaml | 1 + .../plugins/tools/api/urlscan/_provider.yaml | 1 + .../api/urlscan/available_countries.yaml | 1 + .flocks/plugins/tools/api/urlscan/dom.yaml | 1 + .flocks/plugins/tools/api/urlscan/quotas.yaml | 1 + .flocks/plugins/tools/api/urlscan/result.yaml | 1 + .flocks/plugins/tools/api/urlscan/scan.yaml | 1 + .../plugins/tools/api/urlscan/screenshot.yaml | 1 + .flocks/plugins/tools/api/urlscan/search.yaml | 1 + .../tools/api/urlscan/user_agents.yaml | 1 + .../plugins/tools/api/urlscan/username.yaml | 1 + .../tools/api/virustotal/_provider.yaml | 2 + .../virustotal_analysis_status.yaml | 1 + .../virustotal/virustotal_domain_query.yaml | 1 + .../api/virustotal/virustotal_file_query.yaml | 1 + .../api/virustotal/virustotal_file_scan.yaml | 1 + .../api/virustotal/virustotal_ip_query.yaml | 1 + .../api/virustotal/virustotal_url_query.yaml | 1 + .../api/virustotal/virustotal_url_scan.yaml | 1 + .../tools/device/ngsoc_v4_15_1/_provider.yaml | 1 + .../device/ngsoc_v4_15_1/ngsoc_alarms.yaml | 1 + .../device/ngsoc_v4_15_1/ngsoc_assets.yaml | 1 + .../ngsoc_v4_15_1/ngsoc_bigscreens.yaml | 1 + .../device/ngsoc_v4_15_1/ngsoc_risks.yaml | 1 + .../device/ngsoc_v4_15_1/ngsoc_storage.yaml | 1 + .../device/ngsoc_v4_15_1/ngsoc_users.yaml | 1 + .../device/ngsoc_v4_15_1/ngsoc_vuls.yaml | 1 + .../ngsoc_v4_15_1/ngsoc_workorders.yaml | 1 + .../tools/device/ngtip_v5_1_5/_provider.yaml | 1 + .../device/ngtip_v5_1_5/ngtip_platform.yaml | 1 + .../device/ngtip_v5_1_5/ngtip_query.yaml | 1 + .../tools/device/onesandbox_v3/_provider.yaml | 1 + .../device/onesandbox_v3/onesandbox_ops.yaml | 1 + .../onesandbox_v3/onesandbox_query.yaml | 1 + .../tools/device/onesec_v2_8_2/_provider.yaml | 1 + .../device/onesec_v2_8_2/onesec_dns.yaml | 1 + .../device/onesec_v2_8_2/onesec_edr.yaml | 1 + .../device/onesec_v2_8_2/onesec_ops.yaml | 1 + .../device/onesec_v2_8_2/onesec_software.yaml | 1 + .../device/onesec_v2_8_2/onesec_threat.yaml | 1 + .../tools/device/onesig_v2_5_3/_provider.yaml | 1 + .../onesig_strategy_api_ops.yaml | 1 + .../onesig_strategy_api_query.yaml | 1 + .../device/qingteng_v3_4_1_66/_provider.yaml | 1 + .../qingteng_asset_discovery.yaml | 1 + .../qingteng_v3_4_1_66/qingteng_assets.yaml | 1 + .../qingteng_v3_4_1_66/qingteng_baseline.yaml | 1 + .../qingteng_v3_4_1_66/qingteng_detect.yaml | 1 + .../qingteng_v3_4_1_66/qingteng_fastjob.yaml | 1 + .../qingteng_host_list.yaml | 1 + .../qingteng_v3_4_1_66/qingteng_microseg.yaml | 1 + .../qingteng_v3_4_1_66/qingteng_risk.yaml | 1 + .../qingteng_system_audit.yaml | 1 + .../qingteng_vul_check.yaml | 1 + .../device/sangfor_af_v8_0_106/_provider.yaml | 1 + .../sangfor_af_v106_alarm.yaml | 1 + .../sangfor_af_v106_auth.yaml | 1 + .../sangfor_af_v106_monitor.yaml | 1 + .../sangfor_af_v106_network.yaml | 1 + .../sangfor_af_v106_objects.yaml | 1 + .../sangfor_af_v106_ops.yaml | 1 + .../sangfor_af_v106_status.yaml | 1 + .../sangfor_af_v106_system.yaml | 1 + .../device/sangfor_edr_asset_inventory.yaml | 1 + .../device/sangfor_edr_webcli/_provider.yaml | 1 + .../sangfor_edr_advanced_threat.yaml | 1 + .../sangfor_edr_webcli/sangfor_edr_auth.yaml | 1 + .../sangfor_edr_dashboard.yaml | 1 + .../sangfor_edr_threat_assets.yaml | 1 + .../device/sangfor_sip_v92/_provider.yaml | 1 + .../sangfor_sip_v92/sangfor_sip_assets.yaml | 1 + .../sangfor_sip_v92/sangfor_sip_events.yaml | 1 + .../sangfor_sip_v92/sangfor_sip_risk.yaml | 1 + .../sangfor_sip_v92/sangfor_sip_vuln.yaml | 1 + .../device/sangfor_xdr_v2_2/_provider.yaml | 1 + .../sangfor_xdr_v2_2/sangfor_xdr_alerts.yaml | 1 + .../sangfor_xdr_v2_2/sangfor_xdr_assets.yaml | 1 + .../sangfor_xdr_incidents.yaml | 1 + .../sangfor_xdr_responses.yaml | 1 + .../sangfor_xdr_v2_2/sangfor_xdr_vulns.yaml | 1 + .../sangfor_xdr_whitelists.yaml | 1 + .../skyeye_v4_0_14_0_SP2/_provider.yaml | 1 + .../skyeye_alarm_list.yaml | 1 + .../skyeye_alarm_params.yaml | 1 + .../skyeye_dashboard_view.yaml | 1 + .../skyeye_download_alarm_report.yaml | 1 + .../skyeye_download_pcap.yaml | 1 + .../skyeye_download_uploadfile.yaml | 1 + .../tools/device/tdp_v3_3_10/_provider.yaml | 1 + .../tdp_v3_3_10/tdp_asset_upload_api.yaml | 1 + .../tdp_v3_3_10/tdp_assets_domain_list.yaml | 1 + .../tdp_v3_3_10/tdp_cloud_facilities.yaml | 1 + .../tdp_v3_3_10/tdp_dashboard_status.yaml | 1 + .../device/tdp_v3_3_10/tdp_file_download.yaml | 1 + .../tdp_v3_3_10/tdp_interface_list.yaml | 1 + .../tdp_v3_3_10/tdp_interface_risk_list.yaml | 1 + .../device/tdp_v3_3_10/tdp_log_search.yaml | 1 + .../tdp_v3_3_10/tdp_login_api_list.yaml | 1 + .../tdp_v3_3_10/tdp_login_weakpwd_list.yaml | 1 + .../tdp_v3_3_10/tdp_machine_asset_list.yaml | 1 + .../tdp_v3_3_10/tdp_mdr_alert_list.yaml | 1 + .../device/tdp_v3_3_10/tdp_pcap_download.yaml | 1 + .../tdp_v3_3_10/tdp_platform_config.yaml | 1 + .../tdp_v3_3_10/tdp_policy_settings.yaml | 1 + .../tdp_v3_3_10/tdp_privacy_diagram.yaml | 1 + .../device/tdp_v3_3_10/tdp_system_status.yaml | 1 + .../tdp_v3_3_10/tdp_threat_alert_host.yaml | 1 + .../tdp_threat_external_attack.yaml | 1 + .../tdp_threat_intelligent_aggregation.yaml | 1 + .../tdp_v3_3_10/tdp_threat_monitor_list.yaml | 1 + .../tdp_v3_3_10/tdp_vulnerability_list.yaml | 1 + .flocks/plugins/tools/mcp/nsfocus_mcp.yaml | 1 + .flocks/plugins/tools/mcp/qianxin_mcp.yaml | 1 + .flocks/plugins/tools/mcp/threatbook_mcp.yaml | 1 + .../loop_host_forensics_fast/meta.json | 1 + .../workflows/tdp_alert_triage/meta.json | 3 + flocks/agent/agent.py | 22 +- flocks/agent/agent_factory.py | 23 +- flocks/agent/agents/explore/agent.yaml | 1 + flocks/agent/agents/hephaestus/agent.yaml | 1 + flocks/agent/agents/librarian/agent.yaml | 1 + .../agent/agents/multimodal_looker/agent.yaml | 1 + flocks/agent/agents/oracle/agent.yaml | 1 + flocks/agent/agents/prometheus/agent.yaml | 1 + flocks/agent/agents/rex/agent.yaml | 1 + flocks/agent/agents/rex_junior/agent.yaml | 1 + flocks/agent/agents/self_improve/agent.yaml | 1 + flocks/agent/registry.py | 59 ++- flocks/config/config.py | 38 +- flocks/config/config_writer.py | 87 +++- flocks/hub/installer.py | 172 ++++++- flocks/mcp/catalog.py | 21 +- flocks/mcp/types.py | 22 +- flocks/project/instance.py | 13 +- flocks/server/routes/agent.py | 85 +++- flocks/server/routes/config.py | 43 ++ flocks/server/routes/mcp.py | 98 +++- flocks/server/routes/provider.py | 121 ++++- flocks/server/routes/skill.py | 115 ++++- flocks/server/routes/tool.py | 159 +++++-- flocks/server/routes/workflow.py | 169 +++++-- flocks/skill/installer.py | 64 ++- flocks/skill/skill.py | 173 +++++-- flocks/tool/agent/delegate_task.py | 1 + flocks/tool/agent/task.py | 1 + flocks/tool/channel/channel_message.py | 1 + flocks/tool/channel/im_send_message.py | 1 + flocks/tool/code/bash.py | 1 + flocks/tool/code/grep.py | 1 + flocks/tool/code/lsp_tool.py | 1 + flocks/tool/device/intake.py | 11 + flocks/tool/device/manage_tool.py | 1 + flocks/tool/device/models.py | 33 +- flocks/tool/device/plugin_index.py | 10 +- flocks/tool/device/store.py | 28 +- flocks/tool/file/apply_patch.py | 1 + flocks/tool/file/copy.py | 1 + flocks/tool/file/delete.py | 1 + flocks/tool/file/doc_parser.py | 1 + flocks/tool/file/edit.py | 1 + flocks/tool/file/glob.py | 1 + flocks/tool/file/mkdir.py | 1 + flocks/tool/file/move.py | 1 + flocks/tool/file/read.py | 1 + flocks/tool/file/write.py | 1 + flocks/tool/registry.py | 230 +++++++--- flocks/tool/schema/api_service_schema.py | 41 ++ flocks/tool/security/ssh_host_cmd.py | 1 + flocks/tool/security/ssh_run_script.py | 1 + flocks/tool/skill/flocks_skills.py | 1 + flocks/tool/skill/skill_load.py | 1 + flocks/tool/system/flocks_mcp.py | 1 + flocks/tool/system/memory.py | 1 + flocks/tool/system/model_config.py | 3 + flocks/tool/system/plan_exit.py | 1 + flocks/tool/system/question.py | 1 + flocks/tool/system/session_manage.py | 1 + flocks/tool/system/slash_command.py | 1 + flocks/tool/system/tool_search.py | 1 + flocks/tool/task/run_workflow.py | 1 + flocks/tool/task/run_workflow_node.py | 1 + flocks/tool/task/schedule_task_center.py | 1 + flocks/tool/task/todo.py | 1 + flocks/tool/task/workflow_config_manage.py | 1 + flocks/tool/tool_loader.py | 104 ++++- flocks/tool/web/webfetch.py | 1 + flocks/tool/web/websearch.py | 1 + flocks/tool/wecom/wecom_mcp.py | 1 + flocks/workflow/fs_store.py | 117 ++++- tests/agent/test_agent.py | 94 ++-- tests/agent/test_agent_factory.py | 94 +++- tests/agent/test_agent_metadata.py | 31 +- tests/config/test_config.py | 66 +++ tests/config/test_config_writer.py | 136 ++++++ tests/hub/test_hub_catalog.py | 29 +- tests/hub/test_native_group_metadata.py | 261 +++++++++++ tests/mcp/test_mcp_catalog.py | 24 + tests/plugin/test_builtin_group_metadata.py | 240 ++++++++++ tests/provider/test_api_service_management.py | 189 ++++++++ tests/server/routes/test_agent_routes.py | 371 +++++++++++++++ tests/server/routes/test_mcp_routes.py | 177 +++++++ tests/server/routes/test_remaining_routes.py | 109 ++++- tests/server/routes/test_skill_routes.py | 254 ++++++++++ .../routes/test_workflow_routes_visibility.py | 353 ++++++++++++++ .../server/routes/test_workflow_run_route.py | 6 +- tests/server/test_tool_setting_routes.py | 201 ++++++++ tests/skill/test_installer.py | 139 +++++- tests/skill/test_skill.py | 37 +- tests/tool/test_api_service_schema.py | 13 + tests/tool/test_apply_tool_settings.py | 174 +++++++ tests/tool/test_device_group_attribute.py | 152 ++++++ tests/tool/test_device_plugin_index.py | 34 ++ tests/tool/test_device_schema_migration.py | 40 ++ tests/workflow/test_fs_store.py | 101 ++++ webui/src/api/agent.ts | 4 + webui/src/api/device.ts | 5 + webui/src/api/provider.ts | 2 +- webui/src/api/skill.ts | 9 + webui/src/api/tool.test.ts | 65 ++- webui/src/api/tool.ts | 18 +- webui/src/api/workflow.ts | 5 + .../plugin-groups/GroupNav.test.tsx | 253 ++++++++++ .../src/components/plugin-groups/GroupNav.tsx | 323 +++++++++++++ .../plugin-groups/PluginViewToggle.test.tsx | 35 ++ .../plugin-groups/PluginViewToggle.tsx | 27 ++ .../plugin-groups/groupView.test.ts | 79 ++++ .../src/components/plugin-groups/groupView.ts | 100 ++++ webui/src/hooks/useAgents.ts | 4 +- webui/src/hooks/usePluginViewMode.test.tsx | 52 +++ webui/src/hooks/usePluginViewMode.ts | 32 ++ webui/src/hooks/useTools.ts | 5 +- webui/src/hooks/useWorkflow.test.tsx | 5 +- webui/src/hooks/useWorkflow.ts | 5 +- webui/src/i18nResources.ts | 3 + webui/src/locales/en-US/pluginGroups.json | 61 +++ webui/src/locales/zh-CN/pluginGroups.json | 61 +++ webui/src/pages/Agent/index.test.tsx | 179 +++++++- webui/src/pages/Agent/index.tsx | 115 +++-- .../pages/DeviceIntegration/index.test.tsx | 91 +++- webui/src/pages/DeviceIntegration/index.tsx | 124 +++-- webui/src/pages/Home/index.test.tsx | 68 ++- webui/src/pages/Skill/SkillSheet.test.tsx | 13 +- webui/src/pages/Skill/SkillSheet.tsx | 10 +- webui/src/pages/Skill/index.test.tsx | 172 ++++++- webui/src/pages/Skill/index.tsx | 155 ++++++- .../src/pages/Tool/ToolDetailDrawer.test.tsx | 2 + webui/src/pages/Tool/ToolGroups.test.tsx | 432 ++++++++++++++++++ .../pages/Tool/ToolPageAutoDisable.test.tsx | 1 + .../Tool/components/APITabContent.test.tsx | 143 +++++- .../pages/Tool/components/APITabContent.tsx | 94 +++- .../pages/Tool/components/LocalTabContent.tsx | 12 +- .../Tool/components/MCPTabContent.test.tsx | 105 ++++- .../pages/Tool/components/MCPTabContent.tsx | 109 +++-- webui/src/pages/Tool/index.tsx | 138 +++++- webui/src/pages/Workflow/index.test.tsx | 113 ++++- webui/src/pages/Workflow/index.tsx | 81 +++- webui/src/types/index.ts | 14 + 310 files changed, 8349 insertions(+), 589 deletions(-) create mode 100644 .flocks/plugins/workflows/tdp_alert_triage/meta.json create mode 100644 tests/hub/test_native_group_metadata.py create mode 100644 tests/plugin/test_builtin_group_metadata.py create mode 100644 tests/tool/test_device_group_attribute.py create mode 100644 webui/src/components/plugin-groups/GroupNav.test.tsx create mode 100644 webui/src/components/plugin-groups/GroupNav.tsx create mode 100644 webui/src/components/plugin-groups/PluginViewToggle.test.tsx create mode 100644 webui/src/components/plugin-groups/PluginViewToggle.tsx create mode 100644 webui/src/components/plugin-groups/groupView.test.ts create mode 100644 webui/src/components/plugin-groups/groupView.ts create mode 100644 webui/src/hooks/usePluginViewMode.test.tsx create mode 100644 webui/src/hooks/usePluginViewMode.ts create mode 100644 webui/src/locales/en-US/pluginGroups.json create mode 100644 webui/src/locales/zh-CN/pluginGroups.json create mode 100644 webui/src/pages/Tool/ToolGroups.test.tsx diff --git a/.flocks/mcp_list.json.example b/.flocks/mcp_list.json.example index 62dc48a3e..4c27b3438 100644 --- a/.flocks/mcp_list.json.example +++ b/.flocks/mcp_list.json.example @@ -66,6 +66,7 @@ "servers": [ { "id": "threatbook_mcp", + "group": "威胁情报", "name": "ThreatBook MCP", "description": "ThreatBook threat intelligence MCP service for IOC lookups, threat analysis, and security investigation workflows.", "category": "threat_intelligence", @@ -101,6 +102,7 @@ }, { "id": "nsfocus_mcp", + "group": "威胁情报", "name": "NSFOCUS MCP", "description": "NSFOCUS threat analysis MCP service for security capability access, intelligence queries, and analyst-assisted investigation workflows.", "category": "threat_intelligence", @@ -136,6 +138,7 @@ }, { "id": "qianxin_mcp", + "group": "威胁情报", "name": "QiAnXin MCP", "description": "QiAnXin threat intelligence MCP service for intelligence lookups, threat analysis, and security operations support.", "category": "threat_intelligence", @@ -171,6 +174,7 @@ }, { "id": "panther", + "group": "SIEM 与日志分析", "name": "Panther SIEM", "description": "Panther SIEM platform integration for writing detection rules with natural language, querying logs, and managing alerts.", "category": "siem", @@ -208,6 +212,7 @@ }, { "id": "secops", + "group": "安全运营", "name": "SecOps Toolbox", "description": "All-in-one security testing toolkit integrating multiple open-source security tools for penetration testing, vulnerability scanning, and threat hunting.", "category": "secops_platform", @@ -233,6 +238,7 @@ }, { "id": "tenzir", + "group": "数据处理", "name": "Tenzir", "description": "Tenzir security data pipeline engine integration with TQL queries, OCSF integration, and automated parser generation.", "category": "data_pipeline", @@ -263,6 +269,7 @@ }, { "id": "cybersec_watchdog", + "group": "合规", "name": "CyberSec Watchdog", "description": "Comprehensive Linux server security audit with 89 CIS benchmark controls and NIST 800-53 / PCI-DSS compliance checks.", "category": "compliance", @@ -289,6 +296,7 @@ }, { "id": "npm_audit", + "group": "代码安全", "name": "NPM Security Audit", "description": "NPM package dependency security audit using the remote npm registry for real-time security checks.", "category": "code_security", @@ -316,6 +324,7 @@ }, { "id": "gridinsoft", + "group": "威胁情报", "name": "GridinSoft Inspector", "description": "Domain and URL security analysis for verifying website and link safety.", "category": "threat_intelligence", @@ -342,6 +351,7 @@ }, { "id": "chimera_csl", + "group": "合规", "name": "Chimera CSL (Z3 Verification)", "description": "Deterministic AI security policy engine using Z3 formal verification to write and enforce machine-verifiable AI constraints.", "category": "compliance", @@ -367,6 +377,7 @@ }, { "id": "aim_guard", + "group": "安全运营", "name": "AIM Guard", "description": "Security-oriented MCP server providing security guidelines and content analysis for AI agents.", "category": "secops_platform", diff --git a/.flocks/plugins/agents/asset-survey/agent.yaml b/.flocks/plugins/agents/asset-survey/agent.yaml index affe426df..e927eb04a 100644 --- a/.flocks/plugins/agents/asset-survey/agent.yaml +++ b/.flocks/plugins/agents/asset-survey/agent.yaml @@ -1,4 +1,5 @@ name: asset-survey +group: 威胁情报 name_cn: 互联网资产测绘智能体 description: >- Internet asset survey and reconnaissance agent. Translates natural language queries into diff --git a/.flocks/plugins/agents/device-inspector/agent.yaml b/.flocks/plugins/agents/device-inspector/agent.yaml index e5acd07ed..bff0b452d 100644 --- a/.flocks/plugins/agents/device-inspector/agent.yaml +++ b/.flocks/plugins/agents/device-inspector/agent.yaml @@ -1,4 +1,5 @@ name: device-inspector +group: 平台集成 name_cn: 设备巡检智能体 description: >- Generic device inspection agent for connected security devices. Discovers enabled diff --git a/.flocks/plugins/agents/host-forensics-fast/agent.yaml b/.flocks/plugins/agents/host-forensics-fast/agent.yaml index 13ed2dc9e..a80926243 100644 --- a/.flocks/plugins/agents/host-forensics-fast/agent.yaml +++ b/.flocks/plugins/agents/host-forensics-fast/agent.yaml @@ -1,4 +1,5 @@ name: host-forensics-fast +group: 安全研判 name_cn: 主机快速排查智能体 description: >- Fast Linux host compromise triage subagent for first-pass investigation. Use when the user diff --git a/.flocks/plugins/agents/host-forensics/agent.yaml b/.flocks/plugins/agents/host-forensics/agent.yaml index 2e171dbf0..828efcc92 100644 --- a/.flocks/plugins/agents/host-forensics/agent.yaml +++ b/.flocks/plugins/agents/host-forensics/agent.yaml @@ -1,4 +1,5 @@ name: host-forensics +group: 安全研判 name_cn: 主机取证智能体 description: >- Linux host compromise detection and forensics subagent. Use when the user asks to inspect, diff --git a/.flocks/plugins/agents/hrti_threat_intelligence/agent.yaml b/.flocks/plugins/agents/hrti_threat_intelligence/agent.yaml index 4e59354be..cfae6cbd3 100644 --- a/.flocks/plugins/agents/hrti_threat_intelligence/agent.yaml +++ b/.flocks/plugins/agents/hrti_threat_intelligence/agent.yaml @@ -1,4 +1,5 @@ name: hrti-threat-intelligence +group: 威胁情报 name_cn: 热点威胁情报智能体 description: >- Situational threat intelligence subagent for querying and analyzing trending cybersecurity events. diff --git a/.flocks/plugins/agents/ndr-analyst/agent.yaml b/.flocks/plugins/agents/ndr-analyst/agent.yaml index fa3e24226..3f9ac93cf 100644 --- a/.flocks/plugins/agents/ndr-analyst/agent.yaml +++ b/.flocks/plugins/agents/ndr-analyst/agent.yaml @@ -1,4 +1,5 @@ name: ndr-analyst +group: 安全研判 name_cn: NDR 分析智能体 description: >- NDR network traffic analyst: analyzes flow logs and alerts, identifies attack techniques, diff --git a/.flocks/plugins/agents/phishing-detector/agent.yaml b/.flocks/plugins/agents/phishing-detector/agent.yaml index 386de253f..8d913fcbb 100644 --- a/.flocks/plugins/agents/phishing-detector/agent.yaml +++ b/.flocks/plugins/agents/phishing-detector/agent.yaml @@ -1,4 +1,5 @@ name: phishing-detector +group: 安全研判 name_cn: 钓鱼邮件检测智能体 description: > Specialized agent for detecting and analyzing phishing emails, extracting IOCs, and assessing threat level. diff --git a/.flocks/plugins/agents/ti-analyst/agent.yaml b/.flocks/plugins/agents/ti-analyst/agent.yaml index 39913ee4f..4d0b3e5ad 100644 --- a/.flocks/plugins/agents/ti-analyst/agent.yaml +++ b/.flocks/plugins/agents/ti-analyst/agent.yaml @@ -1,4 +1,5 @@ name: ti-analyst +group: 威胁情报 name_cn: 威胁情报智能体 description: >- Threat intelligence analyst subagent for IOC analysis, attribution, and threat actor research. diff --git a/.flocks/plugins/agents/vul_threat_intelligence/agent.yaml b/.flocks/plugins/agents/vul_threat_intelligence/agent.yaml index 649e37440..4962b8ebe 100644 --- a/.flocks/plugins/agents/vul_threat_intelligence/agent.yaml +++ b/.flocks/plugins/agents/vul_threat_intelligence/agent.yaml @@ -1,4 +1,5 @@ name: vul-threat-intelligence +group: 威胁情报 name_cn: 漏洞情报智能体 description: >- Vulnerability threat intelligence subagent for querying and analyzing vulnerability data. diff --git a/.flocks/plugins/skills/agent-builder/SKILL.md b/.flocks/plugins/skills/agent-builder/SKILL.md index 252d0b642..01dbb84f6 100644 --- a/.flocks/plugins/skills/agent-builder/SKILL.md +++ b/.flocks/plugins/skills/agent-builder/SKILL.md @@ -1,5 +1,6 @@ --- name: agent-builder +group: 系统辅助 category: system description: Create new sub-agents (subagents) by generating YAML config and prompt files in ~/.flocks/plugins/agents/. The created agent can be delegated to by Rex via delegate_task. Use when the user asks to create, add, or generate a new agent. --- diff --git a/.flocks/plugins/skills/browser-use/SKILL.md b/.flocks/plugins/skills/browser-use/SKILL.md index 819598af5..f298f9265 100644 --- a/.flocks/plugins/skills/browser-use/SKILL.md +++ b/.flocks/plugins/skills/browser-use/SKILL.md @@ -1,5 +1,6 @@ --- name: browser-use +group: 系统辅助 description: 统一处理浏览器使用任务,支持可见浏览器 CDP 直连、专用 headless CDP、agent-browser。Use when the user asks to browse websites, interact with pages, fill forms, capture screenshots, reuse an existing Chrome/Chromium/Edge login session, work with an already-open browser/sidebar browser, access login-only/internal/dynamic pages, or automate browser actions. --- diff --git a/.flocks/plugins/skills/device-integration-guide/SKILL.md b/.flocks/plugins/skills/device-integration-guide/SKILL.md index 6ec0a2035..de95b2429 100644 --- a/.flocks/plugins/skills/device-integration-guide/SKILL.md +++ b/.flocks/plugins/skills/device-integration-guide/SKILL.md @@ -1,5 +1,6 @@ --- name: device-integration-guide +group: 平台集成 description: 指导 Flocks 新建、添加和接入安全设备。Use when the user asks to create, add, onboard, or connect a new security device. --- diff --git a/.flocks/plugins/skills/ndr-alert-analysis/SKILL.md b/.flocks/plugins/skills/ndr-alert-analysis/SKILL.md index 2171e2f77..0fbc531b0 100644 --- a/.flocks/plugins/skills/ndr-alert-analysis/SKILL.md +++ b/.flocks/plugins/skills/ndr-alert-analysis/SKILL.md @@ -1,5 +1,6 @@ --- name: ndr-alert-analysis +group: 安全研判 description: 用于 NDR(网络检测与响应)告警的自动化研判分析,按照标准流程进行情报查询、资产关联、漏洞分析、payload 解析和综合研判。 --- diff --git a/.flocks/plugins/skills/onboarding/SKILL.md b/.flocks/plugins/skills/onboarding/SKILL.md index a779fba22..3292095d3 100644 --- a/.flocks/plugins/skills/onboarding/SKILL.md +++ b/.flocks/plugins/skills/onboarding/SKILL.md @@ -1,5 +1,6 @@ --- name: onboarding +group: 系统辅助 category: system description: Guide new users through the complete Flocks setup process via conversation — covering ThreatBook MCP/API, security tool APIs, IM channels, and scenario demos. Also runs system health inspection for returning users. Trigger when the user sends "请启动新手引导流程", "start onboarding", or any similar request to begin setup/configuration. --- diff --git a/.flocks/plugins/skills/onesec-use/SKILL.md b/.flocks/plugins/skills/onesec-use/SKILL.md index 2a62caa70..6b85a23bf 100644 --- a/.flocks/plugins/skills/onesec-use/SKILL.md +++ b/.flocks/plugins/skills/onesec-use/SKILL.md @@ -1,5 +1,6 @@ --- name: onesec-use +group: 平台集成 description: 用于处理 OneSEC/OneDNS 终端安全平台相关任务,支持通过API或者结合浏览器进行操作。只要用户提到 OneSEC、微步 EDR等相关操纵需求时,必须先加载本 skill。本 skill 是 OneSEC 平台操作的唯一决策入口:在未阅读本 skill 并完成模式判断前,不要直接调用任何 `onesec_*` tool。 --- diff --git a/.flocks/plugins/skills/onesig-use/SKILL.md b/.flocks/plugins/skills/onesig-use/SKILL.md index 1933afd52..3808620ce 100644 --- a/.flocks/plugins/skills/onesig-use/SKILL.md +++ b/.flocks/plugins/skills/onesig-use/SKILL.md @@ -1,5 +1,6 @@ --- name: onesig-use +group: 平台集成 description: 用于处理 OneSIG(安全互联网网关 / Secure Internet Gateway)相关任务,支持通过API或者结合浏览器进行操作。只要用户提到 OneSIG、SIG、微步互联网网关等相关操作时,必须先加载本 skill。本 skill 是 OneSIG 平台操作的唯一决策入口:在未阅读本 skill 并完成模式判断前,不要直接调用任何 `onesig_*` tool。 --- diff --git a/.flocks/plugins/skills/qingteng-use/SKILL.md b/.flocks/plugins/skills/qingteng-use/SKILL.md index 7d2c06e12..29013e706 100644 --- a/.flocks/plugins/skills/qingteng-use/SKILL.md +++ b/.flocks/plugins/skills/qingteng-use/SKILL.md @@ -1,5 +1,6 @@ --- name: qingteng-use +group: 平台集成 description: 用于处理青藤云安全平台相关任务,支持通过API或者结合浏览器进行操作。只要用户提到青藤、青藤云安全、青藤主机安全的相关操作时,必须先加载本 skill。本 skill 是 青藤 平台操作的唯一决策入口:在未阅读本 skill 并完成模式判断前,不要直接调用任何 `qingteng_*` tool。 --- diff --git a/.flocks/plugins/skills/sangfor-edr-use/SKILL.md b/.flocks/plugins/skills/sangfor-edr-use/SKILL.md index 7a01043da..38ce2fbfa 100644 --- a/.flocks/plugins/skills/sangfor-edr-use/SKILL.md +++ b/.flocks/plugins/skills/sangfor-edr-use/SKILL.md @@ -1,5 +1,6 @@ --- name: sangfor-edr-use +group: 平台集成 description: 深信服 EDR 登录态管理、首页仪表盘、威胁资产分析、资产清点和高级威胁 API 采集。用户提到深信服 EDR、EDR、资产清点、高级威胁或 sangfor EDR 时必须先加载本 skill。 --- diff --git a/.flocks/plugins/skills/sangfor-xdr-use/SKILL.md b/.flocks/plugins/skills/sangfor-xdr-use/SKILL.md index ff178452a..60c81e9aa 100644 --- a/.flocks/plugins/skills/sangfor-xdr-use/SKILL.md +++ b/.flocks/plugins/skills/sangfor-xdr-use/SKILL.md @@ -1,5 +1,6 @@ --- name: sangfor-xdr-use +group: 平台集成 description: 用于处理深信服 XDR(扩展检测与响应)相关任务,支持通过 API 或者结合浏览器操作。只要用户提到 深信服 XDR、XDR、sangfor XDR 等需求时,必须先加载本 skill。本 skill 是 XDR 平台操作的唯一决策入口:在未阅读本 skill 并完成模式判断前,不要直接调用任何 `sangfor_xdr_*` tool 或使用 browser-use skill。 --- diff --git a/.flocks/plugins/skills/skill-builder/SKILL.md b/.flocks/plugins/skills/skill-builder/SKILL.md index 0e8226412..5b6922ab0 100644 --- a/.flocks/plugins/skills/skill-builder/SKILL.md +++ b/.flocks/plugins/skills/skill-builder/SKILL.md @@ -1,5 +1,6 @@ --- name: skill-builder +group: 系统辅助 category: system description: Create or improve skill. Use when the user asks to create, add, generate, update, refactor, package, or test a skill, convert a repeated workflow into a reusable skill, write a `SKILL.md`, or add `references/`, `scripts/` for a skill. --- diff --git a/.flocks/plugins/skills/skyeye-sensor-use/SKILL.md b/.flocks/plugins/skills/skyeye-sensor-use/SKILL.md index dc2164abe..997553153 100644 --- a/.flocks/plugins/skills/skyeye-sensor-use/SKILL.md +++ b/.flocks/plugins/skills/skyeye-sensor-use/SKILL.md @@ -1,5 +1,6 @@ --- name: skyeye-sensor-use +group: 平台集成 description: 使用天眼 SkyEye Sensor 传感器侧精简 CLI 查询告警列表和告警统计。适用于用户提到"SkyEye Sensor""天眼流量传感器告警"场景。 --- diff --git a/.flocks/plugins/skills/skyeye-use/SKILL.md b/.flocks/plugins/skills/skyeye-use/SKILL.md index 4ca9227eb..707d1c792 100644 --- a/.flocks/plugins/skills/skyeye-use/SKILL.md +++ b/.flocks/plugins/skills/skyeye-use/SKILL.md @@ -1,5 +1,6 @@ --- name: skyeye-use +group: 平台集成 description: 用于处理 SkyEye/天眼/网神分析平台相关任务,支持通过API或者结合浏览器进行操作。只要用户提到 SkyEye、天眼、网神分析平台的相关操作时,必须先加载本 skill。本 skill 是 天眼 平台操作的唯一决策入口:在未阅读本 skill 并完成模式判断前,不要直接调用任何 `skyeye_*` tool。 --- diff --git a/.flocks/plugins/skills/tdp-use/SKILL.md b/.flocks/plugins/skills/tdp-use/SKILL.md index 30628edc4..b09044ad0 100644 --- a/.flocks/plugins/skills/tdp-use/SKILL.md +++ b/.flocks/plugins/skills/tdp-use/SKILL.md @@ -1,5 +1,6 @@ --- name: tdp-use +group: 平台集成 description: 用于处理 TDP 威胁检测平台相关任务,支持通过API或者结合浏览器进行操作。只要用户提到需要 打开/操作/获取/浏览 TDP、微步 NDR等需求时,必须先加载本 skill。本 skill 是 TDP 平台操作的唯一决策入口:在未阅读本 skill 并完成模式判断前,不要直接调用任何 `tdp_*` tool。 --- diff --git a/.flocks/plugins/skills/tool-builder/SKILL.md b/.flocks/plugins/skills/tool-builder/SKILL.md index 06f99ce8c..5fd6dc10b 100644 --- a/.flocks/plugins/skills/tool-builder/SKILL.md +++ b/.flocks/plugins/skills/tool-builder/SKILL.md @@ -1,5 +1,6 @@ --- name: tool-builder +group: 系统辅助 category: system description: Creates reusable Flocks tools and API integrations. Supports YAML-HTTP for REST APIs and Python for local utilities, with mandatory verification and smoke testing. All output under ~/.flocks/plugins/tools/. When to use: creating or adding a new Flocks tool, building local utilities such as base64 encode-decode, URL encode-decode, JSON formatting, parsing, hashing, text or file transformation, or integrating an external REST API as a reusable tool. Example requests: "Create a base64 encode/decode tool", "Build a URL encode/decode utility", "Add a JSON formatter tool", "Integrate a REST API as a Flocks tool". --- diff --git a/.flocks/plugins/skills/web2cli/SKILL.md b/.flocks/plugins/skills/web2cli/SKILL.md index 9d6b58a9e..bf493b668 100644 --- a/.flocks/plugins/skills/web2cli/SKILL.md +++ b/.flocks/plugins/skills/web2cli/SKILL.md @@ -1,5 +1,6 @@ --- name: web2cli +group: 系统辅助 description: 使用统一的 Web2CLI 流程捕获网站的 XHR/Fetch 请求,并生成可复用的 CLI、Markdown 文档。通过浏览器的 `cdp-direct` 模式复用用户 Chromium 系浏览器登录态与 CDP 能力。适用于复现登录后操作、沉淀接口调用样例,或基于页面操作生成自动化工具时。 required: browser-use --- diff --git a/.flocks/plugins/skills/webui-page-builder/SKILL.md b/.flocks/plugins/skills/webui-page-builder/SKILL.md index c2342dbf0..5b96cecc3 100644 --- a/.flocks/plugins/skills/webui-page-builder/SKILL.md +++ b/.flocks/plugins/skills/webui-page-builder/SKILL.md @@ -1,5 +1,6 @@ --- name: webui-page-builder +group: 系统辅助 category: system description: Guide users to create, develop, hide, or delete WebUI page plugins that appear in the WebUI left navigation under Home, with live preview and no restart required. Also guide development of page-scoped backend APIs through the WebUI Page Backend API Runtime when built-in APIs are insufficient. Trigger when the user asks to create, remove, or delete a WebUI contract page, WebUI page, dashboard, navigation tab, integrate custom APIs for a page, or sends messages such as "create a WebUI contract page", "delete WebUI contract page", "remove WebUI page", "创建WebUI 契约页面", "删除WebUI 契约页面", "用户WebUI 契约页面", "WebUI 契约页面", "左侧导航页面", "首页下面的页面", "页面数据来源", "自定义 API", or wants help understanding how WebUI contract pages work in Flocks. --- diff --git a/.flocks/plugins/skills/workflow-builder/SKILL.md b/.flocks/plugins/skills/workflow-builder/SKILL.md index ad3b62e09..124cd1a95 100644 --- a/.flocks/plugins/skills/workflow-builder/SKILL.md +++ b/.flocks/plugins/skills/workflow-builder/SKILL.md @@ -1,5 +1,6 @@ --- name: workflow-builder +group: 系统辅助 category: system description: 根据自然语言描述生成 flocks 内置工作流(workflow.md, workflow.json)。当用户提出创建/设计/生成/搭建工作流或任何多步骤流程(如告警调查、事件响应、SOP/Runbook 自动化)时使用本 skill。 --- diff --git a/.flocks/plugins/skills/workflow-config-guide/SKILL.md b/.flocks/plugins/skills/workflow-config-guide/SKILL.md index b745542bd..58a069b22 100644 --- a/.flocks/plugins/skills/workflow-config-guide/SKILL.md +++ b/.flocks/plugins/skills/workflow-config-guide/SKILL.md @@ -1,5 +1,6 @@ --- name: workflow-config-guide +group: 系统辅助 category: system ui_hidden: true description: 配置现有 Flocks 工作流的发布、集成、触发器和发布配置模板;本 skill 只定义交互协议,具体配置问题必须来自工作流目录内的 guide.md diff --git a/.flocks/plugins/tools/api/fofa/_provider.yaml b/.flocks/plugins/tools/api/fofa/_provider.yaml index 08903428f..c0b95d5c1 100644 --- a/.flocks/plugins/tools/api/fofa/_provider.yaml +++ b/.flocks/plugins/tools/api/fofa/_provider.yaml @@ -1,4 +1,5 @@ name: fofa +group: 网络测绘 description: FOFA network space mapping engine - search for internet assets, devices, and services description_cn: FOFA 网络空间测绘服务,用于检索互联网资产、设备与暴露服务。 auth: diff --git a/.flocks/plugins/tools/api/fofa/host.yaml b/.flocks/plugins/tools/api/fofa/host.yaml index 96b6210fc..0d0bfe842 100644 --- a/.flocks/plugins/tools/api/fofa/host.yaml +++ b/.flocks/plugins/tools/api/fofa/host.yaml @@ -1,4 +1,5 @@ name: fofa_host +group: 网络测绘 description: > Get detailed host information from FOFA including ports, services, banners, and certificates. Use when investigating a specific IP address for open ports and running services. diff --git a/.flocks/plugins/tools/api/fofa/info.yaml b/.flocks/plugins/tools/api/fofa/info.yaml index 470612727..c50442e3f 100644 --- a/.flocks/plugins/tools/api/fofa/info.yaml +++ b/.flocks/plugins/tools/api/fofa/info.yaml @@ -1,4 +1,5 @@ name: fofa_info +group: 网络测绘 description: > Get FOFA account information including user stats, VIP level, and API usage quota. Use to check account status and available API credits. diff --git a/.flocks/plugins/tools/api/fofa/search.yaml b/.flocks/plugins/tools/api/fofa/search.yaml index d40d41d43..da3115e62 100644 --- a/.flocks/plugins/tools/api/fofa/search.yaml +++ b/.flocks/plugins/tools/api/fofa/search.yaml @@ -1,4 +1,5 @@ name: fofa_search +group: 网络测绘 description: > Search FOFA network space for assets matching query syntax. Use for asset discovery, attack surface enumeration, and vulnerability assessment. diff --git a/.flocks/plugins/tools/api/fofa/stats.yaml b/.flocks/plugins/tools/api/fofa/stats.yaml index 5b03d4571..2268b7265 100644 --- a/.flocks/plugins/tools/api/fofa/stats.yaml +++ b/.flocks/plugins/tools/api/fofa/stats.yaml @@ -1,4 +1,5 @@ name: fofa_stats +group: 网络测绘 description: > Get statistical aggregation of FOFA search results - count by protocol, port, country, etc. Use for attack surface analysis and understanding asset distribution. diff --git a/.flocks/plugins/tools/api/greynoise/_provider.yaml b/.flocks/plugins/tools/api/greynoise/_provider.yaml index d91314bf9..36bdd0303 100644 --- a/.flocks/plugins/tools/api/greynoise/_provider.yaml +++ b/.flocks/plugins/tools/api/greynoise/_provider.yaml @@ -1,4 +1,5 @@ name: greynoise +group: 威胁情报 description: GreyNoise Threat Intelligence - Internet-wide scan and attack traffic analysis auth: secret: greynoise_api_key diff --git a/.flocks/plugins/tools/api/greynoise/community_ip_query.yaml b/.flocks/plugins/tools/api/greynoise/community_ip_query.yaml index e30c5561c..a6513a1f8 100644 --- a/.flocks/plugins/tools/api/greynoise/community_ip_query.yaml +++ b/.flocks/plugins/tools/api/greynoise/community_ip_query.yaml @@ -1,4 +1,5 @@ name: greynoise_community_ip_query +group: 威胁情报 description: > FREE GreyNoise Community API - Query IP threat intelligence for basic classification. Returns whether the IP is malicious/benign, RIOT status, and last seen date. diff --git a/.flocks/plugins/tools/api/greynoise/cve_lookup.yaml b/.flocks/plugins/tools/api/greynoise/cve_lookup.yaml index 5c01ab687..754c853d6 100644 --- a/.flocks/plugins/tools/api/greynoise/cve_lookup.yaml +++ b/.flocks/plugins/tools/api/greynoise/cve_lookup.yaml @@ -1,4 +1,5 @@ name: greynoise_cve_lookup +group: 威胁情报 description: > Query GreyNoise CVE database to find IPs associated with a specific CVE. Returns IPs that have been observed exploiting or targeting this vulnerability. diff --git a/.flocks/plugins/tools/api/greynoise/gnql_query.yaml b/.flocks/plugins/tools/api/greynoise/gnql_query.yaml index b632600df..fdd093014 100644 --- a/.flocks/plugins/tools/api/greynoise/gnql_query.yaml +++ b/.flocks/plugins/tools/api/greynoise/gnql_query.yaml @@ -1,4 +1,5 @@ name: greynoise_gnql_query +group: 威胁情报 description: > GreyNoise Query Language (GNQL) search. Use to search the GreyNoise NOISE dataset for IPs matching criteria like classification:malicious, tags:Mirai, cve:CVE-2021-xxx, diff --git a/.flocks/plugins/tools/api/greynoise/gnql_stats.yaml b/.flocks/plugins/tools/api/greynoise/gnql_stats.yaml index 080aca1fc..611d90515 100644 --- a/.flocks/plugins/tools/api/greynoise/gnql_stats.yaml +++ b/.flocks/plugins/tools/api/greynoise/gnql_stats.yaml @@ -1,4 +1,5 @@ name: greynoise_gnql_stats +group: 威胁情报 description: > GreyNoise Query Language (GNQL) statistics. Returns aggregate counts grouped by specified field (country, port, tag, classification, etc.). diff --git a/.flocks/plugins/tools/api/greynoise/greynoise_enterprise_ip_query.yaml b/.flocks/plugins/tools/api/greynoise/greynoise_enterprise_ip_query.yaml index 2bc01aca0..56d565bf5 100644 --- a/.flocks/plugins/tools/api/greynoise/greynoise_enterprise_ip_query.yaml +++ b/.flocks/plugins/tools/api/greynoise/greynoise_enterprise_ip_query.yaml @@ -1,4 +1,5 @@ name: greynoise_enterprise_ip_query +group: 威胁情报 description: > Query GreyNoise threat intelligence for an IP address to determine if it's associated with malicious scanning or attack activity. Returns classification diff --git a/.flocks/plugins/tools/api/greynoise/ip_timeline.yaml b/.flocks/plugins/tools/api/greynoise/ip_timeline.yaml index 474dd3c0e..bd3ec42a0 100644 --- a/.flocks/plugins/tools/api/greynoise/ip_timeline.yaml +++ b/.flocks/plugins/tools/api/greynoise/ip_timeline.yaml @@ -1,4 +1,5 @@ name: greynoise_ip_timeline +group: 威胁情报 description: > Get IP activity timeline showing when an IP was observed by GreyNoise sensors. Returns timestamps of scanning activity. Useful for understanding attack timing. diff --git a/.flocks/plugins/tools/api/greynoise/tags.yaml b/.flocks/plugins/tools/api/greynoise/tags.yaml index 52680e955..2f69c048b 100644 --- a/.flocks/plugins/tools/api/greynoise/tags.yaml +++ b/.flocks/plugins/tools/api/greynoise/tags.yaml @@ -1,4 +1,5 @@ name: greynoise_tags +group: 威胁情报 description: > List all available GreyNoise tags. Tags categorize threat actors, malware families, attack techniques, and scanner types. Useful for understanding what threats GreyNoise diff --git a/.flocks/plugins/tools/api/threatbook-cn/_provider.yaml b/.flocks/plugins/tools/api/threatbook-cn/_provider.yaml index 256d7fcad..7c16a8aee 100644 --- a/.flocks/plugins/tools/api/threatbook-cn/_provider.yaml +++ b/.flocks/plugins/tools/api/threatbook-cn/_provider.yaml @@ -1,4 +1,5 @@ name: threatbook-cn +group: 威胁情报 description: ThreatBook Threat Intelligence Platform description_cn: 微步在线威胁情报平台,提供文件样本分析与URL扫描能力 auth: diff --git a/.flocks/plugins/tools/api/threatbook-cn/threatbook_cn_file_upload.yaml b/.flocks/plugins/tools/api/threatbook-cn/threatbook_cn_file_upload.yaml index 144f06544..9e1bf82f2 100644 --- a/.flocks/plugins/tools/api/threatbook-cn/threatbook_cn_file_upload.yaml +++ b/.flocks/plugins/tools/api/threatbook-cn/threatbook_cn_file_upload.yaml @@ -1,4 +1,5 @@ name: threatbook_cn_file_upload +group: 威胁情报 description: > Submit a file to ThreatBook sandbox for malware analysis. Supports PE, Office documents, PDF, HTML, Script, MSI, SWF, JAR, LNK, ELF, diff --git a/.flocks/plugins/tools/api/threatbook-cn/threatbook_cn_url_scan.yaml b/.flocks/plugins/tools/api/threatbook-cn/threatbook_cn_url_scan.yaml index 82ff3708b..b9a2b7d84 100644 --- a/.flocks/plugins/tools/api/threatbook-cn/threatbook_cn_url_scan.yaml +++ b/.flocks/plugins/tools/api/threatbook-cn/threatbook_cn_url_scan.yaml @@ -1,4 +1,5 @@ name: threatbook_cn_url_scan +group: 威胁情报 description: > Submit a URL to ThreatBook for scanning by 11 URL scanning engines and URL/domain blacklists. Also analyzes files downloaded from the URL. Use when analyzing suspicious URLs from emails, diff --git a/.flocks/plugins/tools/api/threatbook-io/_provider.yaml b/.flocks/plugins/tools/api/threatbook-io/_provider.yaml index ab6e331e6..6f6126f37 100644 --- a/.flocks/plugins/tools/api/threatbook-io/_provider.yaml +++ b/.flocks/plugins/tools/api/threatbook-io/_provider.yaml @@ -1,4 +1,5 @@ name: threatbook-io +group: 威胁情报 description: ThreatBook IO Threat Intelligence Platform - Global edition providing IP, domain, URL, file hash intelligence description_cn: ThreatBook IO 威胁情报平台(全球版),提供 IP、域名、URL、文件哈希等威胁情报查询能力 auth: diff --git a/.flocks/plugins/tools/api/threatbook-io/threatbook_io_domain_query.yaml b/.flocks/plugins/tools/api/threatbook-io/threatbook_io_domain_query.yaml index eb26846b6..2f3b97dbf 100644 --- a/.flocks/plugins/tools/api/threatbook-io/threatbook_io_domain_query.yaml +++ b/.flocks/plugins/tools/api/threatbook-io/threatbook_io_domain_query.yaml @@ -1,4 +1,5 @@ name: threatbook_io_domain_query +group: 威胁情报 description: > Query domain threat intelligence including verdict, WHOIS, DNS records, SSL certificates, and associated malware samples. Use when analyzing diff --git a/.flocks/plugins/tools/api/threatbook-io/threatbook_io_file_query.yaml b/.flocks/plugins/tools/api/threatbook-io/threatbook_io_file_query.yaml index 4c95db0ac..eaa180565 100644 --- a/.flocks/plugins/tools/api/threatbook-io/threatbook_io_file_query.yaml +++ b/.flocks/plugins/tools/api/threatbook-io/threatbook_io_file_query.yaml @@ -1,4 +1,5 @@ name: threatbook_io_file_query +group: 威胁情报 description: > Query file hash threat intelligence including static/dynamic analysis reports, malware detection results, behavioral signatures, network behavior, and diff --git a/.flocks/plugins/tools/api/threatbook-io/threatbook_io_file_upload.yaml b/.flocks/plugins/tools/api/threatbook-io/threatbook_io_file_upload.yaml index 71410d356..11e886e66 100644 --- a/.flocks/plugins/tools/api/threatbook-io/threatbook_io_file_upload.yaml +++ b/.flocks/plugins/tools/api/threatbook-io/threatbook_io_file_upload.yaml @@ -1,4 +1,5 @@ name: threatbook_io_file_upload +group: 威胁情报 description: > Upload a file to ThreatBook sandbox for malware analysis. Supports PE, Office, PDF, HTML, scripts, and archive formats up to 100MB. Use when analyzing diff --git a/.flocks/plugins/tools/api/threatbook-io/threatbook_io_ip_query.yaml b/.flocks/plugins/tools/api/threatbook-io/threatbook_io_ip_query.yaml index e89d30545..f408ea98a 100644 --- a/.flocks/plugins/tools/api/threatbook-io/threatbook_io_ip_query.yaml +++ b/.flocks/plugins/tools/api/threatbook-io/threatbook_io_ip_query.yaml @@ -1,4 +1,5 @@ name: threatbook_io_ip_query +group: 威胁情报 description: > Query IP threat intelligence including reputation verdict, geolocation, threat types, and associated malware samples. Use when analyzing diff --git a/.flocks/plugins/tools/api/threatbook-io/threatbook_io_report_detail.yaml b/.flocks/plugins/tools/api/threatbook-io/threatbook_io_report_detail.yaml index bddcb0e7e..63564778b 100644 --- a/.flocks/plugins/tools/api/threatbook-io/threatbook_io_report_detail.yaml +++ b/.flocks/plugins/tools/api/threatbook-io/threatbook_io_report_detail.yaml @@ -1,4 +1,5 @@ name: threatbook_io_report_detail +group: 威胁情报 description: > Retrieve detailed threat intelligence report including severity, threat types, summary, tags, targeted industries/regions/organizations/products, impacts, ATT&CK techniques, diff --git a/.flocks/plugins/tools/api/threatbook-io/threatbook_io_report_list.yaml b/.flocks/plugins/tools/api/threatbook-io/threatbook_io_report_list.yaml index 65e2ad6b3..1a4858f17 100644 --- a/.flocks/plugins/tools/api/threatbook-io/threatbook_io_report_list.yaml +++ b/.flocks/plugins/tools/api/threatbook-io/threatbook_io_report_list.yaml @@ -1,4 +1,5 @@ name: threatbook_io_report_list +group: 威胁情报 description: > Query threat intelligence reports list with filters including threat type, severity, threat actor, target region/industry/organization, and time range. Use when searching diff --git a/.flocks/plugins/tools/api/threatbook-io/threatbook_io_url_query.yaml b/.flocks/plugins/tools/api/threatbook-io/threatbook_io_url_query.yaml index e47189d1a..08b60e529 100644 --- a/.flocks/plugins/tools/api/threatbook-io/threatbook_io_url_query.yaml +++ b/.flocks/plugins/tools/api/threatbook-io/threatbook_io_url_query.yaml @@ -1,4 +1,5 @@ name: threatbook_io_url_query +group: 威胁情报 description: > Query URL threat intelligence including scan engine detection results, sandbox analysis reports, and downloaded file analysis. Use when analyzing diff --git a/.flocks/plugins/tools/api/threatbook-io/threatbook_io_vulnerability_query.yaml b/.flocks/plugins/tools/api/threatbook-io/threatbook_io_vulnerability_query.yaml index 0f9216cc1..d7b7c65bc 100644 --- a/.flocks/plugins/tools/api/threatbook-io/threatbook_io_vulnerability_query.yaml +++ b/.flocks/plugins/tools/api/threatbook-io/threatbook_io_vulnerability_query.yaml @@ -1,4 +1,5 @@ name: threatbook_io_vulnerability_query +group: 威胁情报 description: > Query vulnerability intelligence including CVE details, risk assessments, PoC availability, remediation recommendations, and patch information. Use when researching vulnerabilities diff --git a/.flocks/plugins/tools/api/urlscan/_provider.yaml b/.flocks/plugins/tools/api/urlscan/_provider.yaml index 3cde8a763..e0e6a61a6 100644 --- a/.flocks/plugins/tools/api/urlscan/_provider.yaml +++ b/.flocks/plugins/tools/api/urlscan/_provider.yaml @@ -1,4 +1,5 @@ name: urlscan +group: 威胁情报 description: URLScan.io - Automated URL scanning and threat intelligence auth: secret: urlscan_api_key diff --git a/.flocks/plugins/tools/api/urlscan/available_countries.yaml b/.flocks/plugins/tools/api/urlscan/available_countries.yaml index 60988ad7a..ca1759985 100644 --- a/.flocks/plugins/tools/api/urlscan/available_countries.yaml +++ b/.flocks/plugins/tools/api/urlscan/available_countries.yaml @@ -1,4 +1,5 @@ name: urlscan_available_countries +group: 威胁情报 description: > Retrieve the list of countries available for scanning using the Scan API. Use this to check which geographic locations can be specified for scans. diff --git a/.flocks/plugins/tools/api/urlscan/dom.yaml b/.flocks/plugins/tools/api/urlscan/dom.yaml index a1774fbac..6afc4c121 100644 --- a/.flocks/plugins/tools/api/urlscan/dom.yaml +++ b/.flocks/plugins/tools/api/urlscan/dom.yaml @@ -1,4 +1,5 @@ name: urlscan_dom +group: 威胁情报 description: > Retrieve the DOM snapshot for a URLScan scan. Use the scan UUID to retrieve the DOM snapshot once the scan has finished. diff --git a/.flocks/plugins/tools/api/urlscan/quotas.yaml b/.flocks/plugins/tools/api/urlscan/quotas.yaml index 85d44ffe6..26fae89c5 100644 --- a/.flocks/plugins/tools/api/urlscan/quotas.yaml +++ b/.flocks/plugins/tools/api/urlscan/quotas.yaml @@ -1,4 +1,5 @@ name: urlscan_quotas +group: 威胁情报 description: > Get current API quota usage and limits for the authenticated user. Returns information about remaining scans, search quota, and rate limits. diff --git a/.flocks/plugins/tools/api/urlscan/result.yaml b/.flocks/plugins/tools/api/urlscan/result.yaml index 5d29b2707..841060d24 100644 --- a/.flocks/plugins/tools/api/urlscan/result.yaml +++ b/.flocks/plugins/tools/api/urlscan/result.yaml @@ -1,4 +1,5 @@ name: urlscan_result +group: 威胁情报 description: > Retrieve the scan result from URLScan.io using the UUID returned from a scan. Returns detailed information including page content, screenshots, DOM snapshot, diff --git a/.flocks/plugins/tools/api/urlscan/scan.yaml b/.flocks/plugins/tools/api/urlscan/scan.yaml index eb5a93eea..f593fa5e8 100644 --- a/.flocks/plugins/tools/api/urlscan/scan.yaml +++ b/.flocks/plugins/tools/api/urlscan/scan.yaml @@ -1,4 +1,5 @@ name: urlscan_scan +group: 威胁情报 description: > Submit a URL to URLScan.io for automated scanning. URLScan browses the URL like a regular user and records domains/IPs contacted, resources loaded, diff --git a/.flocks/plugins/tools/api/urlscan/screenshot.yaml b/.flocks/plugins/tools/api/urlscan/screenshot.yaml index f1da387ad..9565ad8a7 100644 --- a/.flocks/plugins/tools/api/urlscan/screenshot.yaml +++ b/.flocks/plugins/tools/api/urlscan/screenshot.yaml @@ -1,4 +1,5 @@ name: urlscan_screenshot +group: 威胁情报 description: > Retrieve the screenshot for a URLScan scan in PNG format. Use the scan UUID to retrieve the screenshot once the scan has finished. diff --git a/.flocks/plugins/tools/api/urlscan/search.yaml b/.flocks/plugins/tools/api/urlscan/search.yaml index 07020184d..9207dc215 100644 --- a/.flocks/plugins/tools/api/urlscan/search.yaml +++ b/.flocks/plugins/tools/api/urlscan/search.yaml @@ -1,4 +1,5 @@ name: urlscan_search +group: 威胁情报 description: > Search URLScan.io's historical scan database. Query by domain, IP, URL, filename, or other attributes to find previous scans. Useful for threat intelligence gathering, diff --git a/.flocks/plugins/tools/api/urlscan/user_agents.yaml b/.flocks/plugins/tools/api/urlscan/user_agents.yaml index 72b2e8739..ac8b6c69a 100644 --- a/.flocks/plugins/tools/api/urlscan/user_agents.yaml +++ b/.flocks/plugins/tools/api/urlscan/user_agents.yaml @@ -1,4 +1,5 @@ name: urlscan_user_agents +group: 威胁情报 description: > Get grouped user agents that can be used with the Scan API. Use this to see available browser/User-Agent options for custom scans. diff --git a/.flocks/plugins/tools/api/urlscan/username.yaml b/.flocks/plugins/tools/api/urlscan/username.yaml index 31d9c5dbf..44a5c6d39 100644 --- a/.flocks/plugins/tools/api/urlscan/username.yaml +++ b/.flocks/plugins/tools/api/urlscan/username.yaml @@ -1,4 +1,5 @@ name: urlscan_username +group: 威胁情报 description: > Get information about the current user or API key making the request. Returns account details and Pro status. Use to verify API key authentication. diff --git a/.flocks/plugins/tools/api/virustotal/_provider.yaml b/.flocks/plugins/tools/api/virustotal/_provider.yaml index 44efa3372..a082a2abd 100644 --- a/.flocks/plugins/tools/api/virustotal/_provider.yaml +++ b/.flocks/plugins/tools/api/virustotal/_provider.yaml @@ -1,4 +1,6 @@ name: VirusTotal +group: 威胁情报 +service_id: virustotal description: VirusTotal threat intelligence service for IP, domain, URL, file hash, and analysis lookups. description_cn: VirusTotal 威胁情报服务,提供 IP、域名、URL、文件哈希与分析结果查询能力。 auth: diff --git a/.flocks/plugins/tools/api/virustotal/virustotal_analysis_status.yaml b/.flocks/plugins/tools/api/virustotal/virustotal_analysis_status.yaml index 906ab093b..077546b78 100644 --- a/.flocks/plugins/tools/api/virustotal/virustotal_analysis_status.yaml +++ b/.flocks/plugins/tools/api/virustotal/virustotal_analysis_status.yaml @@ -1,4 +1,5 @@ name: virustotal_analysis_status +group: 威胁情报 description: Check the status of a file or URL analysis in VirusTotal. category: custom enabled: true diff --git a/.flocks/plugins/tools/api/virustotal/virustotal_domain_query.yaml b/.flocks/plugins/tools/api/virustotal/virustotal_domain_query.yaml index dd89b8016..278178de4 100644 --- a/.flocks/plugins/tools/api/virustotal/virustotal_domain_query.yaml +++ b/.flocks/plugins/tools/api/virustotal/virustotal_domain_query.yaml @@ -1,4 +1,5 @@ name: virustotal_domain_query +group: 威胁情报 description: Query domain threat intelligence from VirusTotal. category: custom enabled: true diff --git a/.flocks/plugins/tools/api/virustotal/virustotal_file_query.yaml b/.flocks/plugins/tools/api/virustotal/virustotal_file_query.yaml index 49e077f48..d8b3e657c 100644 --- a/.flocks/plugins/tools/api/virustotal/virustotal_file_query.yaml +++ b/.flocks/plugins/tools/api/virustotal/virustotal_file_query.yaml @@ -1,4 +1,5 @@ name: virustotal_file_query +group: 威胁情报 description: Query file hash threat intelligence from VirusTotal. category: custom enabled: true diff --git a/.flocks/plugins/tools/api/virustotal/virustotal_file_scan.yaml b/.flocks/plugins/tools/api/virustotal/virustotal_file_scan.yaml index 7a2de34c2..039bd5c0a 100644 --- a/.flocks/plugins/tools/api/virustotal/virustotal_file_scan.yaml +++ b/.flocks/plugins/tools/api/virustotal/virustotal_file_scan.yaml @@ -1,4 +1,5 @@ name: virustotal_file_scan +group: 威胁情报 description: Upload a file to VirusTotal for sandbox analysis. category: custom enabled: true diff --git a/.flocks/plugins/tools/api/virustotal/virustotal_ip_query.yaml b/.flocks/plugins/tools/api/virustotal/virustotal_ip_query.yaml index 3f043380f..a47d8bf02 100644 --- a/.flocks/plugins/tools/api/virustotal/virustotal_ip_query.yaml +++ b/.flocks/plugins/tools/api/virustotal/virustotal_ip_query.yaml @@ -1,4 +1,5 @@ name: virustotal_ip_query +group: 威胁情报 description: Query IP address threat intelligence from VirusTotal. category: custom enabled: true diff --git a/.flocks/plugins/tools/api/virustotal/virustotal_url_query.yaml b/.flocks/plugins/tools/api/virustotal/virustotal_url_query.yaml index 3dda17148..46805856f 100644 --- a/.flocks/plugins/tools/api/virustotal/virustotal_url_query.yaml +++ b/.flocks/plugins/tools/api/virustotal/virustotal_url_query.yaml @@ -1,4 +1,5 @@ name: virustotal_url_query +group: 威胁情报 description: Query URL threat intelligence from VirusTotal. category: custom enabled: true diff --git a/.flocks/plugins/tools/api/virustotal/virustotal_url_scan.yaml b/.flocks/plugins/tools/api/virustotal/virustotal_url_scan.yaml index a5d55330c..c7cc63ecd 100644 --- a/.flocks/plugins/tools/api/virustotal/virustotal_url_scan.yaml +++ b/.flocks/plugins/tools/api/virustotal/virustotal_url_scan.yaml @@ -1,4 +1,5 @@ name: virustotal_url_scan +group: 威胁情报 description: Submit a URL to VirusTotal for scanning. category: custom enabled: true diff --git a/.flocks/plugins/tools/device/ngsoc_v4_15_1/_provider.yaml b/.flocks/plugins/tools/device/ngsoc_v4_15_1/_provider.yaml index 4c518b5ac..da4b70ae1 100644 --- a/.flocks/plugins/tools/device/ngsoc_v4_15_1/_provider.yaml +++ b/.flocks/plugins/tools/device/ngsoc_v4_15_1/_provider.yaml @@ -1,4 +1,5 @@ name: ngsoc +group: SIEM vendor: qianxin service_id: ngsoc_api version: "4.15.1" diff --git a/.flocks/plugins/tools/device/ngsoc_v4_15_1/ngsoc_alarms.yaml b/.flocks/plugins/tools/device/ngsoc_v4_15_1/ngsoc_alarms.yaml index 24635290b..84f27f9f0 100644 --- a/.flocks/plugins/tools/device/ngsoc_v4_15_1/ngsoc_alarms.yaml +++ b/.flocks/plugins/tools/device/ngsoc_v4_15_1/ngsoc_alarms.yaml @@ -1,4 +1,5 @@ name: ngsoc_alarms +group: SIEM description: > NGSOC threat-alarm grouped tool. Use the `action` parameter to query alarm lists, fetch alarm details, change alarm dispose state, request diff --git a/.flocks/plugins/tools/device/ngsoc_v4_15_1/ngsoc_assets.yaml b/.flocks/plugins/tools/device/ngsoc_v4_15_1/ngsoc_assets.yaml index 9edc29735..ddfc97894 100644 --- a/.flocks/plugins/tools/device/ngsoc_v4_15_1/ngsoc_assets.yaml +++ b/.flocks/plugins/tools/device/ngsoc_v4_15_1/ngsoc_assets.yaml @@ -1,4 +1,5 @@ name: ngsoc_assets +group: SIEM description: > NGSOC asset center grouped tool. Use the `action` parameter to fetch per-asset detail, list asset-IDs grouped by asset-group, and traverse diff --git a/.flocks/plugins/tools/device/ngsoc_v4_15_1/ngsoc_bigscreens.yaml b/.flocks/plugins/tools/device/ngsoc_v4_15_1/ngsoc_bigscreens.yaml index 8276b4fbb..c82628b5d 100644 --- a/.flocks/plugins/tools/device/ngsoc_v4_15_1/ngsoc_bigscreens.yaml +++ b/.flocks/plugins/tools/device/ngsoc_v4_15_1/ngsoc_bigscreens.yaml @@ -1,4 +1,5 @@ name: ngsoc_bigscreens +group: SIEM description: > NGSOC situational-dashboard grouped tool. Use the `action` parameter to fetch the dashboard widgets the executive screens consume: TOP-N diff --git a/.flocks/plugins/tools/device/ngsoc_v4_15_1/ngsoc_risks.yaml b/.flocks/plugins/tools/device/ngsoc_v4_15_1/ngsoc_risks.yaml index 7ad9a3f4f..33bbdc5b9 100644 --- a/.flocks/plugins/tools/device/ngsoc_v4_15_1/ngsoc_risks.yaml +++ b/.flocks/plugins/tools/device/ngsoc_v4_15_1/ngsoc_risks.yaml @@ -1,4 +1,5 @@ name: ngsoc_risks +group: SIEM description: > NGSOC risk management grouped tool. Use the `action` parameter to fetch the asset risk list and per-asset risk-score detail under `/risks/*` diff --git a/.flocks/plugins/tools/device/ngsoc_v4_15_1/ngsoc_storage.yaml b/.flocks/plugins/tools/device/ngsoc_v4_15_1/ngsoc_storage.yaml index e97449d3d..04a215ca3 100644 --- a/.flocks/plugins/tools/device/ngsoc_v4_15_1/ngsoc_storage.yaml +++ b/.flocks/plugins/tools/device/ngsoc_v4_15_1/ngsoc_storage.yaml @@ -1,4 +1,5 @@ name: ngsoc_storage +group: SIEM description: > NGSOC storage management grouped tool. Use the `action` parameter to download files (PCAP, evidence packs, exported reports) generated by diff --git a/.flocks/plugins/tools/device/ngsoc_v4_15_1/ngsoc_users.yaml b/.flocks/plugins/tools/device/ngsoc_v4_15_1/ngsoc_users.yaml index 8fec4703f..dd34b3158 100644 --- a/.flocks/plugins/tools/device/ngsoc_v4_15_1/ngsoc_users.yaml +++ b/.flocks/plugins/tools/device/ngsoc_v4_15_1/ngsoc_users.yaml @@ -1,4 +1,5 @@ name: ngsoc_users +group: SIEM description: > NGSOC user management grouped tool. Use the `action` parameter to fetch the username (nickname) directory used elsewhere as audit / dispatch diff --git a/.flocks/plugins/tools/device/ngsoc_v4_15_1/ngsoc_vuls.yaml b/.flocks/plugins/tools/device/ngsoc_v4_15_1/ngsoc_vuls.yaml index 4721ff8ff..e38e6eabc 100644 --- a/.flocks/plugins/tools/device/ngsoc_v4_15_1/ngsoc_vuls.yaml +++ b/.flocks/plugins/tools/device/ngsoc_v4_15_1/ngsoc_vuls.yaml @@ -1,4 +1,5 @@ name: ngsoc_vuls +group: SIEM description: > NGSOC vulnerability management grouped tool. Use the `action` parameter to fetch per-asset vulnerabilities, per-asset configuration-check diff --git a/.flocks/plugins/tools/device/ngsoc_v4_15_1/ngsoc_workorders.yaml b/.flocks/plugins/tools/device/ngsoc_v4_15_1/ngsoc_workorders.yaml index 6b674a71f..6e8dc7fef 100644 --- a/.flocks/plugins/tools/device/ngsoc_v4_15_1/ngsoc_workorders.yaml +++ b/.flocks/plugins/tools/device/ngsoc_v4_15_1/ngsoc_workorders.yaml @@ -1,4 +1,5 @@ name: ngsoc_workorders +group: SIEM description: > NGSOC work-order grouped tool. Use the `action` parameter to update work-order status, list work-orders with rich filters / pagination, diff --git a/.flocks/plugins/tools/device/ngtip_v5_1_5/_provider.yaml b/.flocks/plugins/tools/device/ngtip_v5_1_5/_provider.yaml index 03b92cfa8..260c611b8 100644 --- a/.flocks/plugins/tools/device/ngtip_v5_1_5/_provider.yaml +++ b/.flocks/plugins/tools/device/ngtip_v5_1_5/_provider.yaml @@ -1,4 +1,5 @@ name: ngtip +group: 威胁情报 vendor: threatbook service_id: ngtip_api version: "5.1.5" diff --git a/.flocks/plugins/tools/device/ngtip_v5_1_5/ngtip_platform.yaml b/.flocks/plugins/tools/device/ngtip_v5_1_5/ngtip_platform.yaml index 0815140b3..7443ebf99 100644 --- a/.flocks/plugins/tools/device/ngtip_v5_1_5/ngtip_platform.yaml +++ b/.flocks/plugins/tools/device/ngtip_v5_1_5/ngtip_platform.yaml @@ -1,4 +1,5 @@ name: ngtip_platform +group: 威胁情报 description: > NGTIP Platform Function grouped tool. Use the `action` parameter to access intelligence management, asset management, user management, intelligence diff --git a/.flocks/plugins/tools/device/ngtip_v5_1_5/ngtip_query.yaml b/.flocks/plugins/tools/device/ngtip_v5_1_5/ngtip_query.yaml index 69b91cfa7..63cdda80a 100644 --- a/.flocks/plugins/tools/device/ngtip_v5_1_5/ngtip_query.yaml +++ b/.flocks/plugins/tools/device/ngtip_v5_1_5/ngtip_query.yaml @@ -1,4 +1,5 @@ name: ngtip_query +group: 威胁情报 description: > NGTIP Intelligence Query grouped tool. Use the `action` parameter to access DNS/domain detection, IP reputation, file hash reputation, vulnerability diff --git a/.flocks/plugins/tools/device/onesandbox_v3/_provider.yaml b/.flocks/plugins/tools/device/onesandbox_v3/_provider.yaml index f3123192a..554c772e4 100644 --- a/.flocks/plugins/tools/device/onesandbox_v3/_provider.yaml +++ b/.flocks/plugins/tools/device/onesandbox_v3/_provider.yaml @@ -1,4 +1,5 @@ name: onesandbox +group: 威胁情报 vendor: threatbook service_id: onesandbox_api version: "3" diff --git a/.flocks/plugins/tools/device/onesandbox_v3/onesandbox_ops.yaml b/.flocks/plugins/tools/device/onesandbox_v3/onesandbox_ops.yaml index d05149b02..c8400f145 100644 --- a/.flocks/plugins/tools/device/onesandbox_v3/onesandbox_ops.yaml +++ b/.flocks/plugins/tools/device/onesandbox_v3/onesandbox_ops.yaml @@ -1,4 +1,5 @@ name: onesandbox_ops +group: 威胁情报 description: > OneSandbox v3 mutation grouped tool for sample upload, report deletion, event upload, hash reputation update/delete, SafeSkill scan, and CheckURL diff --git a/.flocks/plugins/tools/device/onesandbox_v3/onesandbox_query.yaml b/.flocks/plugins/tools/device/onesandbox_v3/onesandbox_query.yaml index c7e6cfa1f..9c62535a8 100644 --- a/.flocks/plugins/tools/device/onesandbox_v3/onesandbox_query.yaml +++ b/.flocks/plugins/tools/device/onesandbox_v3/onesandbox_query.yaml @@ -1,4 +1,5 @@ name: onesandbox_query +group: 威胁情报 description: > OneSandbox v3 read-only grouped tool for health checks, API version, file reports, task queue, report download, hash reputation, SDK policy, diff --git a/.flocks/plugins/tools/device/onesec_v2_8_2/_provider.yaml b/.flocks/plugins/tools/device/onesec_v2_8_2/_provider.yaml index 4e1b654ff..d605b7624 100644 --- a/.flocks/plugins/tools/device/onesec_v2_8_2/_provider.yaml +++ b/.flocks/plugins/tools/device/onesec_v2_8_2/_provider.yaml @@ -1,4 +1,5 @@ name: onesec +group: EDR/HIDS vendor: threatbook service_id: onesec_api version: "2.8.2" diff --git a/.flocks/plugins/tools/device/onesec_v2_8_2/onesec_dns.yaml b/.flocks/plugins/tools/device/onesec_v2_8_2/onesec_dns.yaml index 6f6642910..9ab5f39ff 100644 --- a/.flocks/plugins/tools/device/onesec_v2_8_2/onesec_dns.yaml +++ b/.flocks/plugins/tools/device/onesec_v2_8_2/onesec_dns.yaml @@ -1,4 +1,5 @@ name: onesec_dns +group: EDR/HIDS description: > OneSEC DNS grouped tool. Use the `action` parameter to access DNS security query and policy APIs. Configure the OneSEC service with separate API Key and diff --git a/.flocks/plugins/tools/device/onesec_v2_8_2/onesec_edr.yaml b/.flocks/plugins/tools/device/onesec_v2_8_2/onesec_edr.yaml index a17264cd2..66a07a214 100644 --- a/.flocks/plugins/tools/device/onesec_v2_8_2/onesec_edr.yaml +++ b/.flocks/plugins/tools/device/onesec_v2_8_2/onesec_edr.yaml @@ -1,4 +1,5 @@ name: onesec_edr +group: EDR/HIDS description: > OneSEC EDR grouped tool. Use the `action` parameter to access endpoint detection, response, IOC, and action-status APIs. Configure the OneSEC diff --git a/.flocks/plugins/tools/device/onesec_v2_8_2/onesec_ops.yaml b/.flocks/plugins/tools/device/onesec_v2_8_2/onesec_ops.yaml index d76713691..813b81783 100644 --- a/.flocks/plugins/tools/device/onesec_v2_8_2/onesec_ops.yaml +++ b/.flocks/plugins/tools/device/onesec_v2_8_2/onesec_ops.yaml @@ -1,4 +1,5 @@ name: onesec_ops +group: EDR/HIDS description: > OneSEC operations grouped tool. Use the `action` parameter to access endpoint management, audit, task, and strategy APIs. Configure the OneSEC service with diff --git a/.flocks/plugins/tools/device/onesec_v2_8_2/onesec_software.yaml b/.flocks/plugins/tools/device/onesec_v2_8_2/onesec_software.yaml index 25dfc2185..6694019cd 100644 --- a/.flocks/plugins/tools/device/onesec_v2_8_2/onesec_software.yaml +++ b/.flocks/plugins/tools/device/onesec_v2_8_2/onesec_software.yaml @@ -1,4 +1,5 @@ name: onesec_software +group: EDR/HIDS description: > OneSEC software grouped tool. Use the `action` parameter to access installed software inventory APIs. Configure the OneSEC service with separate API Key diff --git a/.flocks/plugins/tools/device/onesec_v2_8_2/onesec_threat.yaml b/.flocks/plugins/tools/device/onesec_v2_8_2/onesec_threat.yaml index 6fd7ca33e..e47046bc9 100644 --- a/.flocks/plugins/tools/device/onesec_v2_8_2/onesec_threat.yaml +++ b/.flocks/plugins/tools/device/onesec_v2_8_2/onesec_threat.yaml @@ -1,4 +1,5 @@ name: onesec_threat +group: EDR/HIDS description: > OneSEC threat protection grouped tool. Use the `action` parameter to access antivirus version and scan task APIs. Configure the OneSEC service with diff --git a/.flocks/plugins/tools/device/onesig_v2_5_3/_provider.yaml b/.flocks/plugins/tools/device/onesig_v2_5_3/_provider.yaml index 29dd25c7c..ab6da131a 100644 --- a/.flocks/plugins/tools/device/onesig_v2_5_3/_provider.yaml +++ b/.flocks/plugins/tools/device/onesig_v2_5_3/_provider.yaml @@ -1,4 +1,5 @@ name: onesig +group: 网络防护 vendor: threatbook service_id: onesig_api version: "2.5.3" diff --git a/.flocks/plugins/tools/device/onesig_v2_5_3/onesig_strategy_api_ops.yaml b/.flocks/plugins/tools/device/onesig_v2_5_3/onesig_strategy_api_ops.yaml index 2a15c5570..3ffb14962 100644 --- a/.flocks/plugins/tools/device/onesig_v2_5_3/onesig_strategy_api_ops.yaml +++ b/.flocks/plugins/tools/device/onesig_v2_5_3/onesig_strategy_api_ops.yaml @@ -1,4 +1,5 @@ name: onesig_strategy_api_ops +group: 网络防护 description: > OneSIG Strategy API mutation grouped tool. Uses ApiKey + Secret HMAC-SHA1 signing and calls /api/v3 mutation endpoints for assets, policies, diff --git a/.flocks/plugins/tools/device/onesig_v2_5_3/onesig_strategy_api_query.yaml b/.flocks/plugins/tools/device/onesig_v2_5_3/onesig_strategy_api_query.yaml index 120e12d3f..7e29d8622 100644 --- a/.flocks/plugins/tools/device/onesig_v2_5_3/onesig_strategy_api_query.yaml +++ b/.flocks/plugins/tools/device/onesig_v2_5_3/onesig_strategy_api_query.yaml @@ -1,4 +1,5 @@ name: onesig_strategy_api_query +group: 网络防护 description: > OneSIG Strategy API read-only grouped tool. Uses ApiKey + Secret HMAC-SHA1 signing and calls /api/v3 endpoints for status, asset, policy, whitelist, diff --git a/.flocks/plugins/tools/device/qingteng_v3_4_1_66/_provider.yaml b/.flocks/plugins/tools/device/qingteng_v3_4_1_66/_provider.yaml index d57854bef..86e276d20 100644 --- a/.flocks/plugins/tools/device/qingteng_v3_4_1_66/_provider.yaml +++ b/.flocks/plugins/tools/device/qingteng_v3_4_1_66/_provider.yaml @@ -1,4 +1,5 @@ name: qingteng +group: EDR/HIDS vendor: qingteng service_id: qingteng version: "3.4.1.66" diff --git a/.flocks/plugins/tools/device/qingteng_v3_4_1_66/qingteng_asset_discovery.yaml b/.flocks/plugins/tools/device/qingteng_v3_4_1_66/qingteng_asset_discovery.yaml index 05bd52180..2010ba893 100644 --- a/.flocks/plugins/tools/device/qingteng_v3_4_1_66/qingteng_asset_discovery.yaml +++ b/.flocks/plugins/tools/device/qingteng_v3_4_1_66/qingteng_asset_discovery.yaml @@ -1,4 +1,5 @@ name: qingteng_asset_discovery +group: EDR/HIDS description: > 青藤资产发现工具。支持查询已发现主机列表,以及创建/查询/更新/删除/执行资产扫描任务。 适用于对未安装 Agent 的主机进行网络扫描发现,了解全局资产状况。 diff --git a/.flocks/plugins/tools/device/qingteng_v3_4_1_66/qingteng_assets.yaml b/.flocks/plugins/tools/device/qingteng_v3_4_1_66/qingteng_assets.yaml index cec09506d..20d86dcc0 100644 --- a/.flocks/plugins/tools/device/qingteng_v3_4_1_66/qingteng_assets.yaml +++ b/.flocks/plugins/tools/device/qingteng_v3_4_1_66/qingteng_assets.yaml @@ -1,4 +1,5 @@ name: qingteng_assets +group: EDR/HIDS description: > 青藤资产清点分组工具。通过 `action` 参数访问资产查询、资产刷新、刷新状态和资产侧扩展操作。 适用于主机、进程、账号、端口、Web 应用、数据库、安装包等资产域接口。 diff --git a/.flocks/plugins/tools/device/qingteng_v3_4_1_66/qingteng_baseline.yaml b/.flocks/plugins/tools/device/qingteng_v3_4_1_66/qingteng_baseline.yaml index 072a91746..2071b16e7 100644 --- a/.flocks/plugins/tools/device/qingteng_v3_4_1_66/qingteng_baseline.yaml +++ b/.flocks/plugins/tools/device/qingteng_v3_4_1_66/qingteng_baseline.yaml @@ -1,4 +1,5 @@ name: qingteng_baseline +group: EDR/HIDS description: > 青藤合规基线分组工具。通过 `action` 参数访问基线任务、执行状态、规则结果和授权相关接口。 description_cn: > diff --git a/.flocks/plugins/tools/device/qingteng_v3_4_1_66/qingteng_detect.yaml b/.flocks/plugins/tools/device/qingteng_v3_4_1_66/qingteng_detect.yaml index 3cbf3bb55..7f21b2fd1 100644 --- a/.flocks/plugins/tools/device/qingteng_v3_4_1_66/qingteng_detect.yaml +++ b/.flocks/plugins/tools/device/qingteng_v3_4_1_66/qingteng_detect.yaml @@ -1,4 +1,5 @@ name: qingteng_detect +group: EDR/HIDS description: > 青藤入侵检测分组工具。通过 `action` 参数访问可疑操作、暴力破解、异常登录、 WebShell、反弹 Shell、本地提权、后门检测和网络蜜罐相关接口。 diff --git a/.flocks/plugins/tools/device/qingteng_v3_4_1_66/qingteng_fastjob.yaml b/.flocks/plugins/tools/device/qingteng_v3_4_1_66/qingteng_fastjob.yaml index 71e7e7aec..a06bc19af 100644 --- a/.flocks/plugins/tools/device/qingteng_v3_4_1_66/qingteng_fastjob.yaml +++ b/.flocks/plugins/tools/device/qingteng_v3_4_1_66/qingteng_fastjob.yaml @@ -1,4 +1,5 @@ name: qingteng_fastjob +group: EDR/HIDS description: > 青藤快速任务(FastJob)工具。支持查看检测项列表、创建/编辑/删除/执行快速作业及查询执行结果。 适用于对单台或多台主机快速执行安全检测和应急响应任务。 diff --git a/.flocks/plugins/tools/device/qingteng_v3_4_1_66/qingteng_host_list.yaml b/.flocks/plugins/tools/device/qingteng_v3_4_1_66/qingteng_host_list.yaml index b68800d68..ecfab9508 100644 --- a/.flocks/plugins/tools/device/qingteng_v3_4_1_66/qingteng_host_list.yaml +++ b/.flocks/plugins/tools/device/qingteng_v3_4_1_66/qingteng_host_list.yaml @@ -1,4 +1,5 @@ name: qingteng_host_list +group: EDR/HIDS description: > 查询青藤云平台的主机资产列表。支持查询 Linux 和 Windows 主机。 用于主机资产梳理、资产盘点、安全评估等场景。 diff --git a/.flocks/plugins/tools/device/qingteng_v3_4_1_66/qingteng_microseg.yaml b/.flocks/plugins/tools/device/qingteng_v3_4_1_66/qingteng_microseg.yaml index a4bc2c5d4..a68d25804 100644 --- a/.flocks/plugins/tools/device/qingteng_v3_4_1_66/qingteng_microseg.yaml +++ b/.flocks/plugins/tools/device/qingteng_v3_4_1_66/qingteng_microseg.yaml @@ -1,4 +1,5 @@ name: qingteng_microseg +group: EDR/HIDS description: > 青藤微隔离工具。支持一键隔离策略管理、主机微隔离状态控制及黑名单策略管理。 适用于对受感染主机实施网络隔离、配置访问控制策略及管理黑名单流量规则。 diff --git a/.flocks/plugins/tools/device/qingteng_v3_4_1_66/qingteng_risk.yaml b/.flocks/plugins/tools/device/qingteng_v3_4_1_66/qingteng_risk.yaml index 19e57050f..09fe8e404 100644 --- a/.flocks/plugins/tools/device/qingteng_v3_4_1_66/qingteng_risk.yaml +++ b/.flocks/plugins/tools/device/qingteng_v3_4_1_66/qingteng_risk.yaml @@ -1,4 +1,5 @@ name: qingteng_risk +group: EDR/HIDS description: > 青藤风险发现分组工具。通过 `action` 参数访问补丁、风险项、弱密码、Web 风险文件、 漏洞检测和漏洞作业管理相关接口。 diff --git a/.flocks/plugins/tools/device/qingteng_v3_4_1_66/qingteng_system_audit.yaml b/.flocks/plugins/tools/device/qingteng_v3_4_1_66/qingteng_system_audit.yaml index cf9e48fbb..ecf4fc260 100644 --- a/.flocks/plugins/tools/device/qingteng_v3_4_1_66/qingteng_system_audit.yaml +++ b/.flocks/plugins/tools/device/qingteng_v3_4_1_66/qingteng_system_audit.yaml @@ -1,4 +1,5 @@ name: qingteng_system_audit +group: EDR/HIDS description: > 查询青藤云平台系统审计日志。支持按操作名称和操作账号模糊过滤, 适用于排查配置变更来源、分析用户操作轨迹和审计外部 API 调用记录。 diff --git a/.flocks/plugins/tools/device/qingteng_v3_4_1_66/qingteng_vul_check.yaml b/.flocks/plugins/tools/device/qingteng_v3_4_1_66/qingteng_vul_check.yaml index 6491203d4..339f93d39 100644 --- a/.flocks/plugins/tools/device/qingteng_v3_4_1_66/qingteng_vul_check.yaml +++ b/.flocks/plugins/tools/device/qingteng_v3_4_1_66/qingteng_vul_check.yaml @@ -1,4 +1,5 @@ name: qingteng_vul_check +group: EDR/HIDS description: > 青藤云漏洞/风险检测。支持多种风险类型检测: - app: 应用风险 diff --git a/.flocks/plugins/tools/device/sangfor_af_v8_0_106/_provider.yaml b/.flocks/plugins/tools/device/sangfor_af_v8_0_106/_provider.yaml index 8b66831e7..8df6e0dc1 100644 --- a/.flocks/plugins/tools/device/sangfor_af_v8_0_106/_provider.yaml +++ b/.flocks/plugins/tools/device/sangfor_af_v8_0_106/_provider.yaml @@ -1,4 +1,5 @@ name: sangfor_af +group: 网络防护 vendor: sangfor service_id: sangfor_af version: "8.0.106" diff --git a/.flocks/plugins/tools/device/sangfor_af_v8_0_106/sangfor_af_v106_alarm.yaml b/.flocks/plugins/tools/device/sangfor_af_v8_0_106/sangfor_af_v106_alarm.yaml index 24d98cb52..1c8545712 100644 --- a/.flocks/plugins/tools/device/sangfor_af_v8_0_106/sangfor_af_v106_alarm.yaml +++ b/.flocks/plugins/tools/device/sangfor_af_v8_0_106/sangfor_af_v106_alarm.yaml @@ -1,4 +1,5 @@ name: sangfor_af_v106_alarm +group: 网络防护 description: > Sangfor AF v8.0.106 alarm and notification tool. Query alarm event configurations, notification channels, registered events, provider diff --git a/.flocks/plugins/tools/device/sangfor_af_v8_0_106/sangfor_af_v106_auth.yaml b/.flocks/plugins/tools/device/sangfor_af_v8_0_106/sangfor_af_v106_auth.yaml index 3e8e7f5f3..4c6571a78 100644 --- a/.flocks/plugins/tools/device/sangfor_af_v8_0_106/sangfor_af_v106_auth.yaml +++ b/.flocks/plugins/tools/device/sangfor_af_v8_0_106/sangfor_af_v106_auth.yaml @@ -1,4 +1,5 @@ name: sangfor_af_v106_auth +group: 网络防护 description: > Sangfor AF v8.0.48 authentication tool. Use the `action` parameter to login, logout, or keep the session alive. Token is cached automatically diff --git a/.flocks/plugins/tools/device/sangfor_af_v8_0_106/sangfor_af_v106_monitor.yaml b/.flocks/plugins/tools/device/sangfor_af_v8_0_106/sangfor_af_v106_monitor.yaml index 4f4a0fd94..012353702 100644 --- a/.flocks/plugins/tools/device/sangfor_af_v8_0_106/sangfor_af_v106_monitor.yaml +++ b/.flocks/plugins/tools/device/sangfor_af_v8_0_106/sangfor_af_v106_monitor.yaml @@ -1,4 +1,5 @@ name: sangfor_af_v106_monitor +group: 网络防护 description: > Sangfor AF v8.0.106 monitoring tool. Provides real-time and historical session data, traffic rankings, network statistics, packet diagnostics, diff --git a/.flocks/plugins/tools/device/sangfor_af_v8_0_106/sangfor_af_v106_network.yaml b/.flocks/plugins/tools/device/sangfor_af_v8_0_106/sangfor_af_v106_network.yaml index 0d7f31d15..e8f35a14d 100644 --- a/.flocks/plugins/tools/device/sangfor_af_v8_0_106/sangfor_af_v106_network.yaml +++ b/.flocks/plugins/tools/device/sangfor_af_v8_0_106/sangfor_af_v106_network.yaml @@ -1,4 +1,5 @@ name: sangfor_af_v106_network +group: 网络防护 description: > Sangfor AF v8.0.48 network tool. Query routing tables (IPv4 and IPv6) and network-related status information. diff --git a/.flocks/plugins/tools/device/sangfor_af_v8_0_106/sangfor_af_v106_objects.yaml b/.flocks/plugins/tools/device/sangfor_af_v8_0_106/sangfor_af_v106_objects.yaml index f901ca8ac..54778b762 100644 --- a/.flocks/plugins/tools/device/sangfor_af_v8_0_106/sangfor_af_v106_objects.yaml +++ b/.flocks/plugins/tools/device/sangfor_af_v8_0_106/sangfor_af_v106_objects.yaml @@ -1,4 +1,5 @@ name: sangfor_af_v106_objects +group: 网络防护 description: > Sangfor AF v8.0.48 objects management tool. Query, create, update, and delete network IP group objects and services (protocol/port definitions) diff --git a/.flocks/plugins/tools/device/sangfor_af_v8_0_106/sangfor_af_v106_ops.yaml b/.flocks/plugins/tools/device/sangfor_af_v8_0_106/sangfor_af_v106_ops.yaml index b63b3a6da..57fa4b52b 100644 --- a/.flocks/plugins/tools/device/sangfor_af_v8_0_106/sangfor_af_v106_ops.yaml +++ b/.flocks/plugins/tools/device/sangfor_af_v8_0_106/sangfor_af_v106_ops.yaml @@ -1,4 +1,5 @@ name: sangfor_af_v106_ops +group: 网络防护 description: > Sangfor AF v8.0.48 operations center tool. Manages blacklist/whitelist entries (IPs, domains, URLs) and blocked attacker IPs via the `action` diff --git a/.flocks/plugins/tools/device/sangfor_af_v8_0_106/sangfor_af_v106_status.yaml b/.flocks/plugins/tools/device/sangfor_af_v8_0_106/sangfor_af_v106_status.yaml index 9ea4d2c6e..93f0b057a 100644 --- a/.flocks/plugins/tools/device/sangfor_af_v8_0_106/sangfor_af_v106_status.yaml +++ b/.flocks/plugins/tools/device/sangfor_af_v8_0_106/sangfor_af_v106_status.yaml @@ -1,4 +1,5 @@ name: sangfor_af_v106_status +group: 网络防护 description: > Sangfor AF v8.0.48 device status tool. Query system resource usage (CPU, memory, disk), firmware version, network interface status, diff --git a/.flocks/plugins/tools/device/sangfor_af_v8_0_106/sangfor_af_v106_system.yaml b/.flocks/plugins/tools/device/sangfor_af_v8_0_106/sangfor_af_v106_system.yaml index 1acda1d27..7aae38b2d 100644 --- a/.flocks/plugins/tools/device/sangfor_af_v8_0_106/sangfor_af_v106_system.yaml +++ b/.flocks/plugins/tools/device/sangfor_af_v8_0_106/sangfor_af_v106_system.yaml @@ -1,4 +1,5 @@ name: sangfor_af_v106_system +group: 网络防护 description: > Sangfor AF v8.0.48 system management tool. Query and manage administrator accounts on the AF device. diff --git a/.flocks/plugins/tools/device/sangfor_edr_asset_inventory.yaml b/.flocks/plugins/tools/device/sangfor_edr_asset_inventory.yaml index bc71a71cb..9c56bb171 100644 --- a/.flocks/plugins/tools/device/sangfor_edr_asset_inventory.yaml +++ b/.flocks/plugins/tools/device/sangfor_edr_asset_inventory.yaml @@ -1,4 +1,5 @@ name: sangfor_edr_asset_inventory +group: EDR/HIDS description: > Collect Sangfor EDR asset-inventory classification counts through authenticated HTTP APIs. description_cn: > diff --git a/.flocks/plugins/tools/device/sangfor_edr_webcli/_provider.yaml b/.flocks/plugins/tools/device/sangfor_edr_webcli/_provider.yaml index 39065e251..6ba5812aa 100644 --- a/.flocks/plugins/tools/device/sangfor_edr_webcli/_provider.yaml +++ b/.flocks/plugins/tools/device/sangfor_edr_webcli/_provider.yaml @@ -1,4 +1,5 @@ name: Sangfor EDR WebCLI +group: EDR/HIDS vendor: sangfor service_id: sangfor_edr version: "1.0.0" diff --git a/.flocks/plugins/tools/device/sangfor_edr_webcli/sangfor_edr_advanced_threat.yaml b/.flocks/plugins/tools/device/sangfor_edr_webcli/sangfor_edr_advanced_threat.yaml index 3a43e0752..a4fb7f5a0 100644 --- a/.flocks/plugins/tools/device/sangfor_edr_webcli/sangfor_edr_advanced_threat.yaml +++ b/.flocks/plugins/tools/device/sangfor_edr_webcli/sangfor_edr_advanced_threat.yaml @@ -1,4 +1,5 @@ name: sangfor_edr_advanced_threat +group: EDR/HIDS description: > Collect Sangfor EDR advanced-threat warning-mode and incident-mode lists through authenticated HTTP APIs using one verified cookie/login_token pair. diff --git a/.flocks/plugins/tools/device/sangfor_edr_webcli/sangfor_edr_auth.yaml b/.flocks/plugins/tools/device/sangfor_edr_webcli/sangfor_edr_auth.yaml index 3aed97339..4eac1714e 100644 --- a/.flocks/plugins/tools/device/sangfor_edr_webcli/sangfor_edr_auth.yaml +++ b/.flocks/plugins/tools/device/sangfor_edr_webcli/sangfor_edr_auth.yaml @@ -1,4 +1,5 @@ name: sangfor_edr_auth +group: EDR/HIDS description: > Manage a matched Sangfor EDR cookie/login_token pair. ensure_auth_state and refresh_auth_state use HTTP login by default. browser_login is used only when diff --git a/.flocks/plugins/tools/device/sangfor_edr_webcli/sangfor_edr_dashboard.yaml b/.flocks/plugins/tools/device/sangfor_edr_webcli/sangfor_edr_dashboard.yaml index 4a8c27ce3..66deb575d 100644 --- a/.flocks/plugins/tools/device/sangfor_edr_webcli/sangfor_edr_dashboard.yaml +++ b/.flocks/plugins/tools/device/sangfor_edr_webcli/sangfor_edr_dashboard.yaml @@ -1,4 +1,5 @@ name: sangfor_edr_dashboard +group: EDR/HIDS description: > Collect Sangfor EDR home dashboard data through authenticated HTTP APIs. Probes the saved cookie/login_token pair before every collection, skips login diff --git a/.flocks/plugins/tools/device/sangfor_edr_webcli/sangfor_edr_threat_assets.yaml b/.flocks/plugins/tools/device/sangfor_edr_webcli/sangfor_edr_threat_assets.yaml index 839afaa7b..ea18367d3 100644 --- a/.flocks/plugins/tools/device/sangfor_edr_webcli/sangfor_edr_threat_assets.yaml +++ b/.flocks/plugins/tools/device/sangfor_edr_webcli/sangfor_edr_threat_assets.yaml @@ -1,4 +1,5 @@ name: sangfor_edr_threat_assets +group: EDR/HIDS description: > Collect Sangfor EDR threat-asset analysis data through authenticated HTTP APIs. The collector uses the verified cookie/login_token pair from the HTTP login diff --git a/.flocks/plugins/tools/device/sangfor_sip_v92/_provider.yaml b/.flocks/plugins/tools/device/sangfor_sip_v92/_provider.yaml index 7b7806f8b..adce82bb9 100644 --- a/.flocks/plugins/tools/device/sangfor_sip_v92/_provider.yaml +++ b/.flocks/plugins/tools/device/sangfor_sip_v92/_provider.yaml @@ -1,4 +1,5 @@ name: sangfor_sip +group: SIEM vendor: sangfor service_id: sangfor_sip version: "9.2" diff --git a/.flocks/plugins/tools/device/sangfor_sip_v92/sangfor_sip_assets.yaml b/.flocks/plugins/tools/device/sangfor_sip_v92/sangfor_sip_assets.yaml index 28cbfc932..cd0f0c632 100644 --- a/.flocks/plugins/tools/device/sangfor_sip_v92/sangfor_sip_assets.yaml +++ b/.flocks/plugins/tools/device/sangfor_sip_v92/sangfor_sip_assets.yaml @@ -1,4 +1,5 @@ name: sangfor_sip_assets +group: SIEM description: > Query Sangfor SIP asset data. Use the `action` parameter to select: - `ipgroup` – 受监控IP模块(监控范围、资产组、责任人等) diff --git a/.flocks/plugins/tools/device/sangfor_sip_v92/sangfor_sip_events.yaml b/.flocks/plugins/tools/device/sangfor_sip_v92/sangfor_sip_events.yaml index 7a3337c8b..695ee74d4 100644 --- a/.flocks/plugins/tools/device/sangfor_sip_v92/sangfor_sip_events.yaml +++ b/.flocks/plugins/tools/device/sangfor_sip_v92/sangfor_sip_events.yaml @@ -1,4 +1,5 @@ name: sangfor_sip_events +group: SIEM description: > Query Sangfor SIP security event data. - `sec_event` – 安全事件(含威胁级别、攻击方向、处置状态、攻击结果等) diff --git a/.flocks/plugins/tools/device/sangfor_sip_v92/sangfor_sip_risk.yaml b/.flocks/plugins/tools/device/sangfor_sip_v92/sangfor_sip_risk.yaml index 05a67ac83..4a5cae967 100644 --- a/.flocks/plugins/tools/device/sangfor_sip_v92/sangfor_sip_risk.yaml +++ b/.flocks/plugins/tools/device/sangfor_sip_v92/sangfor_sip_risk.yaml @@ -1,4 +1,5 @@ name: sangfor_sip_risk +group: SIEM description: > Query Sangfor SIP risk data. Use the `action` parameter to select: - `risk_business` – 风险业务(GET /sangforinter/v1/data/riskbusiness) diff --git a/.flocks/plugins/tools/device/sangfor_sip_v92/sangfor_sip_vuln.yaml b/.flocks/plugins/tools/device/sangfor_sip_v92/sangfor_sip_vuln.yaml index d8e93ad01..65a8502ed 100644 --- a/.flocks/plugins/tools/device/sangfor_sip_v92/sangfor_sip_vuln.yaml +++ b/.flocks/plugins/tools/device/sangfor_sip_v92/sangfor_sip_vuln.yaml @@ -1,4 +1,5 @@ name: sangfor_sip_vuln +group: SIEM description: > Query Sangfor SIP vulnerability data. Use the `action` parameter to select: - `weak_password` – 脆弱性-弱密码(弱密码账号、服务类型、端口) diff --git a/.flocks/plugins/tools/device/sangfor_xdr_v2_2/_provider.yaml b/.flocks/plugins/tools/device/sangfor_xdr_v2_2/_provider.yaml index 01286b3bd..b0fb2748e 100644 --- a/.flocks/plugins/tools/device/sangfor_xdr_v2_2/_provider.yaml +++ b/.flocks/plugins/tools/device/sangfor_xdr_v2_2/_provider.yaml @@ -1,4 +1,5 @@ name: sangfor_xdr +group: XDR vendor: sangfor service_id: sangfor_xdr version: "2.2" diff --git a/.flocks/plugins/tools/device/sangfor_xdr_v2_2/sangfor_xdr_alerts.yaml b/.flocks/plugins/tools/device/sangfor_xdr_v2_2/sangfor_xdr_alerts.yaml index 24d189d7e..00b742d44 100644 --- a/.flocks/plugins/tools/device/sangfor_xdr_v2_2/sangfor_xdr_alerts.yaml +++ b/.flocks/plugins/tools/device/sangfor_xdr_v2_2/sangfor_xdr_alerts.yaml @@ -1,4 +1,5 @@ name: sangfor_xdr_alerts +group: XDR description: > Manage Sangfor XDR security alerts. Query alerts by time range, update alert disposition status, and retrieve alert proof/evidence. diff --git a/.flocks/plugins/tools/device/sangfor_xdr_v2_2/sangfor_xdr_assets.yaml b/.flocks/plugins/tools/device/sangfor_xdr_v2_2/sangfor_xdr_assets.yaml index b6c35f0a9..23222ba51 100644 --- a/.flocks/plugins/tools/device/sangfor_xdr_v2_2/sangfor_xdr_assets.yaml +++ b/.flocks/plugins/tools/device/sangfor_xdr_v2_2/sangfor_xdr_assets.yaml @@ -1,4 +1,5 @@ name: sangfor_xdr_assets +group: XDR description: > Query and manage Sangfor XDR assets. Retrieve asset lists, IP segment tree, asset types, connected devices, and department structure. diff --git a/.flocks/plugins/tools/device/sangfor_xdr_v2_2/sangfor_xdr_incidents.yaml b/.flocks/plugins/tools/device/sangfor_xdr_v2_2/sangfor_xdr_incidents.yaml index 692a3a224..5415b3d5f 100644 --- a/.flocks/plugins/tools/device/sangfor_xdr_v2_2/sangfor_xdr_incidents.yaml +++ b/.flocks/plugins/tools/device/sangfor_xdr_v2_2/sangfor_xdr_incidents.yaml @@ -1,4 +1,5 @@ name: sangfor_xdr_incidents +group: XDR description: > Manage Sangfor XDR security incidents. Query incidents by time range, update disposition status, retrieve incident proof and related entities diff --git a/.flocks/plugins/tools/device/sangfor_xdr_v2_2/sangfor_xdr_responses.yaml b/.flocks/plugins/tools/device/sangfor_xdr_v2_2/sangfor_xdr_responses.yaml index db997d2ec..f84a976bd 100644 --- a/.flocks/plugins/tools/device/sangfor_xdr_v2_2/sangfor_xdr_responses.yaml +++ b/.flocks/plugins/tools/device/sangfor_xdr_v2_2/sangfor_xdr_responses.yaml @@ -1,4 +1,5 @@ name: sangfor_xdr_responses +group: XDR description: > Manage Sangfor XDR host isolation responses. Query currently isolated hosts and release (unisolate) hosts. Use during incident containment. diff --git a/.flocks/plugins/tools/device/sangfor_xdr_v2_2/sangfor_xdr_vulns.yaml b/.flocks/plugins/tools/device/sangfor_xdr_v2_2/sangfor_xdr_vulns.yaml index ed8ddaca1..8e682bee2 100644 --- a/.flocks/plugins/tools/device/sangfor_xdr_v2_2/sangfor_xdr_vulns.yaml +++ b/.flocks/plugins/tools/device/sangfor_xdr_v2_2/sangfor_xdr_vulns.yaml @@ -1,4 +1,5 @@ name: sangfor_xdr_vulns +group: XDR description: > Query and manage Sangfor XDR vulnerability data. Retrieve baseline compliance data, vulnerability lists, update fix status, and query diff --git a/.flocks/plugins/tools/device/sangfor_xdr_v2_2/sangfor_xdr_whitelists.yaml b/.flocks/plugins/tools/device/sangfor_xdr_v2_2/sangfor_xdr_whitelists.yaml index 05bc55ef6..6a983b51b 100644 --- a/.flocks/plugins/tools/device/sangfor_xdr_v2_2/sangfor_xdr_whitelists.yaml +++ b/.flocks/plugins/tools/device/sangfor_xdr_v2_2/sangfor_xdr_whitelists.yaml @@ -1,4 +1,5 @@ name: sangfor_xdr_whitelists +group: XDR description: > Manage Sangfor XDR whitelists. Create, query, update, delete whitelist rules, and toggle their enabled/disabled status. Use to suppress diff --git a/.flocks/plugins/tools/device/skyeye_v4_0_14_0_SP2/_provider.yaml b/.flocks/plugins/tools/device/skyeye_v4_0_14_0_SP2/_provider.yaml index 07f03850d..2ec91b508 100644 --- a/.flocks/plugins/tools/device/skyeye_v4_0_14_0_SP2/_provider.yaml +++ b/.flocks/plugins/tools/device/skyeye_v4_0_14_0_SP2/_provider.yaml @@ -1,4 +1,5 @@ name: SkyEye +group: NDR vendor: qianxin service_id: skyeye_api version: "4.0.14.0.SP2" diff --git a/.flocks/plugins/tools/device/skyeye_v4_0_14_0_SP2/skyeye_alarm_list.yaml b/.flocks/plugins/tools/device/skyeye_v4_0_14_0_SP2/skyeye_alarm_list.yaml index 08044e089..800e5ab52 100644 --- a/.flocks/plugins/tools/device/skyeye_v4_0_14_0_SP2/skyeye_alarm_list.yaml +++ b/.flocks/plugins/tools/device/skyeye_v4_0_14_0_SP2/skyeye_alarm_list.yaml @@ -1,4 +1,5 @@ name: skyeye_alarm_list +group: NDR description: > 查询 SkyEye 告警列表。默认返回最近 7 天的告警,可按告警类型、API 威胁类型、 威胁级别、攻击结果、攻击阶段、资产组、攻击组织、IOC、资产标签和分页排序等条件过滤。 diff --git a/.flocks/plugins/tools/device/skyeye_v4_0_14_0_SP2/skyeye_alarm_params.yaml b/.flocks/plugins/tools/device/skyeye_v4_0_14_0_SP2/skyeye_alarm_params.yaml index 464723d2f..95f20bc08 100644 --- a/.flocks/plugins/tools/device/skyeye_v4_0_14_0_SP2/skyeye_alarm_params.yaml +++ b/.flocks/plugins/tools/device/skyeye_v4_0_14_0_SP2/skyeye_alarm_params.yaml @@ -1,4 +1,5 @@ name: skyeye_alarm_params +group: NDR description: > 获取 SkyEye 告警检索所需的枚举值,例如攻击阶段、攻击组织、资产组、 资产标签、告警类型、API 威胁类型和重点关注标签等,可先查询本接口再调用 diff --git a/.flocks/plugins/tools/device/skyeye_v4_0_14_0_SP2/skyeye_dashboard_view.yaml b/.flocks/plugins/tools/device/skyeye_v4_0_14_0_SP2/skyeye_dashboard_view.yaml index 2d78c1dd7..e5e77fa8c 100644 --- a/.flocks/plugins/tools/device/skyeye_v4_0_14_0_SP2/skyeye_dashboard_view.yaml +++ b/.flocks/plugins/tools/device/skyeye_v4_0_14_0_SP2/skyeye_dashboard_view.yaml @@ -1,4 +1,5 @@ name: skyeye_dashboard_view +group: NDR description: > 查询 SkyEye 仪表板视图数据。默认返回整体视图,可通过 `name` 切换到告警统计、 资产态势、攻击源排行和系统维护等不同看板;部分视图还支持关键字、分页和排序参数。 diff --git a/.flocks/plugins/tools/device/skyeye_v4_0_14_0_SP2/skyeye_download_alarm_report.yaml b/.flocks/plugins/tools/device/skyeye_v4_0_14_0_SP2/skyeye_download_alarm_report.yaml index 2bc34c121..e053e4733 100644 --- a/.flocks/plugins/tools/device/skyeye_v4_0_14_0_SP2/skyeye_download_alarm_report.yaml +++ b/.flocks/plugins/tools/device/skyeye_v4_0_14_0_SP2/skyeye_download_alarm_report.yaml @@ -1,4 +1,5 @@ name: skyeye_download_alarm_report +group: NDR description: > 下载 SkyEye 告警详情报告,支持 docx 或 pdf。通常需要传入告警发生当天对应的 `start_time` / `end_time` 查询窗口,并返回文件内容的 base64、 diff --git a/.flocks/plugins/tools/device/skyeye_v4_0_14_0_SP2/skyeye_download_pcap.yaml b/.flocks/plugins/tools/device/skyeye_v4_0_14_0_SP2/skyeye_download_pcap.yaml index c80be58f1..177587fc7 100644 --- a/.flocks/plugins/tools/device/skyeye_v4_0_14_0_SP2/skyeye_download_pcap.yaml +++ b/.flocks/plugins/tools/device/skyeye_v4_0_14_0_SP2/skyeye_download_pcap.yaml @@ -1,4 +1,5 @@ name: skyeye_download_pcap +group: NDR description: > 下载 SkyEye 告警关联的 PCAP 包。通常需要传入告警发生当天对应的 `start_time` / `end_time` 查询窗口,并返回文件内容的 base64、 diff --git a/.flocks/plugins/tools/device/skyeye_v4_0_14_0_SP2/skyeye_download_uploadfile.yaml b/.flocks/plugins/tools/device/skyeye_v4_0_14_0_SP2/skyeye_download_uploadfile.yaml index bee05ceae..22696e891 100644 --- a/.flocks/plugins/tools/device/skyeye_v4_0_14_0_SP2/skyeye_download_uploadfile.yaml +++ b/.flocks/plugins/tools/device/skyeye_v4_0_14_0_SP2/skyeye_download_uploadfile.yaml @@ -1,4 +1,5 @@ name: skyeye_download_uploadfile +group: NDR description: > 下载 SkyEye 告警关联的可疑文件。通常需要传入告警发生当天对应的 `start_time` / `end_time` 查询窗口,并返回文件内容的 base64、 diff --git a/.flocks/plugins/tools/device/tdp_v3_3_10/_provider.yaml b/.flocks/plugins/tools/device/tdp_v3_3_10/_provider.yaml index 07b38cd26..69bb921eb 100644 --- a/.flocks/plugins/tools/device/tdp_v3_3_10/_provider.yaml +++ b/.flocks/plugins/tools/device/tdp_v3_3_10/_provider.yaml @@ -1,4 +1,5 @@ name: TDP +group: NDR vendor: threatbook service_id: tdp_api version: "3.3.10" diff --git a/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_asset_upload_api.yaml b/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_asset_upload_api.yaml index 11f422beb..f8f775a4d 100644 --- a/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_asset_upload_api.yaml +++ b/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_asset_upload_api.yaml @@ -1,4 +1,5 @@ name: tdp_asset_upload_api +group: NDR description: > 查询 TDP 上传接口相关数据。`summary` 返回上传接口统计,`host_list` 返回域名列表, `interface_list` 返回上传接口明细。优先使用顶层语义化参数;`fuzzy`、`condition` 和 `page` diff --git a/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_assets_domain_list.yaml b/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_assets_domain_list.yaml index 95e44f9c9..a7c7f0d38 100644 --- a/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_assets_domain_list.yaml +++ b/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_assets_domain_list.yaml @@ -1,4 +1,5 @@ name: tdp_assets_domain_list +group: NDR description: > 查询 TDP 域名资产列表,支持按二级域名、域名或 IP、活跃状态、开放状态和暴露面筛选。 优先使用顶层语义化参数;`condition` / `page` 仅作为高级兼容入口。 diff --git a/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_cloud_facilities.yaml b/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_cloud_facilities.yaml index 1a234be51..e137ce24f 100644 --- a/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_cloud_facilities.yaml +++ b/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_cloud_facilities.yaml @@ -1,4 +1,5 @@ name: tdp_cloud_facilities +group: NDR description: > 查询 TDP 云服务相关数据。`access_source` 返回访问源主机列表;`assets_info` 查询某个访问源主机的云服务访问详情; `instance_list` 返回云实例列表;`instance_access_list` 查询指定云实例的访问明细。优先使用顶层语义化参数,`condition` / `page` diff --git a/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_dashboard_status.yaml b/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_dashboard_status.yaml index 459ad2707..5b575d708 100644 --- a/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_dashboard_status.yaml +++ b/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_dashboard_status.yaml @@ -1,4 +1,5 @@ name: tdp_dashboard_status +group: NDR description: > 查询 TDP Dashboard 相关数据。默认返回概览状态,也可通过 action 切换到阻断、 安全统计、攻击资产、文件检测、阶段统计等子接口。优先使用顶层时间、业务组和分页参数; diff --git a/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_file_download.yaml b/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_file_download.yaml index 3924b772f..965ab6a90 100644 --- a/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_file_download.yaml +++ b/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_file_download.yaml @@ -1,4 +1,5 @@ name: tdp_file_download +group: NDR description: > 按样本哈希下载 TDP 告警关联的恶意文件。 category: custom diff --git a/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_interface_list.yaml b/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_interface_list.yaml index 4d6ef69f6..d2ed45fc1 100644 --- a/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_interface_list.yaml +++ b/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_interface_list.yaml @@ -1,4 +1,5 @@ name: tdp_interface_list +group: NDR description: > 查询 TDP API 接口列表,支持按域名、请求方法、敏感标签、是否对外开放和关键词筛选。 优先使用顶层语义化参数;`condition` / `page` 仅用于传递尚未暴露的底层字段。 diff --git a/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_interface_risk_list.yaml b/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_interface_risk_list.yaml index ea2d61e87..8a2540466 100644 --- a/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_interface_risk_list.yaml +++ b/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_interface_risk_list.yaml @@ -1,4 +1,5 @@ name: tdp_interface_risk_list +group: NDR description: > 查询 TDP API 风险列表,适合排查注入、敏感接口等风险。优先使用顶层语义化参数, 例如风险类型、业务组、关键词和分页;`condition` / `page` 仅作为高级兼容入口。 diff --git a/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_log_search.yaml b/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_log_search.yaml index 778603e9c..cb48dbfef 100644 --- a/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_log_search.yaml +++ b/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_log_search.yaml @@ -1,4 +1,5 @@ name: tdp_log_search +group: NDR description: > 查询 TDP 日志调查/告警相关接口。默认执行日志搜索,也可通过 action 切换到字段聚合统计。 category: custom diff --git a/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_login_api_list.yaml b/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_login_api_list.yaml index 27ebf5038..8cc978690 100644 --- a/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_login_api_list.yaml +++ b/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_login_api_list.yaml @@ -1,4 +1,5 @@ name: tdp_login_api_list +group: NDR description: > 查询 TDP 登录入口相关数据。`summary` 返回登录入口头部统计,`category` 返回分类统计, `list` 返回登录入口明细列表。优先使用顶层语义化参数,例如业务组、分类、风险标签和布尔筛选; diff --git a/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_login_weakpwd_list.yaml b/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_login_weakpwd_list.yaml index ab6913ba5..b66901fdf 100644 --- a/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_login_weakpwd_list.yaml +++ b/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_login_weakpwd_list.yaml @@ -1,4 +1,5 @@ name: tdp_login_weakpwd_list +group: NDR description: > 查询 TDP 弱口令列表,可结合登录入口、攻击结果、应用分类和关键词筛选。优先使用顶层语义化参数; `condition` / `page` 仅作为高级兼容入口。 diff --git a/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_machine_asset_list.yaml b/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_machine_asset_list.yaml index e70f7714d..a5bdeb0ca 100644 --- a/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_machine_asset_list.yaml +++ b/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_machine_asset_list.yaml @@ -1,4 +1,5 @@ name: tdp_machine_asset_list +group: NDR description: > 查询 TDP 服务资产相关接口。`service_list` 适合按服务、分类、端口、是否活跃和是否对外开放筛选; `host_asset_list` 更适合按主机资产视角查看;`web_app_framework_list` 用于按 Web 应用和框架维度筛选。 diff --git a/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_mdr_alert_list.yaml b/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_mdr_alert_list.yaml index 963c4b363..b76b3d047 100644 --- a/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_mdr_alert_list.yaml +++ b/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_mdr_alert_list.yaml @@ -1,4 +1,5 @@ name: tdp_mdr_alert_list +group: NDR description: > 查询 TDP 的 MDR 告警研判相关接口。`indicator` 返回时间范围内的研判指标汇总; `list` 返回研判结果列表,适合按资产类型、严重级别、处置结果和关键词筛选。 diff --git a/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_pcap_download.yaml b/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_pcap_download.yaml index fd4490902..ddf63d5b6 100644 --- a/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_pcap_download.yaml +++ b/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_pcap_download.yaml @@ -1,4 +1,5 @@ name: tdp_pcap_download +group: NDR description: > 下载单个告警日志关联的 PCAP 文件。通常需要从告警日志结果中拿到 `alert_id` 和对应的发生时间 `occ_time` 才能准确下载。 diff --git a/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_platform_config.yaml b/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_platform_config.yaml index dfd7c12f9..5bad94a85 100644 --- a/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_platform_config.yaml +++ b/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_platform_config.yaml @@ -1,4 +1,5 @@ name: tdp_platform_config +group: NDR description: > 管理 TDP 平台配置相关接口,覆盖资产配置、白名单过滤、级联平台管理和自定义规则。 通过 action 可切换到资产列表、资产增删改、白名单搜索与维护、子节点列表、自定义规则和处置日志等子接口; diff --git a/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_policy_settings.yaml b/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_policy_settings.yaml index f5dd917e9..d911c19f0 100644 --- a/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_policy_settings.yaml +++ b/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_policy_settings.yaml @@ -1,4 +1,5 @@ name: tdp_policy_settings +group: NDR description: > 管理 TDP 策略配置相关接口,覆盖自定义情报、IP信誉、旁路阻断、联动阻断、 告警处置状态和处理记录。通过 action 可切换到各子目录下的列表、新增、编辑、 diff --git a/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_privacy_diagram.yaml b/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_privacy_diagram.yaml index 98b558b11..3fc5dcdff 100644 --- a/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_privacy_diagram.yaml +++ b/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_privacy_diagram.yaml @@ -1,4 +1,5 @@ name: tdp_privacy_diagram +group: NDR description: > 查询 TDP 明文敏感信息拓扑概览,返回标签、站点、接口和访问源关系。优先使用顶层语义化参数, 例如敏感标签、HTTP 方法、业务组和 URL/IP 模糊条件;`condition` 仅作为高级兼容入口。 diff --git a/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_system_status.yaml b/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_system_status.yaml index 4ef8e434a..82ec042d0 100644 --- a/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_system_status.yaml +++ b/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_system_status.yaml @@ -1,4 +1,5 @@ name: tdp_system_status +group: NDR description: > 查询 TDP 系统运行状态。默认返回全部状态汇总,也可通过 action 切换到单个子状态接口。 category: custom diff --git a/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_threat_alert_host.yaml b/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_threat_alert_host.yaml index ca11f5091..83e5cb4b6 100644 --- a/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_threat_alert_host.yaml +++ b/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_threat_alert_host.yaml @@ -1,4 +1,5 @@ name: tdp_threat_alert_host +group: NDR description: > 查询 TDP 告警主机相关数据。`alert_host_list` 返回告警主机列表;`host_threat_list` 返回指定告警主机的威胁列表,通常需要先从 `alert_host_list` 结果里取得 `assets_machine`。优先使用顶层语义化参数,不要手写 diff --git a/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_threat_external_attack.yaml b/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_threat_external_attack.yaml index b982f2bb5..7b2d991fd 100644 --- a/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_threat_external_attack.yaml +++ b/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_threat_external_attack.yaml @@ -1,4 +1,5 @@ name: tdp_threat_external_attack +group: NDR 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 index 889c6f9f6..4483609a2 100644 --- 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 @@ -1,4 +1,5 @@ name: tdp_threat_intelligent_aggregation +group: NDR description: > 查询 TDP“威胁-智能聚合”中的聚合攻击事件及其详情。默认查询智能聚合后的攻击事件,也可查询指定聚合事件的 攻击成功统计、攻击时间线、攻击过程告警、结果分布、受攻击实体和攻击者 IP 信息。本 tool 不用于查询 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 0409fa813..8abd490e8 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,4 +1,5 @@ name: tdp_threat_monitor_list +group: NDR description: > 查询 TDP 威胁事件列表,即“威胁-实时监控”中的威胁实时监控列表,对应正式 API `/api/v1/monitor/threat/list`。 查询时间范围不得超过 24 小时;未传时间时 handler 动态使用最近 24 小时。 diff --git a/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_vulnerability_list.yaml b/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_vulnerability_list.yaml index ffcb97353..787cad2b9 100644 --- a/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_vulnerability_list.yaml +++ b/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_vulnerability_list.yaml @@ -1,4 +1,5 @@ name: tdp_vulnerability_list +group: NDR description: > 查询 TDP 脆弱性列表,适合按业务组、严重级别、处置状态和关键词筛选。优先使用顶层语义化参数; `condition` / `page` 仅作为高级兼容入口,避免 Agent 手写嵌套结构。 diff --git a/.flocks/plugins/tools/mcp/nsfocus_mcp.yaml b/.flocks/plugins/tools/mcp/nsfocus_mcp.yaml index 75c2fcc9a..76a3bf77d 100644 --- a/.flocks/plugins/tools/mcp/nsfocus_mcp.yaml +++ b/.flocks/plugins/tools/mcp/nsfocus_mcp.yaml @@ -1,4 +1,5 @@ name: nsfocus_mcp +group: 威胁情报 type: remote url: https://mcp.nsfocus.cn/mcp?apikey={secret:nsfocus_mcp_key} enabled: true diff --git a/.flocks/plugins/tools/mcp/qianxin_mcp.yaml b/.flocks/plugins/tools/mcp/qianxin_mcp.yaml index ac384dfce..3201d4880 100644 --- a/.flocks/plugins/tools/mcp/qianxin_mcp.yaml +++ b/.flocks/plugins/tools/mcp/qianxin_mcp.yaml @@ -1,4 +1,5 @@ name: qianxin_mcp +group: 威胁情报 type: remote url: https://mcp.ti.qianxin.com/ti-stream-mcp?apikey={secret:qianxin_mcp_key} enabled: true diff --git a/.flocks/plugins/tools/mcp/threatbook_mcp.yaml b/.flocks/plugins/tools/mcp/threatbook_mcp.yaml index d3e671dc4..7c83541c0 100644 --- a/.flocks/plugins/tools/mcp/threatbook_mcp.yaml +++ b/.flocks/plugins/tools/mcp/threatbook_mcp.yaml @@ -1,4 +1,5 @@ name: threatbook_mcp +group: 威胁情报 type: remote url: https://mcp.threatbook.cn/mcp?apikey={secret:threatbook_mcp_key} enabled: true diff --git a/.flocks/plugins/workflows/loop_host_forensics_fast/meta.json b/.flocks/plugins/workflows/loop_host_forensics_fast/meta.json index 060000557..b9edd97cf 100644 --- a/.flocks/plugins/workflows/loop_host_forensics_fast/meta.json +++ b/.flocks/plugins/workflows/loop_host_forensics_fast/meta.json @@ -3,6 +3,7 @@ "nameCn": "批量主机快速巡检工作流", "description": "从文件或 inputs 读取主机列表,循环调用 host-forensics-fast 子 Agent;每台主机结果立即落盘为独立文件,末步仅生成轻量索引与清单,避免全量 summary 超时", "category": "default", + "group": "安全研判", "status": "active", "createdBy": null, "createdAt": 1775787114059, diff --git a/.flocks/plugins/workflows/tdp_alert_triage/meta.json b/.flocks/plugins/workflows/tdp_alert_triage/meta.json new file mode 100644 index 000000000..de9e74147 --- /dev/null +++ b/.flocks/plugins/workflows/tdp_alert_triage/meta.json @@ -0,0 +1,3 @@ +{ + "group": "安全研判" +} diff --git a/flocks/agent/agent.py b/flocks/agent/agent.py index 12f547b01..46f47bc2a 100644 --- a/flocks/agent/agent.py +++ b/flocks/agent/agent.py @@ -16,7 +16,8 @@ from dataclasses import dataclass, field from typing import Any, Dict, List, Literal, Optional -from pydantic import BaseModel, Field, model_validator +from pydantic import BaseModel, Field, field_validator, model_validator +from pydantic_core import PydanticCustomError from flocks.permission import Ruleset @@ -100,6 +101,20 @@ class AvailableWorkflow: # Core agent configuration model # --------------------------------------------------------------------------- +def normalize_agent_group(value: Any) -> str: + """Validate the optional display group on an agent's own definition.""" + if value is None: + return "" + if not isinstance(value, str): + raise PydanticCustomError("group_type", "group must be a string or null") + value = value.strip() + if any(ord(char) < 32 or ord(char) == 127 for char in value): + raise PydanticCustomError("group_control", "group must not contain control characters") + if len(value) > 32: + raise PydanticCustomError("group_length", "group must be at most 32 characters") + return value + + class AgentInfo(BaseModel): """ Complete agent configuration. @@ -114,6 +129,9 @@ class AgentInfo(BaseModel): # Chinese display name for localized UI. The canonical ``name`` remains the # stable identifier used by tools, routing, storage, and @mentions. name_cn: Optional[str] = None + group: Optional[str] = None + # Computed from the selected definition's package-anchored source, not native. + group_readonly: bool = False description: Optional[str] = None # Chinese UI label; English ``description`` is used for delegation prompts / tooling. description_cn: Optional[str] = None @@ -160,6 +178,8 @@ class AgentInfo(BaseModel): # which the Web UI uses to decide whether to surface the agent. tags: List[str] = Field(default_factory=list) + _validate_group = field_validator("group", mode="before")(normalize_agent_group) + @model_validator(mode="after") def _derive_delegatable(self) -> "AgentInfo": if self.delegatable is None: diff --git a/flocks/agent/agent_factory.py b/flocks/agent/agent_factory.py index 1a70dd38a..934b7597b 100644 --- a/flocks/agent/agent_factory.py +++ b/flocks/agent/agent_factory.py @@ -30,7 +30,7 @@ import yaml -from flocks.agent.agent import AgentInfo, AgentModel, AgentPromptMetadata, DelegationTrigger +from flocks.agent.agent import AgentInfo, AgentModel, AgentPromptMetadata, DelegationTrigger, normalize_agent_group from flocks.agent.toolset import resolve_agent_initial_tools from flocks.utils.log import Log @@ -38,6 +38,16 @@ # Directory containing built-in agent folders _BUILTIN_AGENTS_DIR = Path(__file__).parent / "agents" +_SYSTEM_AGENT_ROOTS = ( + Path(__file__).resolve().parent / "agents", + Path(__file__).resolve().parents[2] / ".flocks" / "plugins" / "agents", +) + + +def is_system_agent_definition(path: Path) -> bool: + """Only definitions shipped at the installation location have a fixed group.""" + resolved = path.resolve() + return any(resolved.is_relative_to(root.resolve()) for root in _SYSTEM_AGENT_ROOTS) # Default plugin root (same as plugin loader convention) try: @@ -172,6 +182,8 @@ def load_agent(agent_dir: Path, native: bool = False) -> Optional[AgentInfo]: name=name, name_cn=name_cn, description=raw.get("description"), + group=raw.get("group"), + group_readonly=is_system_agent_definition(yaml_path), description_cn=desc_cn, mode=raw.get("mode", "subagent"), native=native, @@ -437,6 +449,8 @@ def yaml_to_agent_info(raw: dict, yaml_path: Path) -> AgentInfo: name=name, name_cn=name_cn, description=raw.get("description"), + group=raw.get("group"), + group_readonly=is_system_agent_definition(yaml_path), description_cn=desc_cn, mode=raw.get("mode", "subagent"), native=False, @@ -485,6 +499,13 @@ def update_yaml_agent(name: str, updates: Dict[str, Any]) -> bool: try: data = _read_yaml_raw(path) + updates = dict(updates) + if "group" in updates: + updates["group"] = normalize_agent_group(updates["group"]) + if is_system_agent_definition(path): + if updates["group"] != normalize_agent_group(data.get("group")): + raise ValueError("System agent group is read-only") + updates.pop("group") prompt_update = updates.pop("prompt", None) if prompt_update is not None: diff --git a/flocks/agent/agents/explore/agent.yaml b/flocks/agent/agents/explore/agent.yaml index 55c6048dc..6a5f65c42 100644 --- a/flocks/agent/agents/explore/agent.yaml +++ b/flocks/agent/agents/explore/agent.yaml @@ -1,4 +1,5 @@ name: explore +group: 系统辅助 name_cn: 代码探索智能体 description: >- Fast agent specialized for exploring codebases. Use this when you need to diff --git a/flocks/agent/agents/hephaestus/agent.yaml b/flocks/agent/agents/hephaestus/agent.yaml index feacd0ca1..29aebaf31 100644 --- a/flocks/agent/agents/hephaestus/agent.yaml +++ b/flocks/agent/agents/hephaestus/agent.yaml @@ -1,4 +1,5 @@ name: hephaestus +group: 系统辅助 name_cn: 深度执行智能体 description: >- Autonomous deep worker. Explores thoroughly with direct tools before acting diff --git a/flocks/agent/agents/librarian/agent.yaml b/flocks/agent/agents/librarian/agent.yaml index c65380ba9..594e6937f 100644 --- a/flocks/agent/agents/librarian/agent.yaml +++ b/flocks/agent/agents/librarian/agent.yaml @@ -1,4 +1,5 @@ name: librarian +group: 系统辅助 name_cn: 资料检索智能体 description: >- Specialized codebase understanding agent for multi-repository analysis, diff --git a/flocks/agent/agents/multimodal_looker/agent.yaml b/flocks/agent/agents/multimodal_looker/agent.yaml index 4f7b9fe58..2982a48bc 100644 --- a/flocks/agent/agents/multimodal_looker/agent.yaml +++ b/flocks/agent/agents/multimodal_looker/agent.yaml @@ -1,4 +1,5 @@ name: multimodal-looker +group: 系统辅助 name_cn: 多模态分析智能体 description: >- Analyze media files (PDFs, images, diagrams) that require interpretation diff --git a/flocks/agent/agents/oracle/agent.yaml b/flocks/agent/agents/oracle/agent.yaml index 01010a42e..7b7e49ec3 100644 --- a/flocks/agent/agents/oracle/agent.yaml +++ b/flocks/agent/agents/oracle/agent.yaml @@ -1,4 +1,5 @@ name: oracle +group: 系统辅助 name_cn: 架构顾问智能体 description: >- Read-only consultation agent. Reasoning specialist for debugging diff --git a/flocks/agent/agents/prometheus/agent.yaml b/flocks/agent/agents/prometheus/agent.yaml index df7ba4ff2..fe2924876 100644 --- a/flocks/agent/agents/prometheus/agent.yaml +++ b/flocks/agent/agents/prometheus/agent.yaml @@ -1,4 +1,5 @@ name: prometheus +group: 系统辅助 name_cn: 任务规划智能体 description: >- Strategic planner. Clarifies scope through interview-style questions, diff --git a/flocks/agent/agents/rex/agent.yaml b/flocks/agent/agents/rex/agent.yaml index 6030d896c..5c88bf7b6 100644 --- a/flocks/agent/agents/rex/agent.yaml +++ b/flocks/agent/agents/rex/agent.yaml @@ -1,4 +1,5 @@ name: rex +group: 系统辅助 name_cn: Rex 主智能体 description: >- Powerful AI orchestrator for security operations. Analyzes threats, diff --git a/flocks/agent/agents/rex_junior/agent.yaml b/flocks/agent/agents/rex_junior/agent.yaml index dba253a1b..ce26fc055 100644 --- a/flocks/agent/agents/rex_junior/agent.yaml +++ b/flocks/agent/agents/rex_junior/agent.yaml @@ -1,4 +1,5 @@ name: rex-junior +group: 系统辅助 name_cn: Rex 执行智能体 description: "Focused task executor. A general-purpose agent for simple tasks." mode: subagent diff --git a/flocks/agent/agents/self_improve/agent.yaml b/flocks/agent/agents/self_improve/agent.yaml index 533aaedf8..0fa88a822 100644 --- a/flocks/agent/agents/self_improve/agent.yaml +++ b/flocks/agent/agents/self_improve/agent.yaml @@ -1,4 +1,5 @@ name: self-improve +group: 系统辅助 description: Hidden Dream Agent that improves durable Memory and reusable user Skills. mode: subagent hidden: true diff --git a/flocks/agent/registry.py b/flocks/agent/registry.py index 75bcf50ef..0811078d1 100644 --- a/flocks/agent/registry.py +++ b/flocks/agent/registry.py @@ -37,6 +37,7 @@ AvailableSkill, AvailableWorkflow, DelegationTrigger, + normalize_agent_group, ) import flocks.agent.delegatable_settings as delegatable_settings from flocks.agent.toolset import agent_declares_tool @@ -162,6 +163,7 @@ def _storage_custom_agent_to_info(agent_data: Dict[str, Any]) -> Optional[AgentI return AgentInfo( name=name, + group=agent_data.get("group"), name_cn=agent_data.get("name_cn") or agent_data.get("nameCn"), description=agent_data.get("description"), description_cn=agent_data.get("description_cn") or agent_data.get("descriptionCn"), @@ -328,6 +330,12 @@ def _consume_agents(agents: list, source: str) -> None: project_dir=Path.cwd(), ) + # Load complete Storage definitions before applying cfg.agent overlays. + # Otherwise a group-only override creates an empty agent and masks the + # stored prompt/model/tools on the next reload. + storage_custom_agents = await _load_storage_custom_agents(set(result.keys())) + result.update(storage_custom_agents) + # User overrides from cfg.agent default_llm = await Config.resolve_default_llm() default_model_id = default_llm["model_id"] if default_llm else None @@ -364,6 +372,14 @@ def _permission_dict_to_tools(permission_cfg: Dict[str, Any]) -> List[str]: item = result.get(key) if not item: + # Metadata overlays cannot turn a removed/missing definition + # into an executable empty agent. Empty serialized defaults + # (notably options={}) are not definition fields either. + if value.group is not None and not any( + field != "group" and setting not in (None, {}, []) + for field, setting in value.model_dump(exclude_defaults=True).items() + ): + continue item = AgentInfo( name=key, mode="all", @@ -402,8 +418,6 @@ def _permission_dict_to_tools(permission_cfg: Dict[str, Any]) -> List[str]: if isinstance(value.permission, dict): item.tools = _permission_dict_to_tools(value.permission) - storage_custom_agents = await _load_storage_custom_agents(set(result.keys())) - result.update(storage_custom_agents) _apply_delegatable_overrides(result) # enabled_agents whitelist filter @@ -425,6 +439,15 @@ def _permission_dict_to_tools(permission_cfg: Dict[str, Any]) -> List[str]: # Merge runtime-registered custom agents result.update(Agent._custom_agents) + # Group overlays also apply to runtime/Storage registrations. Copy the + # result rather than mutating the registered source definition. + for key, value in (cfg.agent or {}).items(): + key = AGENT_ALIASES.get(key, key) + if key in result and value.group is not None and not result[key].group_readonly: + result[key] = result[key].model_copy( + update={"group": normalize_agent_group(value.group)} + ) + # Share reference with metadata query functions _set_agents_ref(result) @@ -466,7 +489,7 @@ def _sync_skill_settings_cache(cls) -> None: # Record the new mtime first to avoid a redundant invalidation on # the very next call in the same worker. cls._skill_settings_mtime = current_mtime - cls._state_accessor.invalidate() # type: ignore[attr-defined] + cls._state_accessor.invalidate_all() # type: ignore[attr-defined] except Exception: pass @@ -480,7 +503,7 @@ def _sync_delegatable_settings_cache(cls) -> None: current_mtime = sentinel.stat().st_mtime if current_mtime > cls._delegatable_settings_mtime: cls._delegatable_settings_mtime = current_mtime - cls._state_accessor.invalidate() # type: ignore[attr-defined] + cls._state_accessor.invalidate_all() # type: ignore[attr-defined] except Exception: pass @@ -498,12 +521,12 @@ async def refresh(cls) -> Dict[str, AgentInfo]: @classmethod def invalidate_cache(cls) -> None: - """Invalidate cached agent state. + """Invalidate cached agent state in every directory of this process. Dynamic agent prompts depend on the current tool registry, so tool plugin refreshes also need a lightweight way to invalidate agents. """ - cls._state_accessor.invalidate() # type: ignore[attr-defined] + cls._state_accessor.invalidate_all() # type: ignore[attr-defined] # ── Lookup ────────────────────────────────────────────────────────────── @@ -513,6 +536,30 @@ async def get(cls, agent: str) -> Optional[AgentInfo]: resolved = AGENT_ALIASES.get(agent, agent) return agents.get(resolved) + @classmethod + async def get_group_definition(cls, name: str) -> Optional[AgentInfo]: + """Resolve selected group authority even for disabled/filtered YAML agents. + + There is no separate ownership cache: active agents reuse their loaded + metadata; disabled definitions use the same first-wins native discovery. + """ + agent = await cls.get(name) + if agent is not None: + return agent + definitions = await asyncio.to_thread(scan_and_load) + return definitions.get(AGENT_ALIASES.get(name, name)) + + @classmethod + async def validate_group_settings(cls, updates: Dict[str, Any]) -> None: + """Reject explicit fixed-group changes before generic config side effects.""" + for name, settings in updates.items(): + if not isinstance(settings, dict) or "group" not in settings: + continue + agent = await cls.get_group_definition(name) + if agent is not None and agent.group_readonly: + if normalize_agent_group(settings["group"]) != normalize_agent_group(agent.group): + raise ValueError(f"System agent group is read-only: {name}") + @classmethod async def list(cls) -> List[AgentInfo]: cfg = await Config.get() diff --git a/flocks/config/config.py b/flocks/config/config.py index fc4a88dc4..e3fc6bda4 100644 --- a/flocks/config/config.py +++ b/flocks/config/config.py @@ -133,6 +133,11 @@ def migrate_legacy_permissions(cls, data): return migrated +def _preserve_group_clear(value: Any) -> Any: + """An explicit null group must survive exclude-none config serialization.""" + return "" if value is None else value + + # ==================== Agent Configuration ==================== class AgentConfig(BaseModel): @@ -144,6 +149,8 @@ class AgentConfig(BaseModel): model_config = {"extra": "allow", "populate_by_name": True} # Allow unknown fields and populate by alias name: Optional[str] = None + group: Optional[str] = None + _normalize_group_clear = field_validator("group", mode="before")(_preserve_group_clear) model: Optional[str] = None temperature: Optional[float] = None top_p: Optional[float] = None @@ -262,7 +269,9 @@ class McpOAuthConfig(BaseModel): class McpLocalConfig(BaseModel): """MCP local server configuration""" model_config = {"extra": "allow", "populate_by_name": True} - + + group: Optional[str] = None + _normalize_group_clear = field_validator("group", mode="before")(_preserve_group_clear) type: Literal["local"] command: List[str] environment: Optional[Dict[str, str]] = Field( @@ -276,7 +285,9 @@ class McpLocalConfig(BaseModel): class McpRemoteConfig(BaseModel): """MCP remote server configuration""" model_config = {"extra": "allow"} - + + group: Optional[str] = None + _normalize_group_clear = field_validator("group", mode="before")(_preserve_group_clear) type: Literal["remote", "sse"] url: str enabled: Optional[bool] = None @@ -887,6 +898,29 @@ def normalize_fallback_providers(cls, value: Any) -> Any: description="Console portal base URL used by OSS console account login redirect.", ) + @model_validator(mode="before") + @classmethod + def preserve_explicit_plugin_group_clears(cls, data: Any) -> Any: + """Keep explicit null groups through the exclude-none config merge/save. + + An omitted group inherits, but null is a clear, represented on disk by + the empty string. Only native plugin metadata is affected; connection + fields and unrelated config retain their existing None semantics. + """ + if not isinstance(data, dict): + return data + normalized = dict(data) + for section in ("agent", "mode", "mcp", "api_services", "tool_settings"): + entries = data.get(section) + if not isinstance(entries, dict): + continue + entries = dict(entries) + for name, entry in entries.items(): + if isinstance(entry, dict) and "group" in entry and entry["group"] is None: + entries[name] = {**entry, "group": ""} + normalized[section] = entries + return normalized + @model_validator(mode="before") @classmethod def remove_legacy_delegate_categories(cls, data): diff --git a/flocks/config/config_writer.py b/flocks/config/config_writer.py index 51f3932c9..3cb76b693 100644 --- a/flocks/config/config_writer.py +++ b/flocks/config/config_writer.py @@ -590,6 +590,32 @@ def set_fallback_providers( "count": len(fallbacks), }) + # ------------------------------------------------------------------ + # Agent metadata (agent section) + # ------------------------------------------------------------------ + + @classmethod + def set_agent_group(cls, name: str, group: Optional[str]) -> None: + """Update one validated Agent's group without resolving other config.""" + from flocks.agent.agent import normalize_agent_group + + if not name: + raise ValueError("Agent name must be a non-empty string") + value = normalize_agent_group(group) or "" + path = cls._get_config_path() + data = cls._read_path_raw(path, strict=True) + if not isinstance(data, dict): + raise ValueError("Configuration must be an object") + agents = data.get("agent", {}) + if not isinstance(agents, dict): + raise ValueError("agent configuration must be an object") + existing = agents.get(name, {}) + if not isinstance(existing, dict): + raise ValueError(f"Agent configuration must be an object: {name}") + data["agent"] = {**agents, name: {**existing, "group": value}} + cls._write_raw(data, path) + log.info("config_writer.agent_group_updated", {"name": name}) + # ------------------------------------------------------------------ # MCP server CRUD (mcp section) # ------------------------------------------------------------------ @@ -614,11 +640,23 @@ def add_mcp_server(cls, name: str, server_config: Dict[str, Any]) -> None: name: MCP server name (key in mcp section) server_config: Full server config dict (McpLocalConfig or McpRemoteConfig) """ - data = cls._read_raw() - if "mcp" not in data: - data["mcp"] = {} - data["mcp"][name] = server_config - cls._write_raw(data) + from flocks.mcp.types import normalize_mcp_group + + path = cls._get_config_path() + data = cls._read_path_raw(path, strict=True) + if not isinstance(data, dict): + raise ValueError("Configuration must be an object") + mcp = data.get("mcp", {}) + if not isinstance(mcp, dict): + raise ValueError("mcp configuration must be an object") + existing = mcp.get(name) + incoming = dict(server_config) + if "group" in incoming: + incoming["group"] = normalize_mcp_group(incoming["group"]) or "" + elif isinstance(existing, dict) and "group" in existing: + incoming["group"] = existing["group"] if existing["group"] is not None else "" + data["mcp"] = {**mcp, name: incoming} + cls._write_raw(data, path) log.info("config_writer.mcp_server_added", {"name": name}) @classmethod @@ -645,6 +683,10 @@ def update_mcp_server_field(cls, name: str, field: str, value: Any) -> bool: Returns: True if the server existed and the field was updated. """ + if field == "group": + from flocks.mcp.types import normalize_mcp_group + + value = normalize_mcp_group(value) or "" data = cls._read_raw() mcp = data.get("mcp", {}) if name not in mcp: @@ -762,7 +804,7 @@ def remove_api_service(cls, service_id: str) -> bool: # Tool settings (tool_settings section) # ------------------------------------------------------------------ # - # User-level overlay for per-tool settings (currently: ``enabled``). + # User-level overlay for per-tool settings (``enabled`` and native ``group``). # The section mirrors ``model_settings`` for naming consistency — # both are flat maps keyed by the entity's unique id. # @@ -801,25 +843,38 @@ def set_tool_setting(cls, tool_name: str, setting: Dict[str, Any]) -> None: """ if not tool_name: raise ValueError("tool_name must be a non-empty string") - data = cls._read_raw() + incoming = dict(setting or {}) + if "group" in incoming: + from flocks.tool.registry import ToolRegistry, normalize_tool_group + + incoming["group"] = normalize_tool_group(incoming["group"]) or "" + ToolRegistry.validate_group_settings({tool_name: incoming}) + path = cls._get_config_path() + data = cls._read_path_raw(path, strict="group" in incoming) + if not isinstance(data, dict): + raise ValueError("Configuration must be an object") settings = data.get("tool_settings") if not isinstance(settings, dict): + if "group" in incoming and settings is not None: + raise ValueError("tool_settings configuration must be an object") settings = {} existing = settings.get(tool_name) if not isinstance(existing, dict): + if "group" in incoming and existing is not None: + raise ValueError(f"Tool setting must be an object: {tool_name}") existing = {} - merged = {**existing, **(setting or {})} + merged = {**existing, **incoming} settings[tool_name] = merged data["tool_settings"] = settings - cls._write_raw(data) + cls._write_raw(data, path) log.info("config_writer.tool_setting_set", { "tool": tool_name, "fields": sorted(merged.keys()), }) @classmethod - def delete_tool_setting(cls, tool_name: str) -> bool: - """Remove the tool_settings[tool_name] entry. + def delete_tool_setting(cls, tool_name: str, *, field: Optional[str] = None) -> bool: + """Remove a tool setting, or only ``field`` while retaining other metadata. Pops the whole ``tool_settings`` key when the last entry is removed so flocks.json doesn't accumulate empty container objects @@ -831,7 +886,15 @@ def delete_tool_setting(cls, tool_name: str) -> bool: settings = data.get("tool_settings") if not isinstance(settings, dict) or tool_name not in settings: return False - del settings[tool_name] + if field is not None: + entry = settings[tool_name] + if not isinstance(entry, dict) or field not in entry: + return False + del entry[field] + if not entry: + del settings[tool_name] + else: + del settings[tool_name] if settings: data["tool_settings"] = settings else: diff --git a/flocks/hub/installer.py b/flocks/hub/installer.py index eb894126c..ad2065238 100644 --- a/flocks/hub/installer.py +++ b/flocks/hub/installer.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import json import shutil import sys import tempfile @@ -176,13 +177,21 @@ def _rollback_replacement(dst: Path, backup: Path | None) -> None: _replace_with_retry(backup, dst) -def _copy_package(src: Path, dst: Path, *, retain_backup: bool = False) -> Path | None: +def _copy_package( + src: Path, + dst: Path, + *, + retain_backup: bool = False, + plugin_type: PluginType | None = None, + scope: str = "global", +) -> Path | None: parent = dst.parent parent.mkdir(parents=True, exist_ok=True) _purge_stale_scratch(parent, dst.name) tmp = Path(tempfile.mkdtemp(prefix=f".{dst.name}.", dir=str(parent))) try: _copy_package_contents(src, tmp) + _preserve_native_group_metadata(plugin_type, dst, tmp, scope=scope) backup = _replace_prepared_path(tmp, dst) if not retain_backup: _commit_replacement(backup) @@ -205,6 +214,165 @@ def _copy_package_contents(src: Path, dst: Path) -> None: shutil.copy2(item, target) +def _preserve_native_group_metadata( + plugin_type: PluginType | None, previous: Path, prepared: Path, *, scope: str, +) -> None: + """Retain one editable native attribute in the existing package staging step. + + Configuration-backed groups and Device instance records are outside package + replacement. No group inventory or membership data is created here. + """ + if plugin_type == "skill": + from flocks.skill.skill import Skill + + new_file = prepared / "SKILL.md" + if new_file.is_file(): + content = new_file.read_bytes().decode("utf-8") + preserved = Skill.preserve_install_group( + previous / "SKILL.md", content, scope=scope, name=previous.name, + ) + if preserved != content: + new_file.write_bytes(preserved.encode("utf-8")) + return + + if plugin_type == "workflow": + from flocks.workflow.fs_store import ( + is_system_workflow_definition, normalize_workflow_group, patch_workflow_metadata, + ) + + if is_system_workflow_definition(previous): + current = json.loads((previous / "meta.json").read_text(encoding="utf-8")) + fixed_group = normalize_workflow_group(current.get("group")) + for path in (prepared / "meta.json", prepared / "workflow.json"): + if not path.is_file(): + continue + incoming = json.loads(path.read_text(encoding="utf-8")) + if path.name == "workflow.json": + incoming = incoming.get("metadata", {}) + if "group" in incoming and normalize_workflow_group(incoming["group"]) != fixed_group: + raise ValueError("System workflow group is read-only") + patch_workflow_metadata(prepared, {"group": fixed_group}, workflow_id=previous.name) + return + if previous.is_symlink(): + return + old_meta = previous / "meta.json" + if old_meta.is_file() and not old_meta.is_symlink(): + metadata = json.loads(old_meta.read_text(encoding="utf-8")) + if isinstance(metadata, dict) and "group" in metadata: + patch_workflow_metadata(prepared, {"group": metadata["group"]}, workflow_id=previous.name) + return + # Package interchange may carry the default in workflow JSON. Once + # installed, meta.json remains the sole locally editable authority. + new_meta = prepared / "meta.json" + if new_meta.is_file(): + metadata = json.loads(new_meta.read_text(encoding="utf-8")) + if isinstance(metadata, dict) and "group" in metadata: + return + definition = prepared / "workflow.json" + if definition.is_file(): + metadata = json.loads(definition.read_text(encoding="utf-8")).get("metadata", {}) + if isinstance(metadata, dict) and "group" in metadata: + patch_workflow_metadata(prepared, {"group": metadata["group"]}, workflow_id=previous.name) + return + + if plugin_type not in {"agent", "tool"}: + return + import yaml + + if plugin_type == "agent": + from flocks.agent.agent import normalize_agent_group + from flocks.agent.agent_factory import is_system_agent_definition + + old_file, new_file = previous / "agent.yaml", prepared / "agent.yaml" + if not old_file.is_file() or not new_file.is_file(): + return + protected = is_system_agent_definition(old_file) + if not protected and (previous.is_symlink() or old_file.is_symlink()): + return + old_data = yaml.safe_load(old_file.read_text(encoding="utf-8")) + if not isinstance(old_data, dict) or "group" not in old_data: + return + new_data = yaml.safe_load(new_file.read_text(encoding="utf-8")) + if not isinstance(new_data, dict): + raise ValueError("Expected native agent metadata") + try: + value = normalize_agent_group(old_data["group"]) + except ValueError as exc: + raise ValueError("Invalid group metadata in agent.yaml") from exc + if protected and "group" in new_data and normalize_agent_group(new_data["group"]) != value: + raise ValueError("System agent group is read-only") + new_data["group"] = value + new_file.write_text(yaml.safe_dump(new_data, allow_unicode=True, sort_keys=False), encoding="utf-8") + return + + from flocks.tool.registry import is_shipped_tool_path, normalize_tool_group + + old_tools = _native_yaml_tools(previous, previous, scope=scope) + new_tools = _native_yaml_tools(prepared, previous, scope=scope) + for name, (old_file, old_data) in old_tools.items(): + protected = is_shipped_tool_path(old_file) + if name not in new_tools: + if protected: + raise ValueError(f"System tool '{name}' cannot be removed by installation") + continue + if "group" not in old_data or (previous.is_symlink() and not protected): + continue + new_file, new_data = new_tools[name] + value = normalize_tool_group(old_data["group"]) + if protected and "group" in new_data and normalize_tool_group(new_data["group"]) != value: + raise ValueError(f"System tool '{name}' group is read-only") + new_data["group"] = value + new_file.write_text(yaml.safe_dump(new_data, allow_unicode=True, sort_keys=False), encoding="utf-8") + + +def _native_yaml_tools(package: Path, destination: Path, *, scope: str) -> dict[str, tuple[Path, dict]]: + """Read only discoverable YAML tool identities, without importing handlers. + + Native discovery scans tools/ at depth two, so an api/provider install has + no remaining child depth while a flat package has one. Staging directory + names do not change that budget or the top-level subsystem exclusions. + """ + import yaml + from flocks.plugin.loader import scan_directory + + try: + relative = destination.resolve().relative_to(local.install_root("tool", scope).resolve()) + except ValueError: + # Direct package-copy callers may specify another project's native + # target rather than the active request's install root. + native_root = next(( + parent for parent in destination.parents + if parent.name == "tools" and parent.parent.name == "plugins" and parent.parent.parent.name == ".flocks" + ), None) + if native_root is not None: + relative = destination.relative_to(native_root) + else: + relative = Path(destination.parent.name, destination.name) if destination.parent.name in _TOOL_TYPE_DIRS else Path(destination.name) + if any(part.startswith("_") for part in relative.parts) or (relative.parts and relative.parts[0] in {"mcp", "generated"}): + return {} + depth = 2 - len(relative.parts) + if depth < 0: + return {} + tools: dict[str, tuple[Path, dict]] = {} + for filename in scan_directory( + package, recursive=True, max_depth=depth, + exclude_subdirs={"mcp", "generated"} if not relative.parts else set(), + ): + path = Path(filename) + if path.suffix not in {".yaml", ".yml"} or path.is_symlink() or not path.resolve().is_relative_to(package.resolve()): + continue + try: + data = yaml.safe_load(path.read_text(encoding="utf-8")) + except yaml.YAMLError: + continue # Invalid YAML is not loaded as a native tool either. + if not isinstance(data, dict) or not isinstance(data.get("name"), str) or not data["name"]: + continue + if not any(isinstance(data.get(key), dict) and data[key] for key in ("handler", "execution")): + continue # Config/provider/fixture YAML is not tool metadata. + tools.setdefault(data["name"], (path, data)) # Native duplicate rule: first wins. + return tools + + def _contracts_access_dir(plugin_id: str, scope: str) -> Path: return local.install_root("webui", scope).parent / "access" / plugin_id @@ -599,7 +767,7 @@ async def install_plugin( retain_backup=True, ) else: - package_backup = _copy_package(src, dst, retain_backup=True) + package_backup = _copy_package(src, dst, retain_backup=True, plugin_type=plugin_type, scope=scope) package_replaced = True access_replacement = _copy_attached_access_contracts( plugin_type, diff --git a/flocks/mcp/catalog.py b/flocks/mcp/catalog.py index 92800bb4e..7bacc1626 100644 --- a/flocks/mcp/catalog.py +++ b/flocks/mcp/catalog.py @@ -9,10 +9,11 @@ from pathlib import Path from typing import Any, Dict, List, Optional, Literal -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator from flocks.config.config import Config from flocks.mcp.installer import rewrite_local_command_for_managed_python +from flocks.mcp.types import normalize_mcp_group from flocks.utils.log import Log log = Log.create(service="mcp.catalog") @@ -112,6 +113,9 @@ class CatalogEntry(BaseModel): system_deps: List[str] = Field(default_factory=list) tags: List[str] = Field(default_factory=list) official: bool = False + group: Optional[str] = None + group_readonly: bool = False + _validate_group = field_validator("group", mode="before")(normalize_mcp_group) @property def tool_type(self) -> str: @@ -281,7 +285,17 @@ def _reload_if_changed(self) -> None: pass def _load(self) -> None: - """Load catalog data from JSON file.""" + """Load mutable catalog details, projecting only shipped canonical groups.""" + from flocks.config.config_writer import _get_example_config_dir + + shipped_groups: Dict[str, Optional[str]] = {} + shipped_path = _get_example_config_dir() / "mcp_list.json.example" + if shipped_path.is_file(): + shipped = json.loads(shipped_path.read_text(encoding="utf-8")) + shipped_groups = { + entry["id"]: normalize_mcp_group(entry.get("group")) + for entry in shipped.get("servers", []) + } try: catalog_path = _resolve_catalog_file() self._catalog_path = catalog_path @@ -294,6 +308,9 @@ def _load(self) -> None: self._categories[cat_id] = CategoryInfo(**cat_data) for entry_data in raw.get("servers", []): + entry_data["group_readonly"] = entry_data.get("id") in shipped_groups + if entry_data["group_readonly"]: + entry_data["group"] = shipped_groups[entry_data["id"]] env_vars_raw = entry_data.get("env_vars", {}) parsed_env: Dict[str, EnvVarSpec] = {} for k, v in env_vars_raw.items(): diff --git a/flocks/mcp/types.py b/flocks/mcp/types.py index 2e0654584..36a9963fa 100644 --- a/flocks/mcp/types.py +++ b/flocks/mcp/types.py @@ -6,10 +6,25 @@ from typing import Optional, Dict, Any, List from enum import Enum -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator +from pydantic_core import PydanticCustomError import time +def normalize_mcp_group(value: Optional[str]) -> Optional[str]: + """Normalize native server grouping metadata.""" + if value is None: + return None + if not isinstance(value, str): + raise PydanticCustomError("group_type", "group must be a string or null") + value = value.strip() + if len(value) > 32: + raise PydanticCustomError("group_length", "group must be at most 32 characters") + if any(ord(char) < 32 or ord(char) == 127 for char in value): + raise PydanticCustomError("group_control", "group cannot contain control characters") + return value + + class McpStatus(str, Enum): """MCP server connection status""" CONNECTING = "connecting" @@ -23,6 +38,8 @@ class McpStatus(str, Enum): class McpStatusInfo(BaseModel): """MCP status information""" status: McpStatus + group: Optional[str] = None + group_readonly: bool = False error: Optional[str] = None connected_at: Optional[float] = None tools_count: int = 0 @@ -97,6 +114,9 @@ class ServerConfig(BaseModel): env: Optional[Dict[str, str]] = None # optional for local cwd: Optional[str] = None # optional for local enabled: bool = True + group: Optional[str] = None + _validate_group = field_validator("group", mode="before")(normalize_mcp_group) + timeout: float = 30.0 auth: Optional[Dict[str, Any]] = None retry: Optional[RetryConfig] = None diff --git a/flocks/project/instance.py b/flocks/project/instance.py index 40f5e6025..9e5ff6dfe 100644 --- a/flocks/project/instance.py +++ b/flocks/project/instance.py @@ -62,8 +62,9 @@ def create( Returns: Function that returns the state value. - The returned function also has an ``invalidate()`` method that - removes the cached state so the next access triggers re-init. + ``invalidate()`` removes the current directory's cached state; + ``invalidate_all()`` removes this accessor's state in every directory. + Subsequent access lazily re-initializes it. """ state_id = self._next_id self._next_id += 1 @@ -93,7 +94,15 @@ def invalidate() -> None: if key in self._disposers: self._disposers[key].pop(state_id, None) + def invalidate_all() -> None: + """Invalidate this accessor in every directory, leaving other states intact.""" + for states in list(self._states.values()): + states.pop(state_id, None) + for disposers in list(self._disposers.values()): + disposers.pop(state_id, None) + accessor.invalidate = invalidate # type: ignore[attr-defined] + accessor.invalidate_all = invalidate_all # type: ignore[attr-defined] return accessor async def dispose(self, key: str) -> None: diff --git a/flocks/server/routes/agent.py b/flocks/server/routes/agent.py index b537da16c..dbf46417d 100644 --- a/flocks/server/routes/agent.py +++ b/flocks/server/routes/agent.py @@ -25,7 +25,7 @@ from typing import List, Optional, Dict, Any from fastapi import APIRouter, HTTPException -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator import flocks.agent.delegatable_settings as delegatable_settings from flocks.agent.registry import Agent @@ -34,8 +34,9 @@ permission_items_to_ruleset, sync_question_permission_with_tools, ) -from flocks.agent.agent import AgentInfo as AgentInfoModel, AgentModel as AgentModelConfig +from flocks.agent.agent import AgentInfo as AgentInfoModel, AgentModel as AgentModelConfig, normalize_agent_group from flocks.agent.agent_factory import find_yaml_agent, read_yaml_agent, update_yaml_agent, delete_yaml_agent +from flocks.server.config_mutation import serialized_config_mutation from flocks.utils.log import Log router = APIRouter() @@ -67,6 +68,8 @@ class AgentResponse(BaseModel): """ name: str nameCn: Optional[str] = None + group: Optional[str] = None + group_readonly: bool = False description: Optional[str] = None descriptionCn: Optional[str] = None mode: str = "primary" @@ -121,6 +124,8 @@ def agent_to_response( return AgentResponse( name=agent.name, + group=agent.group, + group_readonly=agent.group_readonly, nameCn=agent.name_cn, description=agent.description, descriptionCn=agent.description_cn, @@ -154,6 +159,7 @@ def _agent_data_to_info(agent_data: Dict[str, Any]) -> AgentInfoModel: delegatable = mode != "primary" return AgentInfoModel( name=agent_data["name"], + group=agent_data.get("group"), name_cn=agent_data.get("name_cn") or agent_data.get("nameCn"), description=agent_data.get("description") or "", description_cn=agent_data.get("description_cn") or agent_data.get("descriptionCn"), @@ -183,6 +189,7 @@ def _custom_agent_data_to_response(agent_data: Dict[str, Any]) -> AgentResponse: delegatable = mode != "primary" return AgentResponse( name=agent_data["name"], + group=agent_data.get("group"), nameCn=agent_data.get("name_cn") or agent_data.get("nameCn"), description=agent_data.get("description"), descriptionCn=agent_data.get("description_cn") or agent_data.get("descriptionCn"), @@ -364,6 +371,9 @@ async def get_agent_prompt(name: str): class AgentCreateRequest(BaseModel): """Request to create a custom agent""" name: str = Field(..., description="Agent name") + group: Optional[str] = None + _validate_group = field_validator("group", mode="before")(normalize_agent_group) + nameCn: Optional[str] = Field(None, description="Chinese UI agent name") description: Optional[str] = Field(None, description="Agent description (English; used for delegation)") descriptionCn: Optional[str] = Field(None, description="Chinese UI description") @@ -380,6 +390,9 @@ class AgentCreateRequest(BaseModel): class AgentUpdateRequest(BaseModel): """Request to update a custom agent""" + group: Optional[str] = None + _validate_group = field_validator("group", mode="before")(normalize_agent_group) + nameCn: Optional[str] = Field(None, description="Chinese UI agent name") description: Optional[str] = Field(None, description="Agent description (English; used for delegation)") descriptionCn: Optional[str] = Field(None, description="Chinese UI description") @@ -415,6 +428,10 @@ async def create_agent(req: AgentCreateRequest): try: existing = await Agent.get(req.name) + if existing is None: + selected = await Agent.get_group_definition(req.name) + if selected is not None and selected.group_readonly: + existing = selected if existing: raise HTTPException(status_code=409, detail=f"Agent {req.name} already exists") @@ -425,6 +442,7 @@ async def create_agent(req: AgentCreateRequest): ) agent_data: Dict[str, Any] = { "name": req.name, + "group": req.group, "name_cn": req.nameCn, "description": req.description, "description_cn": req.descriptionCn, @@ -453,7 +471,23 @@ async def create_agent(req: AgentCreateRequest): raise HTTPException(status_code=500, detail=str(e)) +@serialized_config_mutation +async def _save_agent_group_override(agent: AgentInfoModel, group: Optional[str]) -> None: + """Patch only the raw group field, preserving references and unknown config.""" + from flocks.config.config_writer import ConfigWriter + + value = normalize_agent_group(group) + if agent.group_readonly: + if value != normalize_agent_group(agent.group): + raise HTTPException(status_code=403, detail="System agent group is read-only") + return + ConfigWriter.set_agent_group(agent.name, value) + # Shared definitions/overrides affect every pre-warmed project context. + Agent.invalidate_cache() + + @router.put("/{name}", response_model=AgentResponse, summary="Update custom agent") +@serialized_config_mutation async def update_agent(name: str, req: AgentUpdateRequest): """ Update a custom agent (Storage-based or YAML plugin). @@ -461,6 +495,31 @@ async def update_agent(name: str, req: AgentUpdateRequest): from flocks.storage.storage import Storage try: + group_agent = None + use_group_override = False + if "group" in req.model_fields_set: + from flocks.config.config import Config + + group_agent = await Agent.get(name) + if group_agent is None: + raise HTTPException(status_code=404, detail=f"Agent {name} not found") + if group_agent.group_readonly: + if req.group != normalize_agent_group(group_agent.group): + raise HTTPException(status_code=403, detail="System agent group is read-only") + if req.model_fields_set == {"group"}: + return await get_agent(name) + # Full edit forms may echo the unchanged system group. Treat it + # as omitted while keeping all pre-existing non-group controls. + req = AgentUpdateRequest.model_validate(req.model_dump(exclude_unset=True, exclude={"group"})) + else: + configured = (await Config.get()).agent.get(group_agent.name) + use_group_override = group_agent.native or ( + configured is not None and configured.group is not None + ) + if use_group_override and req.model_fields_set == {"group"}: + await _save_agent_group_override(group_agent, req.group) + return await get_agent(name) + # --- Try Storage-based custom agent first --- agent_key = f"agent/custom/{name}" agent_data: Optional[Dict[str, Any]] = None @@ -473,6 +532,10 @@ async def update_agent(name: str, req: AgentUpdateRequest): # YAML agents may also have an overlay entry (skills/tools only) which # lacks the "name" key; those should fall through to the YAML path. if agent_data is not None and agent_data.get("name"): + if use_group_override: + await _save_agent_group_override(group_agent, req.group) + if "group" in req.model_fields_set and not use_group_override: + agent_data["group"] = req.group if req.nameCn is not None: agent_data["name_cn"] = req.nameCn if req.description is not None: @@ -508,11 +571,13 @@ async def update_agent(name: str, req: AgentUpdateRequest): AgentRegistry.invalidate_cache() log.info("agent.updated", {"name": name, "source": "storage"}) - return _custom_agent_data_to_response(agent_data) + return await get_agent(name) # --- Fall back to YAML plugin agent --- if find_yaml_agent(name) is not None: updates: Dict[str, Any] = {} + if "group" in req.model_fields_set and not use_group_override: + updates["group"] = req.group if req.nameCn is not None: updates["name_cn"] = req.nameCn if req.description is not None: @@ -530,6 +595,8 @@ async def update_agent(name: str, req: AgentUpdateRequest): if req.delegatable is not None: updates["delegatable"] = req.delegatable + if use_group_override: + await _save_agent_group_override(group_agent, req.group) if not update_yaml_agent(name, updates): raise HTTPException(status_code=500, detail=f"Failed to write YAML for agent {name}") @@ -553,6 +620,8 @@ async def update_agent(name: str, req: AgentUpdateRequest): # Sync: apply updates to the in-memory AgentInfo cache agent = await Agent.get(name) if agent: + if "group" in req.model_fields_set: + agent.group = req.group if req.nameCn is not None: agent.name_cn = req.nameCn if req.description is not None: @@ -577,13 +646,19 @@ async def update_agent(name: str, req: AgentUpdateRequest): agent.permission = permission_items_to_ruleset(extras.get("permission")) elif req.permission is not None: agent.permission = permission_items_to_ruleset(extras.get("permission")) + Agent.invalidate_cache() overrides = await _load_model_overrides() delegatable_overrides = _load_delegatable_overrides() all_tool_names = await _get_all_tool_names_async() return await _build_single_agent_response(agent, overrides, delegatable_overrides, all_tool_names) + Agent.invalidate_cache() yaml_data = read_yaml_agent(name) or {} return _custom_agent_data_to_response(yaml_data) + if group_agent is not None and req.model_fields_set == {"group"}: + await _save_agent_group_override(group_agent, req.group) + return await get_agent(name) + raise HTTPException(status_code=404, detail=f"Custom agent {name} not found") except HTTPException: raise @@ -620,7 +695,7 @@ async def update_agent_delegatable(name: str, req: AgentDelegatableUpdateRequest await AgentRegistry.refresh() log.info("agent.delegatable.updated", {"name": name, "source": "storage", "delegatable": req.delegatable}) - return _custom_agent_data_to_response(agent_data) + return await get_agent(name) delegatable_settings.set_override(name, req.delegatable) await Agent.refresh() @@ -755,7 +830,7 @@ async def update_agent_model(name: str, req: AgentModelUpdateRequest): AgentRegistry.invalidate_cache() log.info("agent.model.updated", {"name": name, "source": "storage"}) - return _custom_agent_data_to_response(agent_data) + return await get_agent(name) # --- Fall back to YAML plugin agent --- if find_yaml_agent(name) is not None: diff --git a/flocks/server/routes/config.py b/flocks/server/routes/config.py index c22364a34..076ddac21 100644 --- a/flocks/server/routes/config.py +++ b/flocks/server/routes/config.py @@ -67,6 +67,48 @@ def _normalize_slack_dm_policy(config_data: Dict[str, Any]) -> None: slack["dmPolicy"] = "open" +async def _validate_plugin_group_updates(config_data: Dict[str, Any]) -> None: + """Check native definition locks before any config or secret side effects.""" + try: + for section in ("agent", "mode"): + entries = config_data.get(section) + if not isinstance(entries, dict): + continue + updates = { + name: entry for name, entry in entries.items() + if isinstance(entry, dict) and "group" in entry + } + if not updates: + continue + from flocks.agent.agent import normalize_agent_group + from flocks.agent.registry import Agent + + for entry in updates.values(): + normalize_agent_group(entry["group"]) + try: + await Agent.validate_group_settings(updates) + except ValueError as exc: + raise HTTPException(status_code=403, detail=str(exc)) from exc + + settings = config_data.get("tool_settings") + if isinstance(settings, dict): + updates = { + name: entry for name, entry in settings.items() + if isinstance(entry, dict) and "group" in entry + } + if updates: + from flocks.tool.registry import ToolRegistry, normalize_tool_group + + for entry in updates.values(): + normalize_tool_group(entry["group"]) + try: + ToolRegistry.validate_group_settings(updates) + except ValueError as exc: + raise HTTPException(status_code=403, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + def _build_model_from_config( provider_id: str, model_id: str, @@ -648,6 +690,7 @@ async def update_config(config_data: Dict[str, Any]) -> Dict[str, Any]: {secret:channel__} references before the config is written to flocks.json, so that plaintext secrets never land in that file. """ + await _validate_plugin_group_updates(config_data) try: channel_allow_from_deletions = _channel_allow_from_deletion_ids(config_data) _normalize_slack_dm_policy(config_data) diff --git a/flocks/server/routes/mcp.py b/flocks/server/routes/mcp.py index ae23feeca..fe72cc9c8 100644 --- a/flocks/server/routes/mcp.py +++ b/flocks/server/routes/mcp.py @@ -14,7 +14,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request, Response from fastapi.responses import JSONResponse -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator from flocks.audit import emit_audit_event from flocks.auth.context import AuthUser @@ -28,6 +28,7 @@ McpServerInfo, ) from flocks.mcp.catalog import McpCatalog +from flocks.mcp.types import normalize_mcp_group from flocks.mcp.auth import McpAuth from flocks.mcp.installer import preflight_install, preflight_uninstall from flocks.mcp.utils import ( @@ -96,6 +97,7 @@ def _to_frontend_mcp_config(server_config: Dict[str, Any]) -> Dict[str, Any]: args = args_value return { "type": transport, + "group": normalize_mcp_group(server_config.get("group")), "url": server_config.get("url"), "command": command, "args": args, @@ -151,6 +153,13 @@ def _load_raw_mcp_server_config(name: str) -> Optional[Dict[str, Any]]: return normalize_mcp_config(server_config) +def _effective_mcp_group(name: str, config: Dict[str, Any]) -> Optional[str]: + if "group" in config: + return normalize_mcp_group(config["group"]) + entry = McpCatalog.get().get_entry(name) + return normalize_mcp_group(getattr(entry, "group", None)) + + async def _build_mcp_status_response() -> Dict[str, Any]: """Merge runtime state with configured-but-not-connected MCP servers.""" status = await MCP.status() @@ -160,26 +169,32 @@ async def _build_mcp_status_response() -> Dict[str, Any]: for name, server_config in configured.items(): if not isinstance(server_config, dict): continue - if name in result: - continue - if server_config.get("enabled", True): - result[name] = McpStatusInfo(status=McpStatus.DISCONNECTED).model_dump() - else: - result[name] = McpStatusInfo(status=McpStatus.DISABLED).model_dump() + if name not in result: + server_status = McpStatus.DISCONNECTED if server_config.get("enabled", True) else McpStatus.DISABLED + result[name] = McpStatusInfo(status=server_status).model_dump() + result[name]["group"] = _effective_mcp_group(name, server_config) return result -def _persist_mcp_server_config(name: str, config: Dict[str, Any]) -> None: - """Persist MCP config to both runtime config and canonical YAML.""" +def _persist_mcp_server_config( + name: str, config: Dict[str, Any], *, metadata_only: bool = False, +) -> None: + """Persist through the native writers, which own omitted-field preservation.""" ConfigWriter.add_mcp_server(name, config) from flocks.tool.tool_loader import save_mcp_config - save_mcp_config(name, config) + if metadata_only: + save_mcp_config(name, config, metadata_only=True) + else: + save_mcp_config(name, config) def _prepare_mcp_config_for_save(name: str, config: Dict[str, Any]) -> Dict[str, Any]: """Normalize config and move any plain-text remote secrets into SecretManager.""" + config = dict(config) + if "group" in config: + config["group"] = normalize_mcp_group(config["group"]) or "" clean_config = extract_api_key_from_mcp_url(name, normalize_mcp_config(config)) clean_config = extract_auth_value_from_mcp_config(name, clean_config) clean_config = extract_sensitive_headers_from_mcp_config(name, clean_config) @@ -193,6 +208,13 @@ class McpAddRequest(BaseModel): name: str = Field(..., description="Server name") config: Dict[str, Any] = Field(..., description="Server configuration (McpLocalConfig or McpRemoteConfig)") + @field_validator("config") + @classmethod + def validate_config_group(cls, config): + if "group" in config: + config = {**config, "group": normalize_mcp_group(config["group"]) or ""} + return config + class McpAuthCallbackRequest(BaseModel): """OAuth callback request""" @@ -310,6 +332,13 @@ class McpUpdateRequest(BaseModel): """Request to update an existing MCP server configuration.""" config: Dict[str, Any] = Field(..., description="Partial or full MCP server configuration") + @field_validator("config") + @classmethod + def validate_config_group(cls, config): + if "group" in config: + config = {**config, "group": normalize_mcp_group(config["group"]) or ""} + return config + class ThreatBookMcpConfigureRequest(BaseModel): """Region-aware ThreatBook MCP setup request.""" @@ -638,6 +667,25 @@ async def remove_mcp_server( raise HTTPException(status_code=500, detail=str(e)) +def _update_mcp_metadata(name: str, updates: Dict[str, Any]) -> Dict[str, Any]: + # Read the raw record: normalizing transport/auth here could alter credentials. + existing = ConfigWriter.get_mcp_server(name) + if not isinstance(existing, dict): + raise HTTPException(status_code=404, detail=f"MCP server not found: {name}") + config = dict(existing) + if "group" in updates: + config["group"] = normalize_mcp_group(updates["group"]) or "" + _persist_mcp_server_config(name, config, metadata_only=True) + projection = {**config, "group": _effective_mcp_group(name, config)} + return { + "success": True, + "message": f"MCP server '{name}' metadata updated successfully.", + "config": _to_frontend_mcp_config(projection), + "reconnected": False, + "reconnect_error": None, + } + + @router.put( "/{name}", response_model=Dict[str, Any], @@ -651,8 +699,10 @@ async def update_mcp_server( request: McpUpdateRequest, _admin: object = Depends(require_admin), ): - """Update an existing MCP server configuration and clear stale runtime state.""" + """Update native config; metadata-only writes leave runtime state untouched.""" try: + if set(request.config) <= {"group"}: + return _update_mcp_metadata(name, request.config) existing_config = _load_raw_mcp_server_config(name) if not existing_config: raise HTTPException(status_code=404, detail=f"MCP server not found: {name}") @@ -761,9 +811,13 @@ async def get_mcp_server_info(name: str): resources=[], ) result = info.model_dump() + result["group_readonly"] = False if isinstance(server_config, dict): - result["config"] = _to_frontend_mcp_config(server_config) + result["group"] = _effective_mcp_group(name, server_config) + result["status"]["group"] = result["group"] + result["config"] = _to_frontend_mcp_config({**server_config, "group": result["group"]}) else: + result["group"] = None result["config"] = None return result except HTTPException: @@ -1298,7 +1352,7 @@ async def get_mcp_catalog(): """List all available MCP servers from catalog""" try: catalog = McpCatalog.get() - return [entry.to_dict() for entry in catalog.entries] + return [{**entry.to_dict(), "group": normalize_mcp_group(getattr(entry, "group", None))} for entry in catalog.entries] except Exception as e: log.error("mcp.catalog.error", {"error": str(e)}) raise HTTPException(status_code=500, detail=str(e)) @@ -1356,7 +1410,7 @@ async def search_mcp_catalog(request: CatalogSearchRequest): tags=request.tags, official_only=request.official_only, ) - return [entry.to_dict() for entry in results] + return [{**entry.to_dict(), "group": normalize_mcp_group(getattr(entry, "group", None))} for entry in results] except Exception as e: log.error("mcp.catalog.search.error", {"error": str(e)}) raise HTTPException(status_code=500, detail=str(e)) @@ -1376,7 +1430,7 @@ async def get_catalog_entry(server_id: str): entry = catalog.get_entry(server_id) if not entry: raise HTTPException(status_code=404, detail=f"Catalog entry not found: {server_id}") - return entry.to_dict() + return {**entry.to_dict(), "group": normalize_mcp_group(getattr(entry, "group", None))} except HTTPException: raise except Exception as e: @@ -1429,6 +1483,9 @@ async def auto_setup_catalog( if not config: continue config["enabled"] = False + group = normalize_mcp_group(getattr(entry, "group", None)) + if group is not None: + config["group"] = group ConfigWriter.add_mcp_server(entry.id, config) configured.append(entry.id) @@ -1497,6 +1554,12 @@ async def install_from_catalog( ) config["enabled"] = bool(request.enabled) + if ConfigWriter.get_mcp_server(request.server_id) is None: + # Only first installation seeds the directory default. Existing + # instance overrides/clears are preserved by the native writers. + group = normalize_mcp_group(getattr(entry, "group", None)) + if group is not None: + config["group"] = group if request.enabled: # Connect immediately only when the caller explicitly enables the @@ -1524,12 +1587,15 @@ async def install_from_catalog( raise log.info("mcp.catalog.installed", {"server_id": request.server_id}) + saved_config = ConfigWriter.get_mcp_server(request.server_id) + response_config = dict(saved_config if isinstance(saved_config, dict) else config) + response_config["group"] = _effective_mcp_group(request.server_id, response_config) return { "success": True, "server_id": request.server_id, "name": entry.name, - "config": config, + "config": response_config, "message": ( f"Added {entry.name} to configuration and enabled it" if request.enabled diff --git a/flocks/server/routes/provider.py b/flocks/server/routes/provider.py index cdb375491..5e4077467 100644 --- a/flocks/server/routes/provider.py +++ b/flocks/server/routes/provider.py @@ -15,7 +15,9 @@ from pathlib import Path from typing import Any, Dict, List, Optional from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request, Response, status -from pydantic import BaseModel, Field, ConfigDict +from fastapi.exceptions import RequestValidationError +from pydantic import BaseModel, Field, ConfigDict, ValidationError, field_validator +from pydantic_core import PydanticCustomError from flocks.audit import emit_audit_event from flocks.auth.context import AuthUser @@ -45,6 +47,7 @@ _get_compound_secret_metadata, _normalize_api_service_credential_field, _should_persist_secondary_secret, + normalize_api_service_group, ) @@ -115,6 +118,9 @@ def _load_provider_yaml_metadata(provider_id: str) -> Optional[Dict[str, Any]]: def _load_api_service_metadata_data(provider_id: str) -> Optional[Dict[str, Any]]: """Compatibility wrapper preserving the historical patch seam in this module.""" + from flocks.config.api_versioning import versioned_storage_key_for + + provider_id = versioned_storage_key_for(provider_id) or provider_id merged: Dict[str, Any] = {} config_data = ConfigWriter.get_api_service_raw(provider_id) @@ -132,6 +138,7 @@ def _load_api_service_metadata_data(provider_id: str) -> Optional[Dict[str, Any] if isinstance(yaml_data, dict): merged = {**yaml_data, **merged} + api_service_schema_helpers.project_api_service_group(merged, yaml_data, config_data) return merged or None @@ -164,7 +171,7 @@ def _legacy_metadata_cache_key(provider_id: str) -> tuple[str, int]: def _api_service_summary_metadata_cache_key(provider_id: str) -> tuple[Any, ...]: - config_data = ConfigWriter.get_api_service_raw(provider_id) or {} + config_data = ConfigWriter.get_api_service_raw(provider_id) return ( json.dumps(config_data, sort_keys=True, default=str), _legacy_metadata_cache_key(provider_id), @@ -206,6 +213,8 @@ def _load_provider_yaml_summary_metadata(provider_id: str) -> Optional[Dict[str, "name": prov.get("name", provider_id), "service_id": prov.get("service_id", provider_id), "version": extract_provider_version(prov), + "group": normalize_api_service_group(prov.get("group")), + "group_readonly": api_service_schema_helpers.is_shipped_tool_path(descriptor.provider_yaml), "description": prov.get("description"), "description_cn": prov.get("description_cn"), "defaults": prov.get("defaults", {}), @@ -216,6 +225,9 @@ def _load_provider_yaml_summary_metadata(provider_id: str) -> Optional[Dict[str, def _load_api_service_summary_metadata_data(provider_id: str) -> Optional[Dict[str, Any]]: """Load only metadata needed by the API service list endpoint.""" + from flocks.config.api_versioning import versioned_storage_key_for + + provider_id = versioned_storage_key_for(provider_id) or provider_id cache_key = _api_service_summary_metadata_cache_key(provider_id) with _api_service_summary_metadata_cache_lock: cached = _api_service_summary_metadata_cache.get(provider_id) @@ -238,6 +250,7 @@ def _load_api_service_summary_metadata_data(provider_id: str) -> Optional[Dict[s if isinstance(yaml_data, dict): merged = {**yaml_data, **merged} + api_service_schema_helpers.project_api_service_group(merged, yaml_data, config_data) result = merged or None with _api_service_summary_metadata_cache_lock: _api_service_summary_metadata_cache[provider_id] = ( @@ -777,7 +790,7 @@ async def list_api_services_route(): @router.patch( "/api-services/{provider_id}", summary="Update API service", - description="Enable or disable an API service and all tools it exposes." + description="Update API service metadata or enable/disable the service and its tools." ) @serialized_config_mutation async def update_api_service_route( @@ -785,7 +798,13 @@ async def update_api_service_route( request: Dict[str, Any] = Body(...), _admin: object = Depends(require_admin), ): - return await update_api_service(provider_id, APIServiceUpdateRequest.model_validate(request)) + try: + update = APIServiceUpdateRequest.model_validate(request) + except ValidationError as exc: + raise RequestValidationError([ + {**error, "loc": ("body", *error["loc"])} for error in exc.errors() + ]) from exc + return await update_api_service(provider_id, update) @router.delete( @@ -1079,6 +1098,11 @@ async def update_provider( class APIServiceMetadata(BaseModel): """API service metadata""" name: str + group: Optional[str] = None + group_readonly: bool = False + + _validate_group = field_validator("group", mode="before")(normalize_api_service_group) + version: Optional[str] = None description: Optional[str] = None description_cn: Optional[str] = None @@ -1097,6 +1121,11 @@ class APIServiceMetadata(BaseModel): class APIServiceSummary(BaseModel): """API service summary for the Tool API page.""" id: str + group: Optional[str] = None + group_readonly: bool = False + + _validate_group = field_validator("group", mode="before")(normalize_api_service_group) + name: str version: Optional[str] = None enabled: bool = True @@ -1115,8 +1144,18 @@ class APIServiceSummary(BaseModel): class APIServiceUpdateRequest(BaseModel): """API service update request.""" - enabled: bool = Field(..., description="Enable or disable the API service") + enabled: Optional[bool] = Field(None, description="Enable or disable the API service; omitted leaves it unchanged") verify_ssl: Optional[bool] = Field(None, description="SSL verification for HTTP requests (default: False)") + group: Optional[str] = None + + _validate_group = field_validator("group", mode="before")(normalize_api_service_group) + + @field_validator("enabled", mode="before") + @classmethod + def validate_enabled(cls, value): + if value is None: + raise PydanticCustomError("bool_type", "enabled must be a boolean when provided") + return value def _get_api_service_enabled(provider_id: str) -> bool: @@ -1255,7 +1294,7 @@ async def _save_api_service_status_if_configured(provider_id: str, response: Dic if is_temporary_credential_override_active(): return raw_service = ConfigWriter.get_api_service_raw(provider_id) - if raw_service is None: + if raw_service is None or set(raw_service) <= {"group"}: return if raw_service.get("enabled") is False: return @@ -1284,6 +1323,8 @@ def _build_api_service_summary( return APIServiceSummary( id=provider_id, + group=meta.get("group"), + group_readonly=meta.get("group_readonly", False), name=meta.get("name", provider_id), version=version, enabled=enabled, @@ -1407,29 +1448,57 @@ async def update_api_service(provider_id: str, request: APIServiceUpdateRequest) try: from flocks.tool.registry import ToolRegistry + from flocks.config.api_versioning import versioned_storage_key_for + + # Resolve legacy aliases once; reads and writes must target the same native record. + provider_id = versioned_storage_key_for(provider_id) or provider_id + if not request.model_fields_set: + raise HTTPException(status_code=400, detail="No updates provided") + raw_existing = ConfigWriter.get_api_service_raw(provider_id) + existing = dict(raw_existing or {}) + if "group" in request.model_fields_set: + descriptor = _find_api_service_descriptor(provider_id) + if descriptor is not None and descriptor.storage_key != provider_id: + raise HTTPException(status_code=400, detail="Use the API service's versioned storage key") + if ( + raw_existing is None + and descriptor is None + and provider_id not in ToolRegistry.get_api_service_ids() + ): + raise HTTPException(status_code=404, detail=f"API service not found: {provider_id}") + definition = _load_provider_yaml_summary_metadata(provider_id) if raw_existing is None else None + if definition and definition.get("group_readonly"): + if request.group != normalize_api_service_group(definition.get("group")): + raise HTTPException(status_code=400, detail="System API definition group is read-only") + if request.model_fields_set == {"group"}: + return _build_api_service_summary(provider_id, await _read_api_service_status_cache()) + # Reposting the fixed default is a no-op; a real connection + # update may still create the ordinary editable config record. + else: + existing["group"] = request.group or "" await ToolRegistry.init_async() - - existing = ConfigWriter.get_api_service_raw(provider_id) or {} - existing["enabled"] = request.enabled + if request.enabled is not None: + existing["enabled"] = request.enabled if request.verify_ssl is not None: existing["verify_ssl"] = request.verify_ssl ConfigWriter.set_api_service(provider_id, existing) _clear_api_service_summary_metadata_cache(provider_id) - matched_count = _set_api_service_tools_enabled(provider_id, request.enabled) - statuses = await _read_api_service_status_cache() - if request.enabled: - status_payload = statuses.get(provider_id, {}) - if status_payload.get("status") == "disabled": - statuses.pop(provider_id, None) - else: - statuses[provider_id] = { - "status": "disabled", - "message": "Service disabled", - "checked_at": int(time.time()), - } - await _write_api_service_status_cache(statuses) + matched_count = 0 + if request.enabled is not None: + matched_count = _set_api_service_tools_enabled(provider_id, request.enabled) + if request.enabled: + status_payload = statuses.get(provider_id, {}) + if status_payload.get("status") == "disabled": + statuses.pop(provider_id, None) + else: + statuses[provider_id] = { + "status": "disabled", + "message": "Service disabled", + "checked_at": int(time.time()), + } + await _write_api_service_status_cache(statuses) log.info("api_service.updated", { "provider_id": provider_id, @@ -1438,6 +1507,8 @@ async def update_api_service(provider_id: str, request: APIServiceUpdateRequest) "matched_tools": matched_count, }) return _build_api_service_summary(provider_id, statuses) + except HTTPException: + raise except Exception as e: log.error("api_service.update.error", { "provider_id": provider_id, @@ -1638,6 +1709,8 @@ async def get_api_service_metadata(provider_id: str): return APIServiceMetadata( name=data.get("name", provider_id), + group=data.get("group"), + group_readonly=data.get("group_readonly", False), version=data.get("version"), description=data.get("description"), description_cn=data.get("description_cn"), @@ -2087,7 +2160,7 @@ async def get_service_credentials( if raw_service: for field in schema: - if field.storage != "config": + if field.storage != "config" or field.key == "group" or field.config_key == "group": continue raw_value = raw_service.get(field.config_key) if raw_value is None and field.key == "base_url": @@ -2103,6 +2176,8 @@ async def get_service_credentials( field_values["username"] = legacy_username for field_name in _get_api_service_secret_field_names(provider_id, metadata): + if field_name == "group": + continue for candidate in _get_api_service_secret_candidates(provider_id, raw_service, field_name=field_name): value = secrets.get(candidate) if value: diff --git a/flocks/server/routes/skill.py b/flocks/server/routes/skill.py index 4d16ee70c..200c7de31 100644 --- a/flocks/server/routes/skill.py +++ b/flocks/server/routes/skill.py @@ -10,9 +10,10 @@ from pathlib import Path from typing import List, Optional, Set from fastapi import APIRouter, Depends, HTTPException, Request, status -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator, ConfigDict +from pydantic_core import PydanticCustomError -from flocks.skill.skill import Skill, SkillInfo +from flocks.skill.skill import Skill, SkillInfo, normalize_skill_group from flocks.skill.installer import SkillInstaller, SkillInstallResult, DepInstallResult from flocks.command.command import API_SURFACES, Command, CommandInfo from flocks.server.auth import require_user @@ -76,6 +77,8 @@ class SkillInstallSpecResponse(BaseModel): class SkillResponse(BaseModel): """Skill response""" name: str = Field(..., description="Skill name") + group: Optional[str] = None + group_readonly: bool = False description: str = Field(..., description="Skill description") location: str = Field(..., description="Path to SKILL.md") source: Optional[str] = Field(None, description="Discovery source") @@ -95,8 +98,26 @@ class SkillResponse(BaseModel): class SkillCreateRequest(BaseModel): """Request to create a new skill""" name: str = Field(..., description="Skill name") + group: Optional[str] = None description: str = Field(..., description="Skill description") content: str = Field(..., description="Skill content (markdown)") + _validate_group = field_validator("group", mode="before")(normalize_skill_group) + + @field_validator("name") + @classmethod + def _validate_name(cls, value: str) -> str: + if not Skill._is_valid_name(value): + raise PydanticCustomError( + "skill_name", "Skill name must contain lowercase letters, numbers, and single hyphens" + ) + return value + + +class SkillMetadataUpdateRequest(BaseModel): + """Partial native metadata edit; ordinary PUT still requires its full body.""" + model_config = ConfigDict(extra="forbid") + group: Optional[str] = None + _validate_group = field_validator("group", mode="before")(normalize_skill_group) class SkillInstallRequest(BaseModel): @@ -203,8 +224,7 @@ def _skill_to_response( content = None if include_content: try: - with open(skill.location, "r", encoding="utf-8") as f: - content = f.read() + content = Path(skill.location).read_bytes().decode("utf-8") except Exception: pass @@ -242,6 +262,8 @@ def _skill_to_response( return SkillResponse( name=skill.name, + group=skill.group, + group_readonly=skill.group_readonly, description=skill.description, location=skill.location, source=skill.source, @@ -419,15 +441,17 @@ async def create_skill(req: SkillCreateRequest, _user=Depends(require_user)): (~/.flocks/plugins/skills//SKILL.md). """ try: - skill_dir = _user_skills_root() / req.name - skill_dir.mkdir(parents=True, exist_ok=True) + skill_dir = await _require_new_skill_name(req.name) + skill_dir.mkdir(parents=True, exist_ok=False) skill_path = skill_dir / "SKILL.md" - frontmatter = f"---\nname: {req.name}\ndescription: {req.description}\n---\n\n" - full_content = frontmatter + req.content + metadata = {"name": req.name, "description": req.description} + if "group" in req.model_fields_set: + metadata["group"] = req.group + full_content = Skill.render_frontmatter("", metadata, body=req.content) - skill_path.write_text(full_content, encoding="utf-8") + skill_path.write_bytes(full_content.encode("utf-8")) # Defensive cleanup: if a previous skill with this name was disabled # and the JSON record was not purged (e.g. manual edit, partial @@ -441,6 +465,7 @@ async def create_skill(req: SkillCreateRequest, _user=Depends(require_user)): return SkillResponse( name=req.name, + group=req.group, description=req.description, location=str(skill_path), source="user", @@ -455,6 +480,50 @@ async def create_skill(req: SkillCreateRequest, _user=Depends(require_user)): raise HTTPException(status_code=500, detail=f"Failed to create skill: {str(e)}") +def _require_writable_skill(skill: SkillInfo) -> None: + if Skill.is_readonly(skill): + raise HTTPException( + status_code=403, + detail="Built-in skills are read-only; their definitions and group cannot be changed", + ) + + +async def _require_new_skill_name(name: str) -> Path: + """Reject create/rename collisions, including user shadows of built-ins.""" + existing = await Skill.get(name) + if existing is not None: + _require_writable_skill(existing) + raise HTTPException(status_code=409, detail=f"Skill already exists: {name}") + target = _user_skills_root() / name + # Also protect undiscoverable definitions and existing resource directories. + if target.exists() or target.is_symlink(): + raise HTTPException(status_code=409, detail=f"Skill directory already exists: {name}") + return target + + +@router.patch("/skills/{name}", response_model=SkillResponse) +async def update_skill_metadata( + name: str, req: SkillMetadataUpdateRequest, _user=Depends(require_user) +): + """Patch the skill's own frontmatter without editing its Markdown body.""" + try: + skill = await Skill.get(name) + if not skill: + raise HTTPException(status_code=404, detail=f"Skill not found: {name}") + _require_writable_skill(skill) + if "group" in req.model_fields_set: + Skill.update_frontmatter(Path(skill.location), {"group": req.group}) + Skill.clear_cache() + skill = skill.model_copy(update={"group": req.group}) + # Group metadata does not affect prompts or enable/disable preferences. + return _skill_to_response(Skill.check_eligibility(skill), include_content=True) + except HTTPException: + raise + except Exception as exc: + log.error("skill.metadata.update.error", {"name": name, "error": str(exc)}) + raise HTTPException(status_code=500, detail=f"Failed to update skill metadata: {exc}") + + @router.put("/skills/{name}", response_model=SkillResponse) async def update_skill(name: str, req: SkillCreateRequest, _user=Depends(require_user)): """ @@ -469,20 +538,19 @@ async def update_skill(name: str, req: SkillCreateRequest, _user=Depends(require if not skill: raise HTTPException(status_code=404, detail=f"Skill not found: {name}") - frontmatter = f"---\nname: {req.name}\ndescription: {req.description}\n---\n\n" - full_content = frontmatter + req.content + _require_writable_skill(skill) + metadata = {"name": req.name, "description": req.description} + if "group" in req.model_fields_set: + metadata["group"] = req.group + existing_content = Path(skill.location).read_bytes().decode("utf-8") + full_content = Skill.render_frontmatter(existing_content, metadata, body=req.content) is_rename = req.name != name if is_rename: - if skill.source == 'project': - raise HTTPException( - status_code=400, - detail="Built-in project skills (.flocks/plugins/skills/) cannot be renamed", - ) - new_dir = _user_skills_root() / req.name + new_dir = await _require_new_skill_name(req.name) new_path = new_dir / "SKILL.md" - new_dir.mkdir(parents=True, exist_ok=True) - new_path.write_text(full_content, encoding="utf-8") + new_dir.mkdir(parents=True, exist_ok=False) + new_path.write_bytes(full_content.encode("utf-8")) old_dir = Path(skill.location).parent if old_dir.exists() and old_dir != new_dir: @@ -503,7 +571,7 @@ async def update_skill(name: str, req: SkillCreateRequest, _user=Depends(require Skill.rename_disabled(name, req.name) log.info("skill.renamed", {"old": name, "new": req.name, "path": location}) else: - Path(skill.location).write_text(full_content, encoding="utf-8") + Path(skill.location).write_bytes(full_content.encode("utf-8")) location = skill.location log.info("skill.updated", {"name": name, "path": location}) @@ -511,6 +579,7 @@ async def update_skill(name: str, req: SkillCreateRequest, _user=Depends(require await _refresh_agents_for_skill_change() return SkillResponse( name=req.name, + group=Skill._parse_frontmatter(full_content).get("group"), description=req.description, location=location, source=skill.source, @@ -541,11 +610,7 @@ async def delete_skill(name: str, _user=Depends(require_user)): if not skill: raise HTTPException(status_code=404, detail=f"Skill not found: {name}") - if skill.source == 'project': - raise HTTPException( - status_code=403, - detail="Built-in project skills (.flocks/plugins/skills/) cannot be deleted", - ) + _require_writable_skill(skill) skill_dir = Path(skill.location).parent if skill_dir.exists(): diff --git a/flocks/server/routes/tool.py b/flocks/server/routes/tool.py index 2d8b62798..b052fdaf5 100644 --- a/flocks/server/routes/tool.py +++ b/flocks/server/routes/tool.py @@ -8,7 +8,8 @@ import time from typing import Annotated, List, Optional, Dict, Any, Literal, Sequence from fastapi import APIRouter, Depends, HTTPException, Query, status -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator +from pydantic_core import PydanticCustomError from flocks.server.auth import require_admin from flocks.server.config_mutation import serialized_config_mutation @@ -25,6 +26,8 @@ ToolResult, ToolCategory, ToolContext, + normalize_tool_group, + is_shipped_tool_path, ) @@ -47,7 +50,9 @@ class ToolInfoResponse(BaseModel): parameters_count: int = Field(0, description="Number of tool parameters") enabled: bool = Field(True, description="Effective enabled state (overlay applied, ANDed with API service flag)") enabled_default: bool = Field(True, description="Factory default from the YAML/registration source (no overlay)") - enabled_customized: bool = Field(False, description="True if a user setting is recorded in flocks.json tool_settings") + group: Optional[str] = Field(None, description="Optional plugin group") + group_readonly: bool = False + enabled_customized: bool = Field(False, description="True if an enabled override is recorded in flocks.json tool_settings") requires_confirmation: bool = Field(False, description="Requires confirmation") @@ -59,7 +64,17 @@ class ToolSchemaResponse(BaseModel): class ToolUpdateRequest(BaseModel): """Tool update request""" - enabled: bool = Field(..., description="Enable or disable the tool") + enabled: Optional[bool] = Field(None, description="Enable or disable the tool; omitted leaves it unchanged") + group: Optional[str] = Field(None, description="Plugin group; null or empty clears it") + + _validate_group = field_validator("group", mode="before")(normalize_tool_group) + + @field_validator("enabled", mode="before") + @classmethod + def validate_enabled(cls, value): + if value is None: + raise PydanticCustomError("bool_type", "enabled must be a boolean when provided") + return value class ToolExecuteRequest(BaseModel): @@ -127,6 +142,7 @@ class ToolListFacets(BaseModel): category: Dict[str, int] = Field(default_factory=dict) source: Dict[str, int] = Field(default_factory=dict) source_groups: Dict[str, int] = Field(default_factory=dict) + group: Dict[str, int] = Field(default_factory=dict) source_name: Dict[str, int] = Field(default_factory=dict) enabled: Dict[str, int] = Field(default_factory=dict) @@ -171,11 +187,13 @@ class ToolListIndexItem: enabled_default: bool enabled_customized: bool requires_confirmation: bool + group: Optional[str] = None + group_readonly: bool = False _TOOL_SUMMARY_CACHE_TTL_SECONDS = 5.0 _tool_summary_cache_lock = threading.Lock() -_tool_summary_cache_key: tuple[int, tuple[str, ...]] | None = None +_tool_summary_cache_key: tuple[Any, ...] | None = None _tool_summary_cache_expires_at = 0.0 _tool_summary_cache_items: tuple[ToolListIndexItem, ...] = () @@ -189,8 +207,10 @@ def _invalidate_tool_summary_cache() -> None: _tool_summary_cache_items = () -def _tool_summary_cache_current_key() -> tuple[int, tuple[str, ...]]: - return ToolRegistry.snapshot_identity() +def _tool_summary_cache_current_key() -> tuple[Any, ...]: + # Metadata changes do not affect executable toolset revisions, but another + # worker's native settings write must still invalidate this list snapshot. + return (*ToolRegistry.snapshot_identity(), ToolRegistry._current_config_state_token()) def _get_tool_summary_items() -> List[ToolListIndexItem]: @@ -265,6 +285,8 @@ def _build_tool_response(t: ToolInfo, *, include_parameters: bool = True) -> Too parameters = [p.model_dump() for p in t.parameters] if include_parameters else [] return ToolInfoResponse( name=t.name, + group=normalize_tool_group(t.group if t.group_readonly else setting.get("group", t.group)), + group_readonly=t.group_readonly, description=t.description, description_cn=t.description_cn, category=t.category.value, @@ -286,6 +308,8 @@ def _build_tool_index_item(t: ToolInfo) -> ToolListIndexItem: setting = ConfigWriter.get_tool_setting(t.name) or {} return ToolListIndexItem( name=t.name, + group=normalize_tool_group(t.group if t.group_readonly else setting.get("group", t.group)), + group_readonly=t.group_readonly, description=t.description, description_cn=t.description_cn, category=t.category.value, @@ -303,6 +327,8 @@ def _build_tool_index_item(t: ToolInfo) -> ToolListIndexItem: def _tool_index_item_to_response(item: ToolListIndexItem) -> ToolInfoResponse: return ToolInfoResponse( name=item.name, + group=item.group, + group_readonly=item.group_readonly, description=item.description, description_cn=item.description_cn, category=item.category, @@ -347,6 +373,8 @@ def _build_tool_facets(items: Sequence[ToolInfoResponse | ToolListIndexItem]) -> facets.source[item.source] = facets.source.get(item.source, 0) + 1 source_name = item.source_name or "Flocks" facets.source_name[source_name] = facets.source_name.get(source_name, 0) + 1 + group = item.group or "" + facets.group[group] = facets.group.get(group, 0) + 1 enabled_key = str(item.enabled).lower() facets.enabled[enabled_key] = facets.enabled.get(enabled_key, 0) + 1 return facets @@ -392,8 +420,11 @@ def _filter_tool_items( include_source: bool = True, include_source_name: bool = True, include_enabled: bool = True, + group_filter: Optional[str] = None, ) -> List[ToolInfoResponse | ToolListIndexItem]: result = list(items) + if group_filter is not None: + result = [tool for tool in result if (tool.group or "") == group_filter] if include_category and category_filter: result = [tool for tool in result if tool.category in category_filter] if include_source and source_filter: @@ -654,7 +685,7 @@ def _set_global_tool_enabled(tool: Any, desired: bool) -> bool: new_enabled = desired and service_ok if desired == default: - removed = ConfigWriter.delete_tool_setting(tool.info.name) + removed = ConfigWriter.delete_tool_setting(tool.info.name, field="enabled") log.info("tool.updated.reset_to_default", { "name": tool.info.name, "enabled": new_enabled, @@ -742,6 +773,7 @@ async def list_tools_page( source_name: Optional[str] = None, enabled: Optional[str] = None, q: Optional[str] = None, + group: Optional[str] = None, sort_by: Literal["category", "source", "source_name", "enabled", "name"] = "source", sort_dir: Literal["asc", "desc"] = "asc", offset: int = Query(0, ge=0), @@ -764,52 +796,38 @@ async def list_tools_page( all_items = _get_tool_summary_items() - result = _filter_tool_items( - all_items, - category_filter=category_filter, - source_filter=source_filter, - source_name_filter=source_name_filter, - enabled_filter=enabled_filter, - query=query, - ) - source_facet_items = _filter_tool_items( - all_items, + try: + group_filter = normalize_tool_group(group) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + + filters = dict( category_filter=category_filter, source_filter=source_filter, source_name_filter=source_name_filter, enabled_filter=enabled_filter, query=query, - include_source=False, ) + # Group counts span the full eligible query, before selecting a group or page. + eligible_items = _filter_tool_items(all_items, **filters) + result = [ + item for item in eligible_items + if group_filter is None or (item.group or "") == group_filter + ] + filters["group_filter"] = group_filter + source_facet_items = _filter_tool_items(all_items, **filters, include_source=False) facets = ToolListFacets( + group=_build_tool_facets(eligible_items).group, category=_build_tool_facets(_filter_tool_items( - all_items, - category_filter=category_filter, - source_filter=source_filter, - source_name_filter=source_name_filter, - enabled_filter=enabled_filter, - query=query, - include_category=False, + all_items, **filters, include_category=False, )).category, source=_build_tool_facets(source_facet_items).source, source_groups=_build_source_group_counts(source_facet_items), source_name=_build_tool_facets(_filter_tool_items( - all_items, - category_filter=category_filter, - source_filter=source_filter, - source_name_filter=source_name_filter, - enabled_filter=enabled_filter, - query=query, - include_source_name=False, + all_items, **filters, include_source_name=False, )).source_name, enabled=_build_tool_facets(_filter_tool_items( - all_items, - category_filter=category_filter, - source_filter=source_filter, - source_name_filter=source_name_filter, - enabled_filter=enabled_filter, - query=query, - include_enabled=False, + all_items, **filters, include_enabled=False, )).enabled, ) result = _sort_tool_items(result, sort_by, sort_dir) @@ -904,8 +922,8 @@ async def update_tool( Two behaviours of note (global mode only): * If ``request.enabled`` matches the registration-time default we - *delete* the overlay entry instead of writing one — the tool is - back to "no customisation", and the UI's "已自定义" badge clears. + remove only the enabled override — grouping and other metadata are + retained, and the UI's "已自定义" enabled badge clears. * Asking to enable a tool whose API service is currently disabled still persists the overlay (so the intent survives the service being re-enabled later) but does not flip the in-memory @@ -921,7 +939,16 @@ async def update_tool( detail=f"Tool not found: {tool_name}", ) - desired = bool(request.enabled) + if not request.model_fields_set: + raise HTTPException(status_code=400, detail="No updates provided") + if device_id and "group" in request.model_fields_set: + raise HTTPException(status_code=400, detail="group belongs to the tool, not a device override") + if "group" in request.model_fields_set: + try: + ToolRegistry.validate_group_settings({tool_name: {"group": request.group}}) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + desired = request.enabled # --- Per-device mode --- if device_id: @@ -953,8 +980,12 @@ async def update_tool( _invalidate_tool_summary_cache() return _build_tool_response(tool.info) - # --- Global mode (original behaviour) --- - _set_global_tool_enabled(tool, desired) + # --- Global mode (native settings, without rescanning plugin files) --- + with ToolRegistry._refresh_lock: + if desired is not None: + _set_global_tool_enabled(tool, desired) + if "group" in request.model_fields_set and not tool.info.group_readonly: + ConfigWriter.set_tool_setting(tool_name, {"group": request.group or ""}) _invalidate_tool_summary_cache() return _build_tool_response(tool.info) @@ -966,7 +997,7 @@ async def update_tool( ) @serialized_config_mutation async def reset_tool_setting(tool_name: str, _admin: object = Depends(require_admin)): - """Remove the user setting for ``tool_name`` and restore the default. + """Remove the enabled override for ``tool_name``, retaining other metadata. Restores the registration-time ``enabled`` value from the registry's snapshot (or the YAML file as a fallback) and re-applies the same @@ -984,7 +1015,7 @@ async def reset_tool_setting(tool_name: str, _admin: object = Depends(require_ad ) with ToolRegistry._refresh_lock: - removed = ConfigWriter.delete_tool_setting(tool_name) + removed = ConfigWriter.delete_tool_setting(tool_name, field="enabled") default = _get_default_enabled(tool.info) new_enabled = default and _service_allows_enable(tool.info) previous_enabled = bool(tool.info.enabled) @@ -1363,6 +1394,9 @@ class CreateToolRequest(BaseModel): parameters: Optional[List[Dict[str, Any]]] = Field(None, description="Simplified parameter list") handler: Dict[str, Any] = Field(..., description="Handler config (type: http|script)") response: Optional[Dict[str, Any]] = Field(None, description="Response processing config") + group: Optional[str] = None + + _validate_group = field_validator("group", mode="before")(normalize_tool_group) class UpdateToolRequest(BaseModel): @@ -1375,6 +1409,9 @@ class UpdateToolRequest(BaseModel): parameters: Optional[List[Dict[str, Any]]] = Field(None) handler: Optional[Dict[str, Any]] = Field(None) response: Optional[Dict[str, Any]] = Field(None) + group: Optional[str] = None + + _validate_group = field_validator("group", mode="before")(normalize_tool_group) class PluginToolListResponse(BaseModel): @@ -1412,6 +1449,8 @@ async def create_tool(request: CreateToolRequest, _admin: object = Depends(requi "requires_confirmation": request.requires_confirmation, "handler": request.handler, } + if "group" in request.model_fields_set: + data["group"] = request.group or "" if request.inputSchema: data["inputSchema"] = request.inputSchema if request.parameters: @@ -1481,13 +1520,16 @@ async def update_plugin_tool(name: str, request: UpdateToolRequest, _admin: obje ToolRegistry.init() - if not find_yaml_tool(name): + yaml_path = find_yaml_tool(name) + if not yaml_path: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"YAML plugin tool not found: {name}", ) - updates = {k: v for k, v in request.model_dump().items() if v is not None} + updates = {k: v for k, v in request.model_dump(exclude_unset=True).items() if v is not None} + if "group" in request.model_fields_set: + updates["group"] = request.group or "" if not updates: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -1499,10 +1541,22 @@ async def update_plugin_tool(name: str, request: UpdateToolRequest, _admin: obje raise HTTPException(status_code=500, detail=f"Failed to update YAML for tool {name}") except HTTPException: raise + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc except Exception as e: log.error("tool.update.error", {"error": str(e), "name": name}) raise HTTPException(status_code=500, detail=str(e)) + if "group" in updates and not is_shipped_tool_path(yaml_path): + # Explicit native edits also replace an existing user group override. + if "group" in (ConfigWriter.get_tool_setting(name) or {}): + ConfigWriter.set_tool_setting(name, {"group": updates["group"]}) + tool = ToolRegistry.get(name) + if tool is not None and set(updates) == {"group"}: + tool.info.group = updates["group"] + _invalidate_tool_summary_cache() + return _build_tool_response(tool.info) + # Reload tool into registry try: yaml_path = find_yaml_tool(name) @@ -1512,6 +1566,8 @@ async def update_plugin_tool(name: str, request: UpdateToolRequest, _admin: obje if not tool.info.source: tool.info.source = "plugin_yaml" ToolRegistry.register(tool) + ToolRegistry._sync_api_service_states() + ToolRegistry._apply_tool_settings() _invalidate_tool_summary_cache() return _build_tool_response(tool.info) except Exception as e: @@ -1607,7 +1663,7 @@ async def reload_tool(name: str, _admin: object = Depends(require_admin)): Re-reads the YAML file from disk and re-registers the tool in the ToolRegistry without restarting the service. """ - from flocks.tool.tool_loader import find_yaml_tool, yaml_to_tool, _read_yaml_raw + from flocks.tool.tool_loader import find_yaml_tool, yaml_to_tool, _read_yaml_raw, validate_yaml_tool_replacement ToolRegistry.init() @@ -1618,6 +1674,11 @@ async def reload_tool(name: str, _admin: object = Depends(require_admin)): detail=f"YAML plugin tool not found: {name}", ) + try: + validate_yaml_tool_replacement(name, yaml_path) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + try: raw = _read_yaml_raw(yaml_path) tool = yaml_to_tool(raw, yaml_path) diff --git a/flocks/server/routes/workflow.py b/flocks/server/routes/workflow.py index dfda6b13c..9e5947ed3 100644 --- a/flocks/server/routes/workflow.py +++ b/flocks/server/routes/workflow.py @@ -18,7 +18,7 @@ from pathlib import Path from typing import List, Optional, Any, Dict, Literal from fastapi import APIRouter, Body, HTTPException, Request, status, Query -from pydantic import BaseModel, Field, ConfigDict +from pydantic import BaseModel, Field, ConfigDict, field_validator import uuid from flocks.workflow.models import Workflow, Node, Edge @@ -45,6 +45,10 @@ read_workflow_dir as _read_workflow_dir, read_workflow_from_fs as shared_read_workflow_from_fs, workflow_scan_dirs as _all_scan_dirs, + normalize_workflow_group, + patch_workflow_metadata, + resolve_workflow_from_fs, + is_system_workflow_definition, ) from flocks.ingest.kafka.constants import WORKFLOW_KAFKA_CONFIG_PREFIX from flocks.ingest.syslog.constants import WORKFLOW_SYSLOG_CONFIG_PREFIX @@ -160,6 +164,9 @@ class WorkflowCreateRequest(BaseModel): model_config = ConfigDict(populate_by_name=True) + group: Optional[str] = None + _validate_group = field_validator("group", mode="before")(normalize_workflow_group) + name: str = Field(..., description="Workflow name") name_i18n: Optional[Dict[str, str]] = Field(None, alias="nameI18n", description="Localized workflow display names") description: Optional[str] = Field(None, description="Workflow description") @@ -177,6 +184,9 @@ class WorkflowUpdateRequest(BaseModel): model_config = ConfigDict(populate_by_name=True) + group: Optional[str] = None + _validate_group = field_validator("group", mode="before")(normalize_workflow_group) + name: Optional[str] = Field(None, description="Workflow name") name_i18n: Optional[Dict[str, str]] = Field(None, alias="nameI18n", description="Localized workflow display names") description: Optional[str] = Field(None, description="Workflow description") @@ -228,6 +238,8 @@ class WorkflowResponse(BaseModel): id: str = Field(..., description="Workflow ID") name: str = Field(..., description="Workflow name") + group: Optional[str] = None + group_readonly: bool = False nameI18n: Optional[Dict[str, str]] = Field(None, description="Localized workflow display names") description: Optional[str] = Field(None, description="Description") markdownContent: Optional[str] = Field(None, description="Workflow markdown documentation content") @@ -251,6 +263,8 @@ class WorkflowSummaryResponse(BaseModel): id: str name: str + group: Optional[str] = None + group_readonly: bool = False nameI18n: Optional[Dict[str, str]] = None description: Optional[str] = None category: str = "default" @@ -362,7 +376,7 @@ def _existing_workflow_dir(workflow_id: str) -> Optional[Path]: result: Optional[Path] = None for root, _source in _all_scan_dirs(): wf_dir = root / workflow_id - if (wf_dir / "workflow.json").is_file(): + if _read_workflow_dir(wf_dir, workflow_id, _source) is not None: result = wf_dir return result @@ -424,35 +438,59 @@ def _write_workflow_to_fs( edit_markdown_content: Optional[str] = None, *, global_store: bool = False, + target_dir: Optional[Path] = None, + create_only: bool = False, ) -> None: """Write workflow definition and metadata to the filesystem. When *global_store* is True the workflow is written under ``~/.flocks/plugins/workflows//`` instead of the project directory. """ - wf_dir = _global_workflow_dir(workflow_id) if global_store else _workflow_dir(workflow_id) - wf_dir.mkdir(parents=True, exist_ok=True) - - with open(wf_dir / "workflow.json", "w", encoding="utf-8") as f: + if create_only and target_dir is None: + raise ValueError("Create-only migration requires an explicit destination") + wf_dir = target_dir or _existing_workflow_dir(workflow_id) or ( + _global_workflow_dir(workflow_id) if global_store else _workflow_dir(workflow_id) + ) + meta = dict(meta) + if is_system_workflow_definition(wf_dir): + meta_path = wf_dir / "meta.json" + native_meta = json.loads(meta_path.read_text(encoding="utf-8")) if meta_path.is_file() else {} + native_group = normalize_workflow_group(native_meta.get("group")) + transport = workflow_json.get("metadata") + for values in (meta, transport if isinstance(transport, dict) else {}): + if "group" in values and normalize_workflow_group(values["group"]) != native_group: + raise ValueError("System workflow group is read-only") + meta["group"] = native_group + # Claim the migration destination atomically. Never redirect a migration to + # an existing definition, nor clobber files created by another worker. + wf_dir.mkdir(parents=True, exist_ok=not create_only) + write_mode = "x" if create_only else "w" + + # The local group authority is meta.json, never the execution definition. + workflow_json = dict(workflow_json) + if isinstance(workflow_json.get("metadata"), dict): + workflow_json["metadata"] = dict(workflow_json["metadata"]) + workflow_json["metadata"].pop("group", None) + with open(wf_dir / "workflow.json", write_mode, encoding="utf-8") as f: json.dump(workflow_json, f, ensure_ascii=False, indent=2) meta_to_save = { k: v for k, v in meta.items() - if k not in ("workflowJson", "markdownContent", "editMarkdownContent", "stats", "source") + if k not in ("workflowJson", "markdownContent", "editMarkdownContent", "stats", "source", "group_readonly") } - with open(wf_dir / "meta.json", "w", encoding="utf-8") as f: + with open(wf_dir / "meta.json", write_mode, encoding="utf-8") as f: json.dump(meta_to_save, f, ensure_ascii=False, indent=2) if markdown_content is None and edit_markdown_content is not None: markdown_content = edit_markdown_content if markdown_content is not None: - with open(wf_dir / "workflow.md", "w", encoding="utf-8") as f: + with open(wf_dir / "workflow.md", write_mode, encoding="utf-8") as f: f.write(markdown_content) legacy_edit_file = wf_dir / "workflow.edit.md" - if legacy_edit_file.exists(): + if not create_only and legacy_edit_file.exists(): legacy_edit_file.unlink() @@ -640,13 +678,21 @@ async def _migrate_storage_to_filesystem() -> None: if not workflow_id: continue - wf_dir = _workflow_dir(workflow_id) - if (wf_dir / "workflow.json").is_file(): - continue # already on the filesystem + def already_on_disk() -> bool: + return any( + (root / workflow_id / filename).is_file() + for root, _source in _all_scan_dirs() + for filename in ("workflow.json", "workflow.md", "workflow.edit.md") + ) + if already_on_disk(): + continue + # Migration has a fixed create-only destination. Ordinary updates + # alone may resolve the highest-priority existing directory. + wf_dir = _workflow_dir(workflow_id) try: data = await Storage.read(key) - if not data: + if not data or already_on_disk(): continue workflow_json = data.get("workflowJson", {}) meta = { @@ -659,8 +705,13 @@ async def _migrate_storage_to_filesystem() -> None: "createdAt": data.get("createdAt", int(time.time() * 1000)), "updatedAt": data.get("updatedAt", int(time.time() * 1000)), } + if "group" in data: + meta["group"] = normalize_workflow_group(data["group"]) markdown_content = data.get("markdownContent") - _write_workflow_to_fs(workflow_id, workflow_json, meta, markdown_content) + _write_workflow_to_fs( + workflow_id, workflow_json, meta, markdown_content, + target_dir=wf_dir, create_only=True, + ) migrated += 1 log.info("workflow.migration.migrated", {"id": workflow_id}) except Exception as exc: @@ -1425,6 +1476,8 @@ async def build_summary(data: Dict[str, Any]) -> Optional[WorkflowSummaryRespons return WorkflowSummaryResponse( id=workflow_id, name=data.get("name") or workflow_id, + group=data.get("group"), + group_readonly=data.get("group_readonly", False), nameI18n=data.get("nameI18n"), description=data.get("description"), category=data.get("category") or "default", @@ -1460,6 +1513,10 @@ async def create_workflow(req: WorkflowCreateRequest): try: workflow_json = _apply_new_workflow_runtime_defaults(req.workflow_json) try: + group = normalize_workflow_group( + req.group if "group" in req.model_fields_set else workflow_json["metadata"].get("group") + ) + workflow_json["metadata"].pop("group", None) workflow_model = Workflow.from_dict(workflow_json) except Exception as e: raise HTTPException(status_code=400, detail=f"Invalid workflow JSON: {str(e)}") @@ -1483,6 +1540,7 @@ async def create_workflow(req: WorkflowCreateRequest): meta = { "id": workflow_id, "name": req.name, + "group": group, "nameI18n": req.name_i18n, "description": req.description, "category": req.category or "default", @@ -1543,18 +1601,38 @@ async def update_workflow(workflow_id: str, req: WorkflowUpdateRequest): """ Update workflow - Reads from the filesystem, applies changes, and writes back. Both the - workflow definition (workflow.json) and metadata (meta.json) are updated - atomically within the same directory. + Reads from the filesystem, applies changes, and writes back to the selected + native directory. Metadata-only updates touch only meta.json, so organizing + a Markdown draft never creates an executable workflow.json. """ try: - data = _read_workflow_from_fs(workflow_id) - if not data: + selected = resolve_workflow_from_fs(workflow_id) + if selected is None: raise HTTPException(status_code=404, detail=f"Workflow not found: {workflow_id}") + wf_dir, data = selected + + # Guard both edit surfaces before writing metadata, graph, or Markdown. + if data["group_readonly"]: + fixed_group = normalize_workflow_group(data.get("group")) + transport = (req.workflow_json or {}).get("metadata") + group_updates = [] + if "group" in req.model_fields_set: + group_updates.append(req.group) + if isinstance(transport, dict) and "group" in transport: + group_updates.append(transport["group"]) + for group in group_updates: + try: + unchanged = normalize_workflow_group(group) == fixed_group + except ValueError: + unchanged = False + if not unchanged: + raise HTTPException(status_code=403, detail="System workflow group is read-only") workflow_json = data["workflowJson"] markdown_content = data.get("markdownContent") + if "group" in req.model_fields_set: + data["group"] = req.group if req.name is not None: data["name"] = req.name if req.name_i18n is not None: @@ -1580,7 +1658,10 @@ async def update_workflow(workflow_id: str, req: WorkflowUpdateRequest): status_code=400, detail=(f"Workflow schema lint failed: {schema_errors[:5]}"), ) - workflow_json = req.workflow_json + workflow_json = dict(req.workflow_json) + if isinstance(workflow_json.get("metadata"), dict): + workflow_json["metadata"] = dict(workflow_json["metadata"]) + workflow_json["metadata"].pop("group", None) except Exception as e: if isinstance(e, HTTPException): raise @@ -1591,14 +1672,23 @@ async def update_workflow(workflow_id: str, req: WorkflowUpdateRequest): markdown_content = req.edit_markdown_content data["updatedAt"] = int(time.time() * 1000) - is_global = data.get("source") == "global" - _write_workflow_to_fs( - workflow_id, - workflow_json, - data, - markdown_content, - global_store=is_global, - ) + if req.workflow_json is None and req.markdown_content is None and req.edit_markdown_content is None: + metadata_updates = req.model_dump( + include={"group", "name", "name_i18n", "description", "category", "status"}, + exclude_unset=True, + exclude_none=True, + by_alias=True, + ) + metadata_updates["updatedAt"] = data["updatedAt"] + patch_workflow_metadata(wf_dir, metadata_updates, workflow_data=data) + else: + _write_workflow_to_fs( + workflow_id, + workflow_json, + data, + markdown_content, + target_dir=wf_dir, + ) stats = await _get_workflow_stats(workflow_id) data["workflowJson"] = workflow_json @@ -2195,9 +2285,16 @@ async def import_workflow(workflow_json: Dict[str, Any]): except Exception as e: raise HTTPException(status_code=400, detail=f"Invalid workflow JSON: {str(e)}") + workflow_json = dict(workflow_json) + metadata = dict(workflow_json.get("metadata") or {}) + try: + group = normalize_workflow_group(metadata.pop("group", None)) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + workflow_json["metadata"] = metadata name = workflow_json.get("name", "Imported Workflow") - description = workflow_json.get("metadata", {}).get("description") - category = workflow_json.get("metadata", {}).get("category", "default") + description = metadata.get("description") + category = metadata.get("category", "default") workflow_id = str(uuid.uuid4()) now_ms = int(time.time() * 1000) @@ -2205,6 +2302,7 @@ async def import_workflow(workflow_json: Dict[str, Any]): meta = { "id": workflow_id, "name": name, + "group": group, "description": description, "category": category, "status": "draft", @@ -2247,10 +2345,9 @@ async def export_workflow(workflow_id: str): if not data: raise HTTPException(status_code=404, detail=f"Workflow not found: {workflow_id}") - workflow_json = data["workflowJson"] - - if "metadata" not in workflow_json: - workflow_json["metadata"] = {} + workflow_json = dict(data["workflowJson"]) + workflow_json["metadata"] = dict(workflow_json.get("metadata") or {}) + workflow_json["metadata"]["group"] = normalize_workflow_group(data.get("group")) workflow_json["metadata"]["exportedFrom"] = "flocks" workflow_json["metadata"]["exportedAt"] = int(time.time() * 1000) workflow_json["name"] = data["name"] @@ -3810,7 +3907,7 @@ async def save_sample_inputs(workflow_id: str, req: SampleInputsRequest): meta = { k: v for k, v in data.items() - if k not in ("workflowJson", "markdownContent", "editMarkdownContent", "stats", "source") + if k not in ("workflowJson", "markdownContent", "editMarkdownContent", "stats", "source", "group_readonly") } meta["updatedAt"] = int(time.time() * 1000) markdown_content = data.get("markdownContent") diff --git a/flocks/skill/installer.py b/flocks/skill/installer.py index 15a9aeac6..cb6a1d0f3 100644 --- a/flocks/skill/installer.py +++ b/flocks/skill/installer.py @@ -337,6 +337,10 @@ async def _install() -> SkillInstallResult: success=False, error=f"Skill install timed out after {_INSTALL_TIMEOUT_SEC}s: {source}", ) + except ValueError as exc: + # Native metadata/readonly preflight failures are install failures, + # not server errors or a reason to try a different shadowing source. + return SkillInstallResult(success=False, error=str(exc)) @classmethod async def _install_from_skills_sh( @@ -584,6 +588,7 @@ def _import_staged_skill_dirs(cls, staging: Path, scope: str) -> List[tuple[str, """Copy staged agent skill directories into Flocks skill storage.""" install_root = _resolve_install_root(scope) imported: List[tuple[str, Path]] = [] + prepared: list[tuple[str, Path, Path, str]] = [] seen: set[Path] = set() candidate_roots = [ staging / ".flocks" / "plugins" / "skills", @@ -601,7 +606,7 @@ def _import_staged_skill_dirs(cls, staging: Path, scope: str) -> List[tuple[str, continue seen.add(skill_dir) try: - content = skill_md.read_text(encoding="utf-8") + content = skill_md.read_bytes().decode("utf-8") except Exception: continue data = Skill._parse_frontmatter(content) @@ -609,10 +614,15 @@ def _import_staged_skill_dirs(cls, staging: Path, scope: str) -> List[tuple[str, if not name or not Skill._is_valid_name(name): continue dest = install_root / name - if dest.exists(): - shutil.rmtree(dest) - shutil.copytree(skill_dir, dest) - imported.append((name, dest / "SKILL.md")) + content = Skill.preserve_install_group(dest / "SKILL.md", content, scope=scope, name=name) + prepared.append((name, skill_dir, dest, content)) + # Preflight the whole CLI import before replacing even the first skill. + for name, skill_dir, dest, content in prepared: + if dest.exists(): + shutil.rmtree(dest) + shutil.copytree(skill_dir, dest) + (dest / "SKILL.md").write_bytes(content.encode("utf-8")) + imported.append((name, dest / "SKILL.md")) return imported @classmethod @@ -694,6 +704,9 @@ async def _install_from_clawhub(cls, name: str, scope: str) -> SkillInstallResul install_root = _resolve_install_root(scope) skill_dir = install_root / skill_name + skill_md_content = Skill.preserve_install_group( + skill_dir / "SKILL.md", skill_md_content, scope=scope, name=skill_name, + ) skill_dir.mkdir(parents=True, exist_ok=True) for zip_entry in names_in_zip: @@ -704,14 +717,17 @@ async def _install_from_clawhub(cls, name: str, scope: str) -> SkillInstallResul continue dest = (skill_dir / zip_entry).resolve() # Zip Slip prevention: ensure dest stays inside skill_dir - if not str(dest).startswith(str(skill_dir.resolve())): + if not dest.is_relative_to(skill_dir.resolve()): log.warn("skill.install.clawhub.zip_slip", { "entry": zip_entry, "skill": name, }) continue dest.parent.mkdir(parents=True, exist_ok=True) - dest.write_bytes(zf.read(zip_entry)) + dest.write_bytes( + skill_md_content.encode("utf-8") if dest == skill_dir.resolve() / "SKILL.md" + else zf.read(zip_entry) + ) except zipfile.BadZipFile: return SkillInstallResult( @@ -911,6 +927,10 @@ def _import_skill_from_github_zip( continue skill_root = _resolve_install_root(scope) / name + try: + content = Skill.preserve_install_group(skill_root / "SKILL.md", content, scope=scope, name=name) + except ValueError as exc: + return SkillInstallResult(success=False, error=str(exc)) skill_root.mkdir(parents=True, exist_ok=True) skill_root_resolved = skill_root.resolve() prefix = f"{skill_dir}/" @@ -927,7 +947,9 @@ def _import_skill_from_github_zip( except ValueError: continue dest.parent.mkdir(parents=True, exist_ok=True) - dest.write_bytes(zf.read(member)) + dest.write_bytes( + content.encode("utf-8") if dest == skill_root_resolved / "SKILL.md" else zf.read(member) + ) file_count += 1 Skill.clear_cache() @@ -1030,11 +1052,19 @@ async def _download_github_dir( ) skill_dir = _resolve_install_root(scope) / name + try: + skill_md_content = Skill.preserve_install_group( + skill_dir / "SKILL.md", skill_md_content, scope=scope, name=name, + ) + except ValueError as exc: + return SkillInstallResult(success=False, error=str(exc)) skill_dir.mkdir(parents=True, exist_ok=True) + (skill_dir / "SKILL.md").write_bytes(skill_md_content.encode("utf-8")) - # Recursively download all files preserving directory structure - file_count = await cls._download_github_entries( - client, entries, skill_dir, relative_base="" + # Reuse the already-validated SKILL.md response; a second download must + # not bypass the preservation/readonly checks with different content. + file_count = 1 + await cls._download_github_entries( + client, [entry for entry in entries if entry["name"] != "SKILL.md"], skill_dir, relative_base="" ) Skill.clear_cache() @@ -1066,6 +1096,8 @@ async def _download_github_entries( for entry in entries: entry_type = entry.get("type") entry_name = entry.get("name", "") + if not entry_name or entry_name in {".", ".."} or "/" in entry_name or "\\" in entry_name: + continue rel_path = f"{relative_base}/{entry_name}".lstrip("/") if entry_type == "file": @@ -1165,7 +1197,7 @@ async def _install_from_local(cls, path: str, scope: str) -> SkillInstallResult: return SkillInstallResult(success=False, error=f"File not found: {path}") try: - content = local_path.read_text(encoding="utf-8") + content = local_path.read_bytes().decode("utf-8") except Exception as exc: return SkillInstallResult(success=False, error=f"Cannot read file: {exc}") @@ -1205,9 +1237,13 @@ def _save_skill_content( ) skill_dir = _resolve_install_root(scope) / name - skill_dir.mkdir(parents=True, exist_ok=True) skill_path = skill_dir / "SKILL.md" - skill_path.write_text(content, encoding="utf-8") + try: + content = Skill.preserve_install_group(skill_path, content, scope=scope, name=name) + except ValueError as exc: + return SkillInstallResult(success=False, error=str(exc)) + skill_dir.mkdir(parents=True, exist_ok=True) + skill_path.write_bytes(content.encode("utf-8")) Skill.clear_cache() log.info("skill.install.saved", {"name": name, "path": str(skill_path)}) diff --git a/flocks/skill/skill.py b/flocks/skill/skill.py index d0ef5fb77..204040745 100644 --- a/flocks/skill/skill.py +++ b/flocks/skill/skill.py @@ -17,7 +17,8 @@ from contextlib import contextmanager from typing import Any, Dict, Iterator, List, Literal, Optional, Set from pathlib import Path -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, computed_field, field_validator +from pydantic_core import PydanticCustomError from flocks.utils.log import Log from flocks.project.instance import Instance @@ -159,9 +160,26 @@ class SkillMetadata(BaseModel): managed_by: Optional[str] = None +def normalize_skill_group(value: Any) -> str: + """Validate the group stored in this skill's own frontmatter.""" + if value is None: + return "" + if not isinstance(value, str): + raise PydanticCustomError("group_type", "group must be a string or null") + value = value.strip() + if any(ord(char) < 32 or ord(char) == 127 for char in value): + raise PydanticCustomError("group_control", "group must not contain control characters") + if len(value) > 32: + raise PydanticCustomError("group_length", "group must be at most 32 characters") + return value + + class SkillInfo(BaseModel): """Skill information""" name: str = Field(..., description="Skill name") + group: Optional[str] = None + _validate_group = field_validator("group", mode="before")(normalize_skill_group) + description: str = Field(..., description="Skill description") location: str = Field(..., description="Path to SKILL.md file") source: Optional[str] = Field(default=None, description="Discovery source") @@ -174,6 +192,12 @@ class SkillInfo(BaseModel): "Derived from source; not declared in SKILL.md frontmatter." )) + @computed_field + @property + def group_readonly(self) -> bool: + """Project/flocks definitions retain their existing, stricter readonly rule.""" + return Skill.is_readonly(self) + # Extended metadata (populated from frontmatter metadata.flocks / metadata.openclaw) metadata: Optional[SkillMetadata] = Field(default=None, description="Parsed skill metadata") install_specs: Optional[List[SkillInstallSpec]] = Field( @@ -215,6 +239,55 @@ def _source_root() -> Path: """Return the Flocks source installation root.""" return Path(__file__).resolve().parents[2] + @classmethod + def is_system_shipped(cls, path: Path | str) -> bool: + """Identify definitions at the installation root, never the request cwd.""" + resolved = Path(path).resolve() + root = cls._source_root() / ".flocks" + return any( + resolved.is_relative_to((root / subdir).resolve()) + for subdir in ("skill", "skills", "plugins/skill", "plugins/skills") + ) + + @classmethod + def is_readonly(cls, skill: SkillInfo) -> bool: + return ( + skill.native + or skill.source in {"project", "flocks"} + or cls.is_system_shipped(skill.location) + ) + + @classmethod + def preserve_install_group( + cls, destination: Path, content: str, *, scope: str, name: Optional[str] = None, + ) -> str: + """Prepare an installation before any destructive write. + + All native installers, including Hub, use this same readonly/collision + check and key-presence merge. No copy may shadow a selected readonly + definition. First installs retain the incoming default verbatim. + """ + incoming, _, _ = cls._decode_frontmatter(content) + name = (incoming.get("name") or name or destination.parent.name).strip() + if not cls._is_valid_name(name): + raise ValueError(f"Invalid skill name: {name!r}") + # Re-discover at the write boundary: a warmed cache is not permission to + # overwrite a definition which has since appeared on disk. + selected = cls._discover().get(name) + if cls.is_system_shipped(destination) or (selected and cls.is_readonly(selected)): + raise ValueError(f"Skill '{name}' is read-only; installation cannot replace or shadow it") + if destination.parent.is_symlink() or destination.is_symlink(): + raise ValueError(f"Cannot install through a skill symlink: {destination}") + if destination.exists(): + if scope == "project": + raise ValueError(f"Skill '{name}' is read-only; project definitions cannot be replaced") + previous, _, _ = cls._decode_frontmatter(destination.read_bytes().decode("utf-8")) + if "group" in previous: + return cls.render_frontmatter(content, {"group": previous["group"]}) + if "group" in incoming: + normalize_skill_group(incoming["group"]) + return content + @staticmethod def _cache_context() -> tuple[str, str]: """Return the normalized request context that scopes discovery.""" @@ -335,6 +408,7 @@ def _parse_skill_md(cls, filepath: str, source: Optional[str] = None) -> Optiona return SkillInfo( name=name, + group=data.get("group"), description=description, location=filepath, source=source, @@ -350,41 +424,80 @@ def _parse_skill_md(cls, filepath: str, source: Optional[str] = None) -> Optiona return None @staticmethod - def _parse_frontmatter(content: str) -> Dict[str, Any]: - """Parse YAML frontmatter using yaml.safe_load for full nested support.""" - lines = content.splitlines() - if not lines or lines[0].strip() != "---": - return {} + def _decode_frontmatter(content: str) -> tuple[Dict[str, Any], str, str]: + """Decode native YAML or the old UI's unquoted, flat string fields. + + Recovery quotes only the offending top-level scalar, then lets the + *same* YAML decoder validate the whole document. In particular, nested + metadata is never flattened by a second key/value parser. Ambiguous or + otherwise broken YAML fails closed before a writer touches the file. + """ + import yaml - end_index = None - for i in range(1, len(lines)): - if lines[i].strip() == "---": - end_index = i + lines = content.splitlines(keepends=True) + newline = "\r\n" if lines and lines[0].endswith("\r\n") else "\n" + if not lines or lines[0].strip() != "---": + return {}, content, newline + end = next((i for i in range(1, len(lines)) if lines[i].strip() == "---"), None) + if end is None: + raise ValueError("SKILL.md frontmatter has no closing delimiter") + header = lines[1:end] + repaired: set[int] = set() + while True: + try: + parsed = yaml.safe_load("".join(header)) break + except yaml.YAMLError as exc: + mark = getattr(exc, "problem_mark", None) + index = mark.line if mark is not None else -1 + match = ( + re.fullmatch(r"(name|description|category|group):[ \t]+([^\r\n]+)[\r\n]*", header[index]) + if 0 <= index < len(header) and index not in repaired else None + ) + value = match[2].rstrip() if match else "" + # Quotes/collections/tags/anchors/block scalars are real YAML, + # not the legacy flat-string format. Do not guess at repairs. + if not value or value[0] in "'\"[{!&*|>@`%" or not re.search(r":(?:\s|$)", value): + raise ValueError("Cannot safely decode SKILL.md frontmatter") from exc + # The legacy UI wrote the whole flat value verbatim, including + # any hashes. Retain it rather than silently treating its tail + # as a YAML comment during recovery. + header[index] = f"{match[1]}: {json.dumps(value, ensure_ascii=False)}{newline}" + repaired.add(index) + if parsed is not None and not isinstance(parsed, dict): + raise ValueError("SKILL.md frontmatter must be a mapping") + return parsed or {}, "".join(lines[end + 1:]), newline - if end_index is None: + @classmethod + def _parse_frontmatter(cls, content: str) -> Dict[str, Any]: + """Discovery skips damaged definitions; writers use the strict decoder.""" + try: + return cls._decode_frontmatter(content)[0] + except ValueError: return {} - frontmatter_text = "\n".join(lines[1:end_index]) - try: - import yaml # pyyaml - parsed = yaml.safe_load(frontmatter_text) - if isinstance(parsed, dict): - return parsed - except Exception: - pass + @classmethod + def render_frontmatter( + cls, content: str, updates: Dict[str, Any], *, body: Optional[str] = None + ) -> str: + """Merge metadata without normalizing the original Markdown body bytes.""" + import yaml + + data, original_body, newline = cls._decode_frontmatter(content) + data.update(updates) + if "group" in updates: + data["group"] = normalize_skill_group(updates["group"]) + header = yaml.safe_dump(data, allow_unicode=True, sort_keys=False).replace("\n", newline) + rendered_body = original_body if body is None else newline + body + return f"---{newline}{header}---{newline}{rendered_body}" - # Fallback: simple key: value line parser (no nesting) - data: Dict[str, Any] = {} - for line in lines[1:end_index]: - if ":" not in line: - continue - key, value = line.split(":", 1) - key = key.strip() - value = value.strip().strip('"\'') - if key and value: - data[key] = value - return data + @classmethod + def update_frontmatter(cls, path: Path, updates: Dict[str, Any]) -> str: + """Patch one already-resolved SKILL.md without changing its body bytes.""" + content = path.read_bytes().decode("utf-8") + rendered = cls.render_frontmatter(content, updates) + path.write_bytes(rendered.encode("utf-8")) + return rendered @staticmethod def _is_valid_name(name: str) -> bool: diff --git a/flocks/tool/agent/delegate_task.py b/flocks/tool/agent/delegate_task.py index c467f0db3..d67186446 100644 --- a/flocks/tool/agent/delegate_task.py +++ b/flocks/tool/agent/delegate_task.py @@ -229,6 +229,7 @@ def _derive_task_description( @ToolRegistry.register_function( name="delegate_task", + group="代理协作", description=DESCRIPTION, category=ToolCategory.SYSTEM, native=True, diff --git a/flocks/tool/agent/task.py b/flocks/tool/agent/task.py index 891231729..ac55bc43b 100644 --- a/flocks/tool/agent/task.py +++ b/flocks/tool/agent/task.py @@ -32,6 +32,7 @@ @ToolRegistry.register_function( name="task", + group="代理协作", description=DESCRIPTION, category=ToolCategory.SYSTEM, native=False, diff --git a/flocks/tool/channel/channel_message.py b/flocks/tool/channel/channel_message.py index b6fac509b..a762eec32 100644 --- a/flocks/tool/channel/channel_message.py +++ b/flocks/tool/channel/channel_message.py @@ -150,6 +150,7 @@ async def _http_session_send( @ToolRegistry.register_function( name="channel_message", + group="企业协作", description=( "Send a message to the messaging channel bound to a session. " "Channel types: WeCom/企业微信=wecom, Weixin/微信=weixin, Feishu=feishu, DingTalk=dingtalk, " diff --git a/flocks/tool/channel/im_send_message.py b/flocks/tool/channel/im_send_message.py index b57255a78..7b515395c 100644 --- a/flocks/tool/channel/im_send_message.py +++ b/flocks/tool/channel/im_send_message.py @@ -203,6 +203,7 @@ async def _resolve_target( @ToolRegistry.register_function( name="im_send_message", + group="企业协作", description=( "Resolve a messaging channel target session and optionally send a message. " "Use this for WeCom/企业微信, Weixin/微信, Feishu, DingTalk, Telegram, WhatsApp, Email/邮件, Slack, " diff --git a/flocks/tool/code/bash.py b/flocks/tool/code/bash.py index af74b56bf..1ac6317f4 100644 --- a/flocks/tool/code/bash.py +++ b/flocks/tool/code/bash.py @@ -337,6 +337,7 @@ async def _resolve_sandbox_workdir( @ToolRegistry.register_function( name="bash", + group="终端执行", description=get_description(os.getcwd()), category=ToolCategory.TERMINAL, parameters=[ diff --git a/flocks/tool/code/grep.py b/flocks/tool/code/grep.py index 371ff8b15..366a240e1 100644 --- a/flocks/tool/code/grep.py +++ b/flocks/tool/code/grep.py @@ -209,6 +209,7 @@ async def ripgrep_search( @ToolRegistry.register_function( name="grep", + group="检索", description=DESCRIPTION, category=ToolCategory.SEARCH, parameters=[ diff --git a/flocks/tool/code/lsp_tool.py b/flocks/tool/code/lsp_tool.py index 88fdfafa0..bc667d6b3 100644 --- a/flocks/tool/code/lsp_tool.py +++ b/flocks/tool/code/lsp_tool.py @@ -59,6 +59,7 @@ @ToolRegistry.register_function( name="lsp", + group="代码分析", description=DESCRIPTION, category=ToolCategory.CODE, native=False, diff --git a/flocks/tool/device/intake.py b/flocks/tool/device/intake.py index 04f5d2211..d0239a3ac 100644 --- a/flocks/tool/device/intake.py +++ b/flocks/tool/device/intake.py @@ -40,6 +40,7 @@ row_to_device, storage_key_to_service_id, update_device_row, + update_device_metadata, ) from flocks.tool.device.sync import sync_service_tool_state @@ -192,6 +193,7 @@ async def create_device(body: DeviceIntegrationCreate) -> DeviceIntegration: enabled=body.enabled, verify_ssl=body.verify_ssl, db_fields=db_fields, + group=body.group or "", ) await _forget_auto_instance_ignore(storage_key) await sync_service_tool_state(service_id) @@ -207,6 +209,14 @@ async def update_device(device_id: str, body: DeviceIntegrationUpdate) -> Device if row is None: raise DeviceNotFoundError("Device not found") + if body.model_fields_set == {"group"}: + if not await update_device_metadata(device_id, group=body.group or ""): + raise DeviceNotFoundError("Device not found") + updated = await fetch_device(device_id) + if updated is None: + raise DeviceNotFoundError("Device not found") + return row_to_device(updated) + prior_fields: dict = json.loads(row["fields"] or "{}") stripped_name = body.name.strip() if body.name else "" @@ -234,6 +244,7 @@ async def update_device(device_id: str, body: DeviceIntegrationUpdate) -> Device enabled=new_enabled, verify_ssl=new_ssl, db_fields=new_fields, + group=(body.group or "") if "group" in body.model_fields_set else None, ) await sync_service_tool_state(storage_key_to_service_id(row["storage_key"])) diff --git a/flocks/tool/device/manage_tool.py b/flocks/tool/device/manage_tool.py index aa4edf066..f3c05e657 100644 --- a/flocks/tool/device/manage_tool.py +++ b/flocks/tool/device/manage_tool.py @@ -36,6 +36,7 @@ @ToolRegistry.register_function( name="device_manage", + group="系统管理", description=( "管理已接入安全设备。action=list 用于列出机房、设备、device_id 和工具集;" "action=list_templates 用于列出已有设备模板、安装状态和配置字段;" diff --git a/flocks/tool/device/models.py b/flocks/tool/device/models.py index e9bc1c27d..5753636d0 100644 --- a/flocks/tool/device/models.py +++ b/flocks/tool/device/models.py @@ -4,7 +4,8 @@ import os from typing import Any, Dict, List, Literal, Optional -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator +from pydantic_core import PydanticCustomError from flocks.storage.storage import Storage @@ -40,6 +41,7 @@ CREATE TABLE IF NOT EXISTS device_integrations ( id TEXT PRIMARY KEY, group_id TEXT NOT NULL, + plugin_group TEXT NOT NULL DEFAULT '', name TEXT NOT NULL, storage_key TEXT NOT NULL, service_id TEXT NOT NULL, @@ -68,6 +70,16 @@ async def _ensure_device_integrations_group_id(db: Any) -> None: Storage.register_ddl(_ensure_device_integrations_group_id) + +async def _ensure_device_integrations_plugin_group(db: Any) -> None: + cursor = await db.execute("PRAGMA table_info(device_integrations)") + columns = {str(row[1]) for row in await cursor.fetchall()} + if "plugin_group" not in columns: + await db.execute("ALTER TABLE device_integrations ADD COLUMN plugin_group TEXT NOT NULL DEFAULT ''") + + +Storage.register_ddl(_ensure_device_integrations_plugin_group) + Storage.register_ddl(""" CREATE INDEX IF NOT EXISTS idx_device_group ON device_integrations(group_id); """) @@ -122,6 +134,8 @@ class DeviceGroupUpdate(BaseModel): class DeviceIntegration(BaseModel): id: str group_id: str + group: str = "" + group_readonly: bool = False name: str storage_key: str service_id: str @@ -140,9 +154,22 @@ class DeviceIntegration(BaseModel): updated_at: int +def _normalize_plugin_group(value: Any) -> Optional[str]: + if value is None: + return None + if not isinstance(value, str): + raise PydanticCustomError("group_type", "group must be a string or null") + value = value.strip() + if len(value) > 32 or any(ord(char) < 32 or ord(char) == 127 for char in value): + raise PydanticCustomError("group_value", "group must contain at most 32 printable characters") + return value + + class DeviceIntegrationCreate(BaseModel): name: str storage_key: str + group: Optional[str] = None + _validate_plugin_group = field_validator("group", mode="before")(_normalize_plugin_group) group_id: Optional[str] = None # defaults to the single default group service_id: Optional[str] = None # derived from storage_key if omitted enabled: bool = True @@ -151,6 +178,8 @@ class DeviceIntegrationCreate(BaseModel): class DeviceIntegrationUpdate(BaseModel): + group: Optional[str] = None + _validate_plugin_group = field_validator("group", mode="before")(_normalize_plugin_group) name: Optional[str] = None group_id: Optional[str] = None enabled: Optional[bool] = None @@ -188,6 +217,8 @@ class DeviceTestRequest(BaseModel): class DeviceTemplate(BaseModel): + group: Optional[str] = None + group_readonly: bool = False plugin_id: str storage_key: str service_id: str diff --git a/flocks/tool/device/plugin_index.py b/flocks/tool/device/plugin_index.py index 04d221cf4..df5173190 100644 --- a/flocks/tool/device/plugin_index.py +++ b/flocks/tool/device/plugin_index.py @@ -20,7 +20,7 @@ from flocks.hub import catalog as hub_catalog from flocks.hub import local as hub_local from flocks.tool.device.models import CustomDeviceTemplateCreate, DeviceTemplate -from flocks.tool.registry import ToolRegistry +from flocks.tool.registry import ToolRegistry, is_shipped_tool_path, normalize_tool_group from flocks.tool.schema.api_service_schema import _build_api_service_credential_schema from flocks.tool.tool_loader import TOOL_TYPE_DEVICE, extract_provider_version from flocks.utils.log import Log @@ -92,6 +92,12 @@ def list_device_templates(*, refresh: bool = False) -> list[DeviceTemplate]: if _integration_type(provider) != "device": continue if descriptor.storage_key in by_key: + # Runtime descriptor discovery decides which same-key definition + # was selected; Hub display state must not lock an editable copy. + by_key[descriptor.storage_key] = by_key[descriptor.storage_key].model_copy(update={ + "group": normalize_tool_group(provider.get("group")), + "group_readonly": is_shipped_tool_path(descriptor.provider_yaml), + }) continue plugin_id = root.name source = _source_from_path(root) @@ -226,6 +232,8 @@ def _template_from_descriptor( description = _optional_str(provider.get("description")) or fallback_description description_cn = _optional_str(provider.get("description_cn")) or fallback_description_cn return DeviceTemplate( + group=normalize_tool_group(provider.get("group")), + group_readonly=is_shipped_tool_path(descriptor.provider_yaml), plugin_id=plugin_id, storage_key=descriptor.storage_key, service_id=descriptor.service_id, diff --git a/flocks/tool/device/store.py b/flocks/tool/device/store.py index 6b8400be5..2052ca4aa 100644 --- a/flocks/tool/device/store.py +++ b/flocks/tool/device/store.py @@ -126,6 +126,7 @@ def row_to_device(row: aiosqlite.Row) -> DeviceIntegration: return DeviceIntegration( id=row["id"], group_id=row["group_id"] or DEFAULT_GROUP_ID, + group=(row["plugin_group"] or "") if "plugin_group" in row.keys() else "", name=row["name"], storage_key=storage_key, service_id=derived_service_id or row["service_id"], @@ -288,6 +289,7 @@ async def insert_device( db_fields: Dict[str, str], status: str = "unknown", message: Optional[str] = None, + group: str = "", ) -> None: """Insert a new device row. ``device_id`` and ``db_fields`` must already be derived by the caller (so secrets can be persisted under their final id). @@ -298,13 +300,13 @@ async def insert_device( """ INSERT INTO device_integrations (id, group_id, name, storage_key, service_id, enabled, verify_ssl, - fields, status, message, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + fields, status, message, created_at, updated_at, plugin_group) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( device_id, group_id, name, storage_key, service_id, int(enabled), int(verify_ssl), json.dumps(db_fields), - status, message, now, now, + status, message, now, now, group, ), ) await db.commit() @@ -319,21 +321,37 @@ async def update_device_row( enabled: bool, verify_ssl: bool, db_fields: Dict[str, str], + group: Optional[str] = None, ) -> None: async with Storage.connect(Storage.get_db_path()) as db: await db.execute( """ UPDATE device_integrations - SET name=?, group_id=?, enabled=?, verify_ssl=?, fields=?, updated_at=? + SET name=?, group_id=?, enabled=?, verify_ssl=?, fields=?, updated_at=?, + plugin_group=COALESCE(?, plugin_group) WHERE id=? """, (name, group_id, int(enabled), int(verify_ssl), - json.dumps(db_fields), _now_ms(), device_id), + json.dumps(db_fields), _now_ms(), group, device_id), ) await db.commit() _bump_revision() +async def update_device_metadata(device_id: str, *, group: str) -> bool: + """Update display metadata without rewriting connection or room settings.""" + async with Storage.connect(Storage.get_db_path()) as db: + cursor = await db.execute( + "UPDATE device_integrations SET plugin_group=?, updated_at=? WHERE id=?", + (group, _now_ms(), device_id), + ) + changed = cursor.rowcount > 0 + await db.commit() + if changed: + _bump_revision() + return changed + + async def delete_device_row(device_id: str) -> None: async with Storage.connect(Storage.get_db_path()) as db: await db.execute("DELETE FROM device_integrations WHERE id = ?", (device_id,)) diff --git a/flocks/tool/file/apply_patch.py b/flocks/tool/file/apply_patch.py index 1faa07811..a4ea9456a 100644 --- a/flocks/tool/file/apply_patch.py +++ b/flocks/tool/file/apply_patch.py @@ -249,6 +249,7 @@ def generate_diff(filepath: str, old_content: str, new_content: str) -> str: @ToolRegistry.register_function( name="apply_patch", + group="文件操作", description=DESCRIPTION, category=ToolCategory.FILE, parameters=[ diff --git a/flocks/tool/file/copy.py b/flocks/tool/file/copy.py index 8a20a6624..0b5c856fa 100644 --- a/flocks/tool/file/copy.py +++ b/flocks/tool/file/copy.py @@ -14,6 +14,7 @@ @ToolRegistry.register_function( name="copy", + group="文件操作", description="Copy a file or directory.", category=ToolCategory.FILE, parameters=[ diff --git a/flocks/tool/file/delete.py b/flocks/tool/file/delete.py index 340917233..6dd2c6070 100644 --- a/flocks/tool/file/delete.py +++ b/flocks/tool/file/delete.py @@ -14,6 +14,7 @@ @ToolRegistry.register_function( name="delete", + group="文件操作", description="Delete file or directory permanently.", category=ToolCategory.FILE, parameters=[ diff --git a/flocks/tool/file/doc_parser.py b/flocks/tool/file/doc_parser.py index a10885a20..71f3ab162 100644 --- a/flocks/tool/file/doc_parser.py +++ b/flocks/tool/file/doc_parser.py @@ -648,6 +648,7 @@ def _run_extractors(file_path: Path) -> tuple[str, str, list[str]]: @ToolRegistry.register_function( name="doc_parser", + group="文件操作", description=( "Parse a PDF, Office, or HTML document into Markdown and write the " "result to an .md file. If output_path is omitted, the markdown file " diff --git a/flocks/tool/file/edit.py b/flocks/tool/file/edit.py index ca29c325c..51ba58be7 100644 --- a/flocks/tool/file/edit.py +++ b/flocks/tool/file/edit.py @@ -405,6 +405,7 @@ def _apply_edits_to_normalized_content( @ToolRegistry.register_function( name="edit", + group="文件操作", description=DESCRIPTION, category=ToolCategory.FILE, parameters=[ diff --git a/flocks/tool/file/glob.py b/flocks/tool/file/glob.py index 8b1fddd4c..bac4195ec 100644 --- a/flocks/tool/file/glob.py +++ b/flocks/tool/file/glob.py @@ -120,6 +120,7 @@ def fallback_glob( @ToolRegistry.register_function( name="glob", + group="检索", description=DESCRIPTION, category=ToolCategory.SEARCH, parameters=[ diff --git a/flocks/tool/file/mkdir.py b/flocks/tool/file/mkdir.py index 1c64e990c..3a2b01314 100644 --- a/flocks/tool/file/mkdir.py +++ b/flocks/tool/file/mkdir.py @@ -13,6 +13,7 @@ @ToolRegistry.register_function( name="mkdir", + group="文件操作", description="Create a directory recursively.", category=ToolCategory.FILE, parameters=[ diff --git a/flocks/tool/file/move.py b/flocks/tool/file/move.py index 4c05fcd9c..c64ac6730 100644 --- a/flocks/tool/file/move.py +++ b/flocks/tool/file/move.py @@ -14,6 +14,7 @@ @ToolRegistry.register_function( name="move", + group="文件操作", description="Move or rename a file or directory.", category=ToolCategory.FILE, parameters=[ diff --git a/flocks/tool/file/read.py b/flocks/tool/file/read.py index 8017ce599..611e9bdfb 100644 --- a/flocks/tool/file/read.py +++ b/flocks/tool/file/read.py @@ -151,6 +151,7 @@ def find_similar_files(directory: str, filename: str, max_suggestions: int = 3) @ToolRegistry.register_function( name="read", + group="文件操作", description=DESCRIPTION, category=ToolCategory.FILE, parameters=[ diff --git a/flocks/tool/file/write.py b/flocks/tool/file/write.py index dd96ce370..9a8c47477 100644 --- a/flocks/tool/file/write.py +++ b/flocks/tool/file/write.py @@ -229,6 +229,7 @@ def _existing_memory_write_error(filepath: str) -> Optional[str]: @ToolRegistry.register_function( name="write", + group="文件操作", description=DESCRIPTION, category=ToolCategory.FILE, parameters=[ diff --git a/flocks/tool/registry.py b/flocks/tool/registry.py index 8c789cdec..419b63812 100644 --- a/flocks/tool/registry.py +++ b/flocks/tool/registry.py @@ -5,6 +5,7 @@ Compatible with Flocks's TypeScript Tool system. """ +import ast import asyncio import importlib import json @@ -19,7 +20,8 @@ from pathlib import Path from typing import Any, Awaitable, Callable, Dict, List, Mapping, Optional, Set -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator +from pydantic_core import PydanticCustomError from flocks.utils.log import Log @@ -92,6 +94,37 @@ def to_json_schema(self) -> Dict[str, Any]: return schema +def normalize_tool_group(value: Optional[str]) -> Optional[str]: + """Validate native tool grouping metadata (not a runtime parameter).""" + if value is None: + return None + if not isinstance(value, str): + raise PydanticCustomError("group_type", "group must be a string or null") + value = value.strip() + if len(value) > 32: + raise PydanticCustomError("group_length", "group must be at most 32 characters") + if any(ord(char) < 32 or ord(char) == 127 for char in value): + raise PydanticCustomError("group_control", "group cannot contain control characters") + return value + + +def is_shipped_tool_path(path: Path) -> bool: + """Whether the resolved definition belongs to the installation's native tools. + + Deliberately independent of cwd, Instance, ``native`` and plugin names. + User/Hub copies (and symlinks pointing outside the installation) stay editable. + """ + root = Path(__file__).resolve().parents[2] / ".flocks" / "plugins" / "tools" + return Path(path).resolve().is_relative_to(root.resolve()) + + +def validate_tool_definition_group(path: Path, current: Mapping[str, Any], updates: Mapping[str, Any]) -> None: + """Preflight a partial native definition update before writing other fields.""" + if "group" in updates and is_shipped_tool_path(path): + if normalize_tool_group(updates["group"]) != normalize_tool_group(current.get("group")): + raise ValueError("System tool group is read-only") + + class ToolInfo(BaseModel): """Tool information""" name: str = Field(..., description="Tool name (unique identifier)") @@ -133,6 +166,11 @@ class ToolInfo(BaseModel): ), ) + group: Optional[str] = Field(None, description="Optional plugin group") + group_readonly: bool = Field(False, description="Computed from the actual system definition source") + + _validate_group = field_validator("group", mode="before")(normalize_tool_group) + def get_schema(self) -> ToolSchema: """Generate JSON Schema for this tool.""" properties = {} @@ -802,6 +840,51 @@ class ToolRegistry: Compatible with Flocks's Tool Registry pattern. """ + _builtin_module_groups = [ + # file/ — filesystem operations + ( + "flocks.tool.file", + [ + "read", + "write", + "edit", + "apply_patch", + "glob", + "doc_parser", + "delete", + "move", + "copy", + "mkdir", + ], + ), + # code/ — code analysis + terminal + ("flocks.tool.code", ["bash", "grep", "lsp_tool"]), + # web/ — internet access + ("flocks.tool.web", ["webfetch", "websearch"]), + # agent/ — agent delegation/coordination + ("flocks.tool.agent", ["delegate_task", "task"]), + # task/ — task/workflow + ("flocks.tool.task", [ + "schedule_task_center", + "todo", + "run_workflow", + "run_workflow_node", + "workflow_config_manage", + ]), + # security/ — SSH forensics + threat intelligence (optional: asyncssh) + ("flocks.tool.security", ["ssh_host_cmd", "ssh_run_script"]), + # system/ — questions, model config, memory, MCP management, session management, slash commands + ("flocks.tool.system", ["question", "plan_exit", "model_config", "memory", "flocks_mcp", "session_manage", "slash_command", "tool_search"]), + # skill/ — skill management (search, install, status, deps, remove, load) + ("flocks.tool.skill", ["flocks_skills", "skill_load"]), + # device/ — security device asset context and status probes + ("flocks.tool.device", ["manage_tool"]), + # channel/ — IM platform messaging + ("flocks.tool.channel", ["channel_message", "im_send_message"]), + # wecom/ — 企业微信 MCP(文档、智能表格) + ("flocks.tool.wecom", ["wecom_mcp"]), + ] + _tools: Dict[str, Tool] = {} _initialized: bool = False _dynamic_modules: Dict[str, str] = {} @@ -836,9 +919,80 @@ class ToolRegistry: # leaking across ``refresh_plugin_tools`` cycles). _enabled_defaults: Dict[str, bool] = {} + @classmethod + def _core_definition_paths(cls) -> List[Path]: + package_root = Path(__file__).resolve().parents[1] + return [ + package_root.joinpath(*package.split(".")[1:], f"{module}.py").resolve() + for package, modules in cls._builtin_module_groups + for module in modules + ] + [Path(__file__).resolve()] + + @classmethod + def _is_core_handler(cls, handler: Callable) -> bool: + code = getattr(handler, "__code__", None) + return code is not None and Path(code.co_filename).resolve() in cls._core_definition_paths() + + @classmethod + def _definition_group_readonly(cls, tool: Tool) -> bool: + definition = getattr(tool, "_yaml_path", None) or getattr(tool, "_definition_path", None) + if definition is not None: + return is_shipped_tool_path(definition) + if cls._is_core_handler(tool.handler): + return True + code = getattr(tool.handler, "__code__", None) + return code is not None and is_shipped_tool_path(Path(code.co_filename)) + + @classmethod + def validate_group_settings(cls, settings: Mapping[str, Any]) -> None: + """Preflight incoming tool_settings without loading plugins or config. + + Safe inside ConfigWriter and generic config preflight: enabled-only + entries do nothing, and even a cold registry uses metadata-only reads. + No bootstrap, config overlay, plugin execution or ConfigWriter calls. + """ + for name, entry in settings.items(): + if not isinstance(entry, Mapping) or "group" not in entry: + continue + desired = normalize_tool_group(entry["group"]) + tool = cls._tools.get(name) + if tool is not None: + readonly = cls._definition_group_readonly(tool) + group = tool.info.group + else: + readonly, group = cls._unloaded_definition_group(name) + if readonly and desired != normalize_tool_group(group): + raise ValueError(f"System tool group is read-only: {name}") + + @classmethod + def _unloaded_definition_group(cls, name: str) -> tuple[bool, Optional[str]]: + # Core registration wins over plugin discovery, including modules + # imported before init and intentionally non-native core tools. + for path in cls._core_definition_paths(): + if not path.is_file(): + continue + for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))): + if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute): + continue + if node.func.attr != "register_function": + continue + fields = {kw.arg: kw.value for kw in node.keywords} + name_node = fields.get("name") + if isinstance(name_node, ast.Constant) and name_node.value == name: + group_node = fields.get("group") + group = ast.literal_eval(group_node) if group_node is not None else None + return True, normalize_tool_group(group) + from flocks.tool.tool_loader import find_yaml_tool, _read_yaml_raw + + path = find_yaml_tool(name) + if path is not None and is_shipped_tool_path(path): + return True, normalize_tool_group(_read_yaml_raw(path).get("group")) + return False, None + @classmethod def register(cls, tool: Tool) -> None: """Register a tool""" + tool.info.group_readonly = cls._definition_group_readonly(tool) try: from flocks.tool.catalog import apply_tool_catalog_defaults @@ -960,6 +1114,7 @@ def register_function( always_load: Optional[bool] = None, tags: Optional[List[str]] = None, enabled: bool = True, + group: Optional[str] = None, ) -> Callable[[ToolHandler], ToolHandler]: """ Decorator to register a function as a tool. @@ -990,6 +1145,7 @@ def decorator(func: ToolHandler) -> ToolHandler: "always_load": always_load, "tags": list(tags or []), "enabled": enabled, + "group": group, } if native is not None: info_kwargs["native"] = native @@ -1788,12 +1944,16 @@ def _consume_tools(items: list, source: str) -> Optional[List[str]]: info = ToolInfo( name=name, description=spec.get("description", ""), + group=spec.get("group"), category=category, parameters=params, source="plugin_py", native=is_native, ) - cls.register(Tool(info=info, handler=handler)) + tool = Tool(info=info, handler=handler) + if Path(source).is_file(): + tool._definition_path = Path(source) + cls.register(tool) return errors def _dedup_key(item: Any) -> str: @@ -1819,59 +1979,12 @@ def _dedup_key(item: Any) -> str: def _register_builtin_tools(cls) -> None: """Register built-in tools by importing tool modules. - All tools registered during these imports are marked ``native=True`` - after the fact. Using a post-import bulk update means individual - ``@register_function`` call sites don't need to pass ``native=True`` - explicitly, and user plugin Python files that also use - ``@register_function`` won't accidentally inherit native status. + Reconcile actual core handlers, including previously imported modules. + Implicit native flags default to True; explicit False remains meaningful + for discovery/search. Group ownership is independent of that flag, and + imported user plugins never inherit core status from the import delta. """ - before = set(cls._tools.keys()) - - _tool_groups = [ - # file/ — filesystem operations - ( - "flocks.tool.file", - [ - "read", - "write", - "edit", - "apply_patch", - "glob", - "doc_parser", - "delete", - "move", - "copy", - "mkdir", - ], - ), - # code/ — code analysis + terminal - ("flocks.tool.code", ["bash", "grep", "lsp_tool"]), - # web/ — internet access - ("flocks.tool.web", ["webfetch", "websearch"]), - # agent/ — agent delegation/coordination - ("flocks.tool.agent", ["delegate_task", "task"]), - # task/ — task/workflow - ("flocks.tool.task", [ - "schedule_task_center", - "todo", - "run_workflow", - "run_workflow_node", - "workflow_config_manage", - ]), - # security/ — SSH forensics + threat intelligence (optional: asyncssh) - ("flocks.tool.security", ["ssh_host_cmd", "ssh_run_script"]), - # system/ — questions, model config, memory, MCP management, session management, slash commands - ("flocks.tool.system", ["question", "plan_exit", "model_config", "memory", "flocks_mcp", "session_manage", "slash_command", "tool_search"]), - # skill/ — skill management (search, install, status, deps, remove, load) - ("flocks.tool.skill", ["flocks_skills", "skill_load"]), - # device/ — security device asset context and status probes - ("flocks.tool.device", ["manage_tool"]), - # channel/ — IM platform messaging - ("flocks.tool.channel", ["channel_message", "im_send_message"]), - # wecom/ — 企业微信 MCP(文档、智能表格) - ("flocks.tool.wecom", ["wecom_mcp"]), - ] - for package, modules in _tool_groups: + for package, modules in cls._builtin_module_groups: for mod_name in modules: try: importlib.import_module(f"{package}.{mod_name}") @@ -1882,8 +1995,12 @@ def _register_builtin_tools(cls) -> None: # explicitly declare native. This keeps the default convenient for # built-ins while preserving native=False for management tools that # should be discovered through tool_search. - for name in set(cls._tools.keys()) - before: - tool = cls._tools[name] + for tool in cls._tools.values(): + if getattr(tool, "_yaml_path", None) or getattr(tool, "_definition_path", None): + continue + if not cls._is_core_handler(tool.handler): + continue + tool.info.group_readonly = True fields_set = getattr(tool.info, "model_fields_set", None) if fields_set is None: fields_set = getattr(tool.info, "__fields_set__", set()) @@ -1895,6 +2012,7 @@ def _register_builtin_tools(cls) -> None: if "get_time" not in cls._tools: @cls.register_function( name="get_time", + group="系统管理", description="Get current date and time in ISO 8601 or Unix timestamp format", category=ToolCategory.SYSTEM, native=True, diff --git a/flocks/tool/schema/api_service_schema.py b/flocks/tool/schema/api_service_schema.py index 781a57b94..a7f95a734 100644 --- a/flocks/tool/schema/api_service_schema.py +++ b/flocks/tool/schema/api_service_schema.py @@ -16,10 +16,12 @@ import yaml from pydantic import BaseModel +from pydantic_core import PydanticCustomError from flocks.config.api_versioning import discover_api_service_descriptors from flocks.config.config_writer import ConfigWriter from flocks.tool.tool_loader import extract_provider_version +from flocks.tool.registry import is_shipped_tool_path from flocks.utils.log import Log log = Log.create(service="tool.api_service.schema") @@ -49,6 +51,20 @@ class APIServiceCredentialField(BaseModel): # Small helpers # --------------------------------------------------------------------------- +def normalize_api_service_group(value: Optional[str]) -> Optional[str]: + """Validate native service metadata independently of credential fields.""" + if value is None: + return None + if not isinstance(value, str): + raise PydanticCustomError("group_type", "group must be a string or null") + value = value.strip() + if len(value) > 32: + raise PydanticCustomError("group_length", "group must be at most 32 characters") + if any(ord(char) < 32 or ord(char) == 127 for char in value): + raise PydanticCustomError("group_control", "group cannot contain control characters") + return value + + def _default_api_service_field_label(field_key: str) -> str: labels = { "api_key": "API Key", @@ -147,6 +163,8 @@ def _load_provider_yaml_metadata(provider_id: str) -> Optional[Dict[str, Any]]: "name": prov.get("name", provider_id), "service_id": prov.get("service_id", provider_id), "version": extract_provider_version(prov), + "group": normalize_api_service_group(prov.get("group")), + "group_readonly": is_shipped_tool_path(descriptor.provider_yaml), "description": prov.get("description"), "description_cn": prov.get("description_cn"), "docs_url": prov.get("docs_url"), @@ -162,8 +180,26 @@ def _load_provider_yaml_metadata(provider_id: str) -> Optional[Dict[str, Any]]: return None +def project_api_service_group( + merged: Dict[str, Any], definition: Optional[Dict[str, Any]], raw_service: Optional[Dict[str, Any]], +) -> None: + """Project source ownership, never a client-supplied readonly flag. + + Even an empty raw record is an editable connection instance. Definition-only + rows keep their canonical descriptor group, including with old JSON metadata. + """ + readonly = bool(definition and definition.get("group_readonly") and raw_service is None) + if merged: + merged["group_readonly"] = readonly + if readonly: + merged["group"] = definition.get("group") + + def _load_api_service_metadata_data(provider_id: str) -> Optional[Dict[str, Any]]: """Load raw API service metadata from config, metadata JSON, or YAML provider.""" + from flocks.config.api_versioning import versioned_storage_key_for + + provider_id = versioned_storage_key_for(provider_id) or provider_id merged: Dict[str, Any] = {} config_data = ConfigWriter.get_api_service_raw(provider_id) @@ -181,6 +217,7 @@ def _load_api_service_metadata_data(provider_id: str) -> Optional[Dict[str, Any] if isinstance(yaml_data, dict): merged = {**yaml_data, **merged} + project_api_service_group(merged, yaml_data, config_data) return merged or None @@ -209,6 +246,10 @@ def _normalize_api_service_credential_field( if not isinstance(config_key, str) or not config_key.strip(): config_key = "apiKey" if key == "api_key" else key + # Native plugin metadata must never become a credential/runtime field. + if key == "group" or config_key.strip() == "group": + return None + label = raw_field.get("label") if not isinstance(label, str) or not label.strip(): label = _default_api_service_field_label(key) diff --git a/flocks/tool/security/ssh_host_cmd.py b/flocks/tool/security/ssh_host_cmd.py index 69f48f919..be8624902 100644 --- a/flocks/tool/security/ssh_host_cmd.py +++ b/flocks/tool/security/ssh_host_cmd.py @@ -220,6 +220,7 @@ async def execute_ssh_host_command( @ToolRegistry.register_function( name="ssh_host_cmd", + group="终端执行", description="Execute a command on a remote Linux host via SSH.", category=ToolCategory.TERMINAL, parameters=[ diff --git a/flocks/tool/security/ssh_run_script.py b/flocks/tool/security/ssh_run_script.py index e2a0a4496..8d5c1a142 100644 --- a/flocks/tool/security/ssh_run_script.py +++ b/flocks/tool/security/ssh_run_script.py @@ -126,6 +126,7 @@ async def execute_ssh_script_content( @ToolRegistry.register_function( name="ssh_run_script", + group="终端执行", description="Execute a local shell script on a remote Linux host via SSH.", category=ToolCategory.TERMINAL, parameters=[ diff --git a/flocks/tool/skill/flocks_skills.py b/flocks/tool/skill/flocks_skills.py index 5a1c2ff21..471ec4ae1 100644 --- a/flocks/tool/skill/flocks_skills.py +++ b/flocks/tool/skill/flocks_skills.py @@ -55,6 +55,7 @@ def _flocks_executable() -> Optional[str]: @ToolRegistry.register_function( name="flocks_skills", + group="系统管理", description=_DESCRIPTION, category=ToolCategory.SYSTEM, parameters=[ diff --git a/flocks/tool/skill/skill_load.py b/flocks/tool/skill/skill_load.py index 0f70e6271..7597ad301 100644 --- a/flocks/tool/skill/skill_load.py +++ b/flocks/tool/skill/skill_load.py @@ -216,6 +216,7 @@ async def get_skill(name: str) -> dict | None: @ToolRegistry.register_function( name="skill_load", + group="系统管理", description=SKILL_TOOL_DESCRIPTION, category=ToolCategory.SYSTEM, native=True, diff --git a/flocks/tool/system/flocks_mcp.py b/flocks/tool/system/flocks_mcp.py index 7442f393b..36c0981d7 100644 --- a/flocks/tool/system/flocks_mcp.py +++ b/flocks/tool/system/flocks_mcp.py @@ -30,6 +30,7 @@ @ToolRegistry.register_function( name="flocks_mcp", + group="系统管理", description=( "Manage MCP servers registered in Flocks. " "Use 'list' to see all servers and their status. " diff --git a/flocks/tool/system/memory.py b/flocks/tool/system/memory.py index 631e13d55..eacf392cc 100644 --- a/flocks/tool/system/memory.py +++ b/flocks/tool/system/memory.py @@ -66,6 +66,7 @@ def evict_session_memory(session_id: str) -> None: @ToolRegistry.register_function( name="memory_search", + group="检索", description=( "Search USER, Global, Daily, and current Project Memory, plus optional " "readable Session History from the current project. Use query only for " diff --git a/flocks/tool/system/model_config.py b/flocks/tool/system/model_config.py index 5ad8cc0d0..59a41276a 100644 --- a/flocks/tool/system/model_config.py +++ b/flocks/tool/system/model_config.py @@ -40,6 +40,7 @@ @ToolRegistry.register_function( name="list_providers", + group="系统管理", description=LIST_PROVIDERS_DESC, category=ToolCategory.SYSTEM, native=False, @@ -128,6 +129,7 @@ async def list_providers_tool( @ToolRegistry.register_function( name="add_provider", + group="系统管理", description=ADD_PROVIDER_DESC, category=ToolCategory.SYSTEM, requires_confirmation=True, @@ -248,6 +250,7 @@ async def add_provider_tool( @ToolRegistry.register_function( name="add_model", + group="系统管理", description=ADD_MODEL_DESC, category=ToolCategory.SYSTEM, requires_confirmation=True, diff --git a/flocks/tool/system/plan_exit.py b/flocks/tool/system/plan_exit.py index 2dacda008..2e95b8b67 100644 --- a/flocks/tool/system/plan_exit.py +++ b/flocks/tool/system/plan_exit.py @@ -64,6 +64,7 @@ async def _turn_model_and_variant( @ToolRegistry.register_function( name="plan_exit", + group="任务与工作流", description=DESCRIPTION, category=ToolCategory.SYSTEM, parameters=[], diff --git a/flocks/tool/system/question.py b/flocks/tool/system/question.py index e97447d3b..166ac3e91 100644 --- a/flocks/tool/system/question.py +++ b/flocks/tool/system/question.py @@ -257,6 +257,7 @@ async def default_question_handler( @ToolRegistry.register_function( name="question", + group="系统管理", description=DESCRIPTION, category=ToolCategory.SYSTEM, parameters=[ diff --git a/flocks/tool/system/session_manage.py b/flocks/tool/system/session_manage.py index 9eb070f67..cd986df58 100644 --- a/flocks/tool/system/session_manage.py +++ b/flocks/tool/system/session_manage.py @@ -156,6 +156,7 @@ @ToolRegistry.register_function( name="session_manage", + group="系统管理", description=SESSION_MANAGE_DESCRIPTION, category=ToolCategory.SYSTEM, parameters=SESSION_MANAGE_PARAMETERS, diff --git a/flocks/tool/system/slash_command.py b/flocks/tool/system/slash_command.py index 55afe4467..7b4af1364 100644 --- a/flocks/tool/system/slash_command.py +++ b/flocks/tool/system/slash_command.py @@ -95,6 +95,7 @@ def _usage_for_command(command: str) -> str: @ToolRegistry.register_function( name="run_slash_command", + group="系统管理", description=build_run_slash_command_description(), category=ToolCategory.SYSTEM, parameters=[ diff --git a/flocks/tool/system/tool_search.py b/flocks/tool/system/tool_search.py index aaf1fc1bb..8b8be9c59 100644 --- a/flocks/tool/system/tool_search.py +++ b/flocks/tool/system/tool_search.py @@ -85,6 +85,7 @@ async def _build_device_tool_hints(matches: List[Dict[str, Any]]) -> Dict[str, D @ToolRegistry.register_function( name="tool_search", + group="检索", description=DESCRIPTION, category=ToolCategory.SYSTEM, parameters=[ diff --git a/flocks/tool/task/run_workflow.py b/flocks/tool/task/run_workflow.py index 7c50452b7..f6c887ec6 100644 --- a/flocks/tool/task/run_workflow.py +++ b/flocks/tool/task/run_workflow.py @@ -376,6 +376,7 @@ async def _record_workflow_tool_result(workflow_id: str, result: Any) -> None: @ToolRegistry.register_function( name="run_workflow", + group="任务与工作流", description=DESCRIPTION, category=ToolCategory.SYSTEM, requires_confirmation=True, diff --git a/flocks/tool/task/run_workflow_node.py b/flocks/tool/task/run_workflow_node.py index bb81908e0..2b559ef5c 100644 --- a/flocks/tool/task/run_workflow_node.py +++ b/flocks/tool/task/run_workflow_node.py @@ -121,6 +121,7 @@ def _format_node_result(result: Dict[str, Any]) -> str: @ToolRegistry.register_function( name="run_workflow_node", + group="任务与工作流", description=DESCRIPTION, category=ToolCategory.SYSTEM, requires_confirmation=False, diff --git a/flocks/tool/task/schedule_task_center.py b/flocks/tool/task/schedule_task_center.py index 04c2c7d12..6d8929425 100644 --- a/flocks/tool/task/schedule_task_center.py +++ b/flocks/tool/task/schedule_task_center.py @@ -497,6 +497,7 @@ async def schedule_task_rerun( @ToolRegistry.register_function( name="schedule_task", + group="任务与工作流", description=( "Manage task scheduler definitions and execution instances. Always select " "resource_type. Scheduler actions: create, list, status, update, enable, " diff --git a/flocks/tool/task/todo.py b/flocks/tool/task/todo.py index dd275858d..81e58d0d2 100644 --- a/flocks/tool/task/todo.py +++ b/flocks/tool/task/todo.py @@ -159,6 +159,7 @@ def _verification_nudge_needed(todos: List[TodoInfo]) -> bool: @ToolRegistry.register_function( name="todo", + group="任务与工作流", description=TODO_DESCRIPTION, category=ToolCategory.SYSTEM, parameters=[ diff --git a/flocks/tool/task/workflow_config_manage.py b/flocks/tool/task/workflow_config_manage.py index 90aa078ed..05b1be365 100644 --- a/flocks/tool/task/workflow_config_manage.py +++ b/flocks/tool/task/workflow_config_manage.py @@ -429,6 +429,7 @@ async def _save_runtime_config(workflow_id: str, config_type: str, config: Dict[ @ToolRegistry.register_function( name="workflow_config_manage", + group="任务与工作流", description=DESCRIPTION, description_cn=DESCRIPTION_CN, category=ToolCategory.SYSTEM, diff --git a/flocks/tool/tool_loader.py b/flocks/tool/tool_loader.py index b6c6348a8..bef14dd4f 100644 --- a/flocks/tool/tool_loader.py +++ b/flocks/tool/tool_loader.py @@ -34,6 +34,9 @@ ToolInfo, ToolParameter, ToolResult, + normalize_tool_group, + is_shipped_tool_path, + validate_tool_definition_group, ) from flocks.utils.log import Log @@ -660,6 +663,8 @@ def yaml_to_tool(raw: dict, yaml_path: Path) -> Tool: name=name, description=raw.get("description", ""), description_cn=raw.get("description_cn", "") or None, + group=raw.get("group"), + group_readonly=is_shipped_tool_path(yaml_path), category=category, parameters=parameters, enabled=raw.get("enabled", True), @@ -730,6 +735,15 @@ def _find_yaml_file(name: str) -> Optional[Path]: The ``mcp/`` subdirectory is skipped — MCP configs have a different format and are managed via :func:`find_mcp_config`. """ + # An already selected definition is authoritative, including filenames + # that differ from the tool's declared name and device tool directories. + from flocks.tool.registry import ToolRegistry + + tool = ToolRegistry._tools.get(name) + selected = getattr(tool, "_yaml_path", None) + if selected is not None and Path(selected).is_file(): + return Path(selected) + for tools_root in _yaml_tool_search_roots(): if not tools_root.is_dir(): continue @@ -770,6 +784,23 @@ def _find_yaml_file(name: str) -> Optional[Path]: if candidate.is_file(): return candidate + # Fall back to native discovery identity, not a filename convention. + from flocks.plugin.loader import scan_directory + + for root in _yaml_tool_search_roots(): + for source in scan_directory(root, recursive=True, max_depth=2, exclude_subdirs={"mcp", "generated"}): + path = Path(source) + if path.suffix not in {".yaml", ".yml"}: + continue + try: + raw = _read_yaml_raw(path) + except (OSError, yaml.YAMLError): + continue + if isinstance(raw, dict) and raw.get("name") == name and ( + isinstance(raw.get("handler"), dict) and raw["handler"] + or isinstance(raw.get("execution"), dict) and raw["execution"] + ): + return path return None @@ -841,6 +872,17 @@ def create_yaml_tool( if _find_yaml_file(name): raise ValueError(f"Tool '{name}' already exists") + from flocks.tool.registry import ToolRegistry + + existing = ToolRegistry._tools.get(name) + readonly = ( + ToolRegistry._definition_group_readonly(existing) + if existing is not None else ToolRegistry._unloaded_definition_group(name)[0] + ) + if readonly: + raise ValueError(f"Cannot shadow system tool definition: {name}") + if "group" in data: + data = {**data, "group": normalize_tool_group(data["group"]) or ""} base_dir = _TOOLS_SUBDIR / tool_type if provider: @@ -854,6 +896,18 @@ def create_yaml_tool( return target_path +def validate_yaml_tool_replacement(name: str, path: Path) -> None: + """An editor/reload cannot replace a selected system tool with a shadow.""" + from flocks.tool.registry import ToolRegistry + + existing = ToolRegistry._tools.get(name) + if existing is None or not ToolRegistry._definition_group_readonly(existing): + return + selected = getattr(existing, "_yaml_path", None) + if selected is None or Path(selected).resolve() != path.resolve(): + raise ValueError(f"Cannot replace system tool definition: {name}") + + def update_yaml_tool(name: str, updates: Dict[str, Any]) -> bool: """Apply partial updates to a YAML plugin tool file. @@ -863,11 +917,15 @@ def update_yaml_tool(name: str, updates: Dict[str, Any]) -> bool: if path is None: return False - try: - data = _read_yaml_raw(path) + validate_yaml_tool_replacement(name, path) + data = _read_yaml_raw(path) + validate_tool_definition_group(path, data, updates) + try: for key, value in updates.items(): - if value is not None: + if key == "group": + data[key] = normalize_tool_group(value) or "" + elif value is not None: data[key] = value else: data.pop(key, None) @@ -1104,6 +1162,8 @@ def _collect(directory: Path, depth: int = 0, max_depth: int = 2) -> None: results.append({ "name": name, "description": data.get("description", ""), + "group": normalize_tool_group(data.get("group")), + "group_readonly": is_shipped_tool_path(yf), "provider": provider_name, "handler_type": handler.get("type", "unknown") if isinstance(handler, dict) else "unknown", "tool_type": _infer_tool_type(yf), @@ -1128,7 +1188,9 @@ def _mcp_filename(name: str) -> str: return name.replace("-", "_") -def save_mcp_config(name: str, config: Dict[str, Any]) -> Path: +def save_mcp_config( + name: str, config: Dict[str, Any], *, metadata_only: bool = False, +) -> Path: """Save an MCP server config to ``~/.flocks/plugins/tools/mcp/{name}.yaml``. Parameters @@ -1137,15 +1199,43 @@ def save_mcp_config(name: str, config: Dict[str, Any]) -> Path: MCP server name (e.g. ``"brave-search"``). config: Server configuration dict (type, command/url, environment, etc.). + metadata_only: + Update only native grouping in an existing YAML, preserving its other + metadata and connection fields even when they differ from runtime config. Returns ------- Path to the created/updated YAML file. """ + from flocks.config.config_writer import ConfigWriter + from flocks.mcp.types import ServerConfig, normalize_mcp_group + from flocks.config.config import McpLocalConfig, McpRemoteConfig + filename = _mcp_filename(name) - target = _MCP_SUBDIR / f"{filename}.yaml" - data: Dict[str, Any] = {"name": name} - data.update(config) + target = find_mcp_config(name) or (_MCP_SUBDIR / f"{filename}.yaml") + previous = _read_yaml_raw(target) if target.is_file() else {} + if not isinstance(previous, dict): + raise ValueError(f"MCP YAML must contain a mapping: {target}") + if metadata_only and previous: + data = dict(previous) + else: + # Preserve YAML-only catalog metadata, never stale transport/auth fields + # when a local connection is replaced by a remote one (or vice versa). + connection_keys = ( + set(ServerConfig.model_fields) | set(McpLocalConfig.model_fields) + | set(McpRemoteConfig.model_fields) | {"env", "environment"} + ) - {"group", "metadata"} + data = {key: value for key, value in previous.items() if key not in connection_keys} + data.update(config) + data["name"] = name + if "group" in config: + data["group"] = normalize_mcp_group(config["group"]) or "" + else: + # Callers often save JSON first with the same omitted field. Its current + # explicit group is authoritative over an older YAML copy. + current = ConfigWriter.get_mcp_server(name) + if isinstance(current, dict) and "group" in current: + data["group"] = normalize_mcp_group(current["group"]) or "" _write_yaml(target, data) log.info("tool.mcp_config.saved", {"name": name, "path": str(target)}) return target diff --git a/flocks/tool/web/webfetch.py b/flocks/tool/web/webfetch.py index 86c6fe073..b29b30a44 100644 --- a/flocks/tool/web/webfetch.py +++ b/flocks/tool/web/webfetch.py @@ -160,6 +160,7 @@ def extract_text_from_html(html: str) -> str: @ToolRegistry.register_function( name="webfetch", + group="检索", description=DESCRIPTION, category=ToolCategory.BROWSER, parameters=[ diff --git a/flocks/tool/web/websearch.py b/flocks/tool/web/websearch.py index c1d9c7f72..652868ee6 100644 --- a/flocks/tool/web/websearch.py +++ b/flocks/tool/web/websearch.py @@ -46,6 +46,7 @@ def get_description() -> str: @ToolRegistry.register_function( name="websearch", + group="检索", description=get_description(), category=ToolCategory.SEARCH, parameters=[ diff --git a/flocks/tool/wecom/wecom_mcp.py b/flocks/tool/wecom/wecom_mcp.py index 4b22433ac..014579105 100644 --- a/flocks/tool/wecom/wecom_mcp.py +++ b/flocks/tool/wecom/wecom_mcp.py @@ -395,6 +395,7 @@ def _parse_args(args: Any) -> dict: @ToolRegistry.register_function( name="wecom_mcp", + group="企业协作", description=( "Discover and call tools provided by the connected WeCom MCP server. " "Use action='list' to inspect method names and input schemas for a category, " diff --git a/flocks/workflow/fs_store.py b/flocks/workflow/fs_store.py index 0f3471959..e22b5aeb3 100644 --- a/flocks/workflow/fs_store.py +++ b/flocks/workflow/fs_store.py @@ -3,10 +3,11 @@ from __future__ import annotations import json -import os from pathlib import Path from typing import Any, Dict, Optional +from pydantic_core import PydanticCustomError + from flocks.utils.log import Log from .center import resolve_workflow_scan_roots @@ -14,6 +15,17 @@ log = Log.create(service="workflow.fs-store") _workspace_root: Optional[Path] = None +_SYSTEM_WORKFLOW_ROOT = Path(__file__).resolve().parents[2] / ".flocks" / "plugins" / "workflows" + + +def is_system_workflow_definition(wf_dir: Path) -> bool: + """Check the actual selected source, never a project/native display flag.""" + for filename in ("workflow.json", "workflow.md", "workflow.edit.md"): + source = wf_dir / filename + if source.is_file(): + return source.resolve().is_relative_to(_SYSTEM_WORKFLOW_ROOT.resolve()) + return False + _EMPTY_DRAFT_WORKFLOW_JSON: Dict[str, Any] = { "start": "", "nodes": [], @@ -21,6 +33,20 @@ } +def normalize_workflow_group(value: Any) -> str: + """Validate a workflow's native meta.json group value.""" + if value is None: + return "" + if not isinstance(value, str): + raise PydanticCustomError("group_type", "group must be a string or null") + value = value.strip() + if any(ord(char) < 32 or ord(char) == 127 for char in value): + raise PydanticCustomError("group_control", "group must not contain control characters") + if len(value) > 32: + raise PydanticCustomError("group_length", "group must be at most 32 characters") + return value + + def _markdown_title(markdown_content: Optional[str], fallback: str) -> str: if not markdown_content: return fallback @@ -138,19 +164,25 @@ def read_workflow_dir( updated_candidates.append(int(legacy_edit_md_file.stat().st_mtime * 1000)) meta_file = wf_dir / "meta.json" + fallback_updated = max(updated_candidates) + meta = { + "name": workflow_json.get("name") or _markdown_title(markdown_content, workflow_id), + "description": workflow_json.get("description"), + "category": workflow_json.get("category", "default"), + "status": "active" if json_file.is_file() else "draft", + "createdBy": None, + "createdAt": fallback_updated, + "updatedAt": fallback_updated, + } if meta_file.is_file(): - meta = json.loads(meta_file.read_text(encoding="utf-8")) - else: - fallback_updated = max(updated_candidates) - meta = { - "name": workflow_json.get("name") or _markdown_title(markdown_content, workflow_id), - "description": workflow_json.get("description"), - "category": workflow_json.get("category", "default"), - "status": "active" if json_file.is_file() else "draft", - "createdBy": None, - "createdAt": fallback_updated, - "updatedAt": fallback_updated, - } + meta.update(json.loads(meta_file.read_text(encoding="utf-8"))) + if "group" in meta: + meta["group"] = normalize_workflow_group(meta["group"]) + + # metadata.group is an import/export transport field, not a second + # local authority. Local readers always use this workflow's meta.json. + if isinstance(workflow_json.get("metadata"), dict): + workflow_json["metadata"].pop("group", None) name_i18n = _workflow_name_i18n(workflow_json, meta) if name_i18n: @@ -165,6 +197,7 @@ def read_workflow_dir( **meta, "id": workflow_id, "source": source, + "group_readonly": is_system_workflow_definition(wf_dir), "workflowJson": workflow_json, "markdownContent": markdown_content, "editMarkdownContent": markdown_content, @@ -177,14 +210,60 @@ def read_workflow_dir( return None +def patch_workflow_metadata( + wf_dir: Path, updates: Dict[str, Any], *, workflow_id: Optional[str] = None, + workflow_data: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + """Patch an existing workflow's meta.json without touching JSON or Markdown. + + Also usable by the native installer after replacing a workflow package. + Unknown metadata is retained, including when the new package has no meta.json. + The directory must already contain a workflow definition or Markdown draft. + Installers staging a package can provide its final workflow_id for defaults. + """ + if not any((wf_dir / name).is_file() for name in ("workflow.json", "workflow.md", "workflow.edit.md")): + raise FileNotFoundError(f"Workflow not found: {wf_dir}") + meta_file = wf_dir / "meta.json" + if meta_file.is_file(): + # Patch the stored mapping, not the derived response: defaults and + # normalized/localized display names must not replace native values. + meta = json.loads(meta_file.read_text(encoding="utf-8")) + else: + data = workflow_data or read_workflow_dir(wf_dir, workflow_id or wf_dir.name, "") + if data is None: + raise FileNotFoundError(f"Workflow not found: {wf_dir}") + meta = { + key: value + for key, value in data.items() + if key not in {"id", "source", "group_readonly", "workflowJson", "markdownContent", "editMarkdownContent", "stats"} + } + updates = dict(updates) + updates.pop("group_readonly", None) + if "group" in updates: + updates["group"] = normalize_workflow_group(updates["group"]) + if is_system_workflow_definition(wf_dir) and updates["group"] != normalize_workflow_group(meta.get("group")): + raise ValueError("System workflow group is read-only") + meta.update(updates) + (wf_dir / "meta.json").write_text( + json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8" + ) + return meta + + +def resolve_workflow_from_fs(workflow_id: str) -> Optional[tuple[Path, Dict[str, Any]]]: + """Resolve the selected directory and its data in one discovery pass.""" + for root, source in reversed(workflow_scan_dirs()): + wf_dir = root / workflow_id + data = read_workflow_dir(wf_dir, workflow_id, source) + if data is not None: + return wf_dir, data + return None + + def read_workflow_from_fs(workflow_id: str) -> Optional[Dict[str, Any]]: """Resolve a workflow by ID from workflow directories on disk.""" - result = None - for root, source in workflow_scan_dirs(): - data = read_workflow_dir(root / workflow_id, workflow_id, source) - if data is not None: - result = data - return result + selected = resolve_workflow_from_fs(workflow_id) + return selected[1] if selected is not None else None def resolve_workflow_id_from_source(workflow: Any) -> Optional[str]: diff --git a/tests/agent/test_agent.py b/tests/agent/test_agent.py index 45141edc8..ab879ac65 100644 --- a/tests/agent/test_agent.py +++ b/tests/agent/test_agent.py @@ -18,7 +18,7 @@ BUILTIN_AGENTS = [ "rex", "hephaestus", "explore", "oracle", "librarian", "prometheus", "multimodal-looker", - "rex-junior", "host-forensics", "host-forensics-fast", + "rex-junior", "self-improve", "host-forensics", "host-forensics-fast", ] @@ -39,8 +39,8 @@ async def test_agent_count(self): @pytest.mark.asyncio async def test_no_legacy_agents(self): - """general / compaction / title / summary are no longer registered agents.""" - for name in ["general", "compaction", "title", "summary"]: + """Retired agents stay absent; planning is provided by Prometheus.""" + for name in ["general", "plan", "compaction", "title", "summary"]: agent = await Agent.get(name) assert agent is None, f"Legacy agent '{name}' should not exist" @@ -56,14 +56,19 @@ async def test_rex_agent(self): assert agent.hidden is False assert agent.delegatable is False + +class TestInternalAgents: + @pytest.mark.asyncio - async def test_plan_agent(self): - agent = await Agent.get("plan") + async def test_self_improve_agent(self): + """The Dream worker is an internal agent, not a delegation target.""" + agent = await Agent.get("self-improve") assert agent is not None assert agent.mode == "subagent" assert agent.native is True assert agent.hidden is True assert agent.delegatable is False + assert "evolution" in agent.tags class TestSubagents: @@ -83,7 +88,8 @@ async def test_hephaestus_agent(self): agent = await Agent.get("hephaestus") assert agent is not None assert agent.mode == "subagent" - assert agent.delegatable is True + # Visible subagent mode does not imply eligibility for delegation. + assert agent.delegatable is False assert agent.hidden is False @pytest.mark.asyncio @@ -91,7 +97,8 @@ async def test_rex_junior_agent(self): agent = await Agent.get("rex-junior") assert agent is not None assert agent.mode == "subagent" - assert agent.delegatable is False + assert agent.delegatable is True + assert agent.hidden is False @pytest.mark.asyncio async def test_prometheus_agent(self): @@ -131,14 +138,17 @@ async def test_list_visible(self): assert "rex" in names assert "explore" in names assert "hephaestus" in names - # plan is hidden - assert "plan" not in names + # The internal Dream worker stays out of user-facing agent lists. + assert "self-improve" not in names + assert all(not agent.hidden for agent in visible) @pytest.mark.asyncio async def test_list_hidden(self): hidden = await Agent.list_hidden() names = [a.name for a in hidden] - assert "plan" in names + assert "self-improve" in names + assert "plan" not in names + assert all(agent.hidden for agent in hidden) @pytest.mark.asyncio async def test_list_subagents(self): @@ -149,8 +159,9 @@ async def test_list_subagents(self): assert "oracle" in names # rex is primary, not subagent assert "rex" not in names - # hidden agents excluded - assert "plan" not in names + # Hidden subagents are excluded even though their mode matches. + assert "self-improve" not in names + assert all(agent.mode == "subagent" and not agent.hidden for agent in subagents) @pytest.mark.asyncio async def test_list_primary(self): @@ -161,7 +172,8 @@ async def test_list_primary(self): @pytest.mark.asyncio async def test_is_hidden(self): - assert await Agent.is_hidden("plan") is True + assert await Agent.is_hidden("self-improve") is True + assert await Agent.is_hidden("plan") is False # Retired, not a hidden agent. assert await Agent.is_hidden("explore") is False assert await Agent.is_hidden("rex") is False assert await Agent.is_hidden("nonexistent") is False @@ -173,11 +185,13 @@ async def delegatable(name: str) -> bool: return bool(agent.delegatable) if agent else False assert await delegatable("rex") is False + assert await delegatable("self-improve") is False assert await delegatable("plan") is False - assert await delegatable("rex-junior") is False + assert await delegatable("rex-junior") is True assert await delegatable("explore") is True - assert await delegatable("hephaestus") is True + assert await delegatable("hephaestus") is False assert await delegatable("oracle") is True + assert await delegatable("prometheus") is True @pytest.mark.asyncio async def test_is_delegatable_respects_sidecar_override(self, tmp_path, monkeypatch): @@ -187,14 +201,17 @@ async def test_is_delegatable_respects_sidecar_override(self, tmp_path, monkeypa import flocks.agent.delegatable_settings as delegatable_settings from flocks.agent.registry import Agent as AgentRegistry, is_delegatable - AgentRegistry._delegatable_settings_mtime = 0.0 + monkeypatch.setattr(AgentRegistry, "_delegatable_settings_mtime", 0.0) delegatable_settings.set_override("explore", False) AgentRegistry.invalidate_cache() - - agent = await AgentRegistry.get("explore") - assert agent is not None - assert agent.delegatable is False - assert is_delegatable("explore") is False + try: + agent = await AgentRegistry.get("explore") + assert agent is not None + assert agent.delegatable is False + assert is_delegatable("explore") is False + finally: + # Do not leak the overridden cached definition into later tests. + AgentRegistry.invalidate_cache() @pytest.mark.asyncio async def test_list_names(self): @@ -211,16 +228,15 @@ class TestAgentPermissions: @pytest.mark.asyncio async def test_explore_tools(self): - """explore agent only allows declared read/search tools.""" - assert await Agent.has_tool("explore", "grep") is True - assert await Agent.has_tool("explore", "glob") is True - assert await Agent.has_tool("explore", "list") is True - assert await Agent.has_tool("explore", "read") is True - assert await Agent.has_tool("explore", "websearch") is True - # write tools denied - assert await Agent.has_tool("explore", "write") is False - assert await Agent.has_tool("explore", "edit") is False - assert await Agent.has_tool("explore", "bash") is True + """Explore declares discovery tools, not the retired list or editing tools.""" + agent = await Agent.get("explore") + assert agent is not None + declared = {"grep", "glob", "bash", "webfetch", "websearch", "read"} + assert set(agent.tools) == declared + for tool_name in declared: + assert await Agent.has_tool("explore", tool_name) is True + for tool_name in ("list", "write", "edit", "apply_patch"): + assert await Agent.has_tool("explore", tool_name) is False @pytest.mark.asyncio async def test_nonexistent_agent_has_no_tool(self): @@ -286,10 +302,13 @@ async def test_unregister_custom_agent(self): assert agents.get("temp_agent") is None @pytest.mark.asyncio - async def test_cannot_unregister_native_agent(self): - result = Agent.unregister("plan") - assert result is False - assert await Agent.get("plan") is not None + @pytest.mark.parametrize("name", ["rex", "explore", "self-improve"]) + async def test_cannot_unregister_native_agent(self, name): + agent = await Agent.get(name) + assert agent is not None and agent.native is True + assert Agent.unregister(name) is False + reloaded = await Agent.refresh() + assert reloaded[name].native is True @pytest.mark.asyncio async def test_unregister_nonexistent_agent(self): @@ -310,8 +329,9 @@ def test_create_agent_model(self): @pytest.mark.asyncio async def test_builtin_agents_no_custom_model(self): - assert await Agent.get_model_config("plan") is None - assert await Agent.get_model_config("explore") is None + for name in ("rex", "explore", "self-improve"): + assert await Agent.get(name) is not None + assert await Agent.get_model_config(name) is None assert await Agent.get_model_config("nonexistent") is None @pytest.mark.asyncio diff --git a/tests/agent/test_agent_factory.py b/tests/agent/test_agent_factory.py index 4d6a688d4..f186ad7df 100644 --- a/tests/agent/test_agent_factory.py +++ b/tests/agent/test_agent_factory.py @@ -52,6 +52,77 @@ def _write_agent_dir(tmp_path: Path, yaml_text: str, prompt_text: str | None = N # _parse_prompt_metadata # =========================================================================== +class TestSystemAgentGroups: + def test_all_discoverable_shipped_agents_have_native_nonempty_group(self, monkeypatch): + monkeypatch.setattr(_factory_module, "resolve_agent_initial_tools", lambda *args, **kwargs: ([], [])) + loaded = [] + for root in _factory_module._SYSTEM_AGENT_ROOTS: + for folder in _factory_module._iter_agent_dirs(root): + agent = load_agent(folder) + if agent is None: + continue + loaded.append(agent) + assert agent.group, folder + assert agent.group_readonly is True, folder + assert len(loaded) >= 18 + + def test_native_flag_and_yaml_cannot_grant_or_remove_group_lock(self, tmp_path, monkeypatch): + folder = _write_agent_dir(tmp_path, "name: custom\ngroup: Custom\ngroup_readonly: true\n") + assert load_agent(folder, native=True).group_readonly is False + assert yaml_to_agent_info({"name": "custom", "group_readonly": True}, folder / "agent.yaml").group_readonly is False + monkeypatch.setattr(_factory_module, "_SYSTEM_AGENT_ROOTS", (tmp_path,)) + (folder / "agent.yaml").write_text("name: shipped\ngroup: Fixed\ngroup_readonly: false\n") + assert load_agent(folder, native=False).group_readonly is True + assert yaml_to_agent_info({"name": "shipped", "group_readonly": False}, folder / "agent.yaml").group_readonly is True + + def test_source_anchor_does_not_follow_cwd_or_discovery_directory(self, tmp_path, monkeypatch): + folder = _write_agent_dir(tmp_path, "name: custom\ngroup: Custom\n") + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(_factory_module, "_BUILTIN_AGENTS_DIR", tmp_path) + assert load_agent(folder, native=True).group_readonly is False + package_root = Path(_factory_module.__file__).resolve().parent / "agents" + actual = next(package_root.glob("*/agent.yaml")) + assert _factory_module.is_system_agent_definition(actual) is True + + def test_selected_user_definition_remains_writable_over_shipped_bundle(self, tmp_path, monkeypatch): + project = tmp_path / "installation" + shipped = project / ".flocks" / "plugins" / "agents" + user = tmp_path / "user-agents" + for root in (shipped, user): + folder = root / "shared" + folder.mkdir(parents=True) + (folder / "agent.yaml").write_text("name: shared\ngroup: Original\n") + monkeypatch.setattr(_factory_module, "_BUILTIN_AGENTS_DIR", tmp_path / "absent") + monkeypatch.setattr(_factory_module, "_PLUGIN_AGENTS_DIR", user) + monkeypatch.setattr(_factory_module, "_SYSTEM_AGENT_ROOTS", (shipped,)) + monkeypatch.chdir(project) + assert scan_and_load()["shared"].group_readonly is False + (user / "shared" / "agent.yaml").unlink() + assert scan_and_load()["shared"].group_readonly is True + + @pytest.mark.parametrize("group", ["Changed", "", None]) + def test_yaml_writer_rejects_locked_group_before_prompt_side_effect(self, tmp_path, monkeypatch, group): + folder = _write_agent_dir(tmp_path, "name: test_agent\ngroup: Fixed\n", "Original prompt") + monkeypatch.setattr(_factory_module, "_PLUGIN_AGENTS_DIR", tmp_path) + monkeypatch.setattr(_factory_module, "_SYSTEM_AGENT_ROOTS", (tmp_path,)) + before = {path: path.read_bytes() for path in folder.iterdir()} + assert update_yaml_agent("test_agent", {"group": group, "prompt": "Must not write"}) is False + assert {path: path.read_bytes() for path in folder.iterdir()} == before + assert update_yaml_agent("test_agent", {"group": "Fixed", "description": "Ordinary edit"}) is True + assert read_yaml_agent("test_agent")["group"] == "Fixed" + assert read_yaml_agent("test_agent")["description"] == "Ordinary edit" + + def test_symlinked_external_source_is_not_shipped(self, tmp_path, monkeypatch): + shipped = tmp_path / "shipped" + folder = shipped / "alias" + folder.mkdir(parents=True) + source = tmp_path / "user.yaml" + source.write_text("name: custom\ngroup: Custom\n") + (folder / "agent.yaml").symlink_to(source) + monkeypatch.setattr(_factory_module, "_SYSTEM_AGENT_ROOTS", (shipped,)) + assert load_agent(folder, native=True).group_readonly is False + + class TestParsePromptMetadata: def test_returns_none_when_missing(self): @@ -497,13 +568,28 @@ async def test_plugin_agent_has_prompt_if_installed(self): assert agent.prompt is not None, f"{name} should have a prompt.md" assert len(agent.prompt) > 20 + @pytest.mark.parametrize("native", [False, True]) + def test_agent_without_prompt_sources_stays_promptless(self, tmp_path, native): + """Promptless definitions are valid without relying on the retired plan agent.""" + agent_dir = _write_agent_dir(tmp_path, "name: promptless\ntools: []\n") + agent = load_agent(agent_dir, native=native) + assert agent is not None + assert agent.prompt is None + assert agent.prompt_builder is None + inject_dynamic_prompts({agent.name: agent}, [], [], []) + assert agent.prompt is None + @pytest.mark.asyncio - async def test_plan_agent_has_no_prompt(self): - """plan agent has neither prompt.md nor prompt_builder.py.""" + async def test_self_improve_gets_dream_prompt(self): + """The hidden Dream worker must receive its dedicated dynamic prompt.""" from flocks.agent.registry import Agent - agent = await Agent.get("plan") + from flocks.memory.evolution.dream import DREAM_SYSTEM_PROMPT + + agent = await Agent.get("self-improve") assert agent is not None - assert agent.prompt is None, "Agent 'plan' should NOT have a prompt" + assert agent.prompt == DREAM_SYSTEM_PROMPT + assert agent.prompt + assert agent.prompt_builder == "flocks.agent.agents.self_improve.prompt_builder:inject" # =========================================================================== diff --git a/tests/agent/test_agent_metadata.py b/tests/agent/test_agent_metadata.py index 2d607d0d1..564c7d3e7 100644 --- a/tests/agent/test_agent_metadata.py +++ b/tests/agent/test_agent_metadata.py @@ -3,7 +3,7 @@ 验证: 1. is_delegatable / get_agent_mode / is_hidden 等查询函数正常 -2. delegatable 逻辑正确(primary/hidden agents 不可委派) +2. 显式 delegatable 与 hidden 共同决定委派资格,subagent 模式不自动允许委派 3. 不存在循环依赖 注意:模块级同步函数(is_delegatable 等)依赖 _agents_ref, @@ -33,23 +33,25 @@ class TestAgentMetadata: """测试 Agent 元数据查询函数(_agents_ref 已加载后)""" def test_is_delegatable_primary_agents(self): - """Primary agents 不应该被委派""" + """The built-in Rex orchestrator is not a delegation target.""" assert is_delegatable("rex") is False def test_is_delegatable_subagents(self): - """常规 subagents 应该返回 True""" + """These bundled subagents explicitly opt into delegation.""" assert is_delegatable("explore") is True - assert is_delegatable("hephaestus") is True + assert is_delegatable("rex-junior") is True + assert is_delegatable("prometheus") is True assert is_delegatable("oracle") is True assert is_delegatable("librarian") is True def test_is_delegatable_special_agents(self): - """特殊 agents(plan, rex-junior)不应该被委派""" - assert is_delegatable("plan") is False - assert is_delegatable("rex-junior") is False + """Visible Hephaestus and hidden Dream both explicitly disable delegation.""" + assert is_delegatable("hephaestus") is False + assert is_delegatable("self-improve") is False def test_is_delegatable_unknown_agent(self): - """Unknown agents must be rejected instead of falling back at runtime.""" + """Unknown/retired agents must be rejected instead of falling back at runtime.""" + assert is_delegatable("plan") is False assert is_delegatable("unknown-agent") is False assert is_delegatable("custom-agent-123") is False @@ -71,17 +73,21 @@ def test_is_delegatable_hidden_agent(self, monkeypatch): def test_is_delegatable_resolves_legacy_aliases(self): """Known aliases should use the target agent's delegation policy.""" assert is_delegatable("sisyphus") is False + assert is_delegatable("sisyphus-junior") is True def test_get_agent_mode(self): """测试获取 agent 模式""" assert get_agent_mode("rex") == "primary" assert get_agent_mode("explore") == "subagent" assert get_agent_mode("hephaestus") == "subagent" + assert get_agent_mode("self-improve") == "subagent" + assert get_agent_mode("plan") is None assert get_agent_mode("nonexistent") is None def test_is_hidden(self): """测试 hidden 属性""" - assert is_hidden("plan") is True + assert is_hidden("self-improve") is True + assert is_hidden("plan") is False assert is_hidden("explore") is False assert is_hidden("rex") is False assert is_hidden("nonexistent") is False @@ -91,11 +97,14 @@ def test_list_delegatable_agents(self): delegatable = list_delegatable_agents() assert isinstance(delegatable, list) assert "explore" in delegatable - assert "hephaestus" in delegatable + assert "rex-junior" in delegatable + assert "prometheus" in delegatable assert "oracle" in delegatable assert "rex" not in delegatable + assert "hephaestus" not in delegatable + assert "self-improve" not in delegatable assert "plan" not in delegatable - assert "rex-junior" not in delegatable + assert all(is_delegatable(name) for name in delegatable) def test_list_primary_agents(self): """测试列出 primary agents""" diff --git a/tests/config/test_config.py b/tests/config/test_config.py index 85ca64e89..aff79e59f 100644 --- a/tests/config/test_config.py +++ b/tests/config/test_config.py @@ -140,6 +140,72 @@ def test_local_mcp_config_accepts_legacy_env_alias(): } +@pytest.mark.parametrize("section", ["agent", "mode", "mcp", "api_services", "tool_settings"]) +@pytest.mark.parametrize("clear", [None, ""]) +def test_native_group_clear_survives_config_merge(section, clear): + entry = {"group": "Package", "future_metadata": {"kept": True}} + if section == "mcp": + entry.update(type="remote", url="https://example.invalid/mcp") + original = ConfigInfo.model_validate({section: {"example": entry}}) + omitted = ConfigInfo.model_validate({section: {"example": {"enabled": False}}}) + merged = Config.merge_config_concat_arrays(original, omitted) + assert merged.model_dump(exclude_none=True)[section]["example"]["group"] == "Package" + + patch_entry = {"group": clear} + # Full MCP config exercises its typed model, not just the partial-dict fallback. + if section == "mcp": + patch_entry.update(type="remote", url="https://example.invalid/mcp") + update = ConfigInfo.model_validate({section: {"example": patch_entry}}) + assert patch_entry["group"] == clear # Validation must not mutate the input. + cleared = Config.merge_config_concat_arrays(merged, update) + reloaded = ConfigInfo.model_validate(cleared.model_dump(exclude_none=True)) + saved = reloaded.model_dump(exclude_none=True)[section]["example"] + assert saved["group"] == "" + assert saved["future_metadata"] == {"kept": True} + assert Config.merge_config_concat_arrays(reloaded, omitted).model_dump(exclude_none=True)[section]["example"]["group"] == "" + + +def test_explicit_group_clear_in_prevalidated_plugin_configs(): + from flocks.config.config import AgentConfig, McpLocalConfig, McpRemoteConfig + + agent = AgentConfig(group=None) + local = McpLocalConfig(type="local", command=["test"], group=None) + remote = McpRemoteConfig(type="remote", url="https://example.invalid", group=None) + config = ConfigInfo(agent={"clear": agent, "omitted": AgentConfig()}, mcp={"local": local, "remote": remote}) + dumped = config.model_dump(exclude_none=True) + assert dumped["agent"]["clear"]["group"] == "" + assert "group" not in dumped["agent"]["omitted"] + assert dumped["mcp"]["local"]["group"] == dumped["mcp"]["remote"]["group"] == "" + assert agent.group == local.group == remote.group == "" + assert agent.model_dump(exclude_none=True)["group"] == "" + assert "group" not in AgentConfig().model_dump(exclude_none=True) + + +@pytest.mark.asyncio +async def test_config_update_persists_null_group_clear_without_resurrection(): + original = ConfigInfo.model_validate({ + "agent": {"example": {"group": "Package", "model": "test/model"}}, + "mcp": {"example": {"type": "remote", "url": "https://example.invalid", "group": "Package"}}, + "tool_settings": {"example": {"group": "Package", "enabled": False}}, + "api_services": {"example_v1": {"group": "Package", "enabled": False}}, + }) + await Config.update(original) + await Config.update(ConfigInfo.model_validate({ + "agent": {"example": {"group": None}}, + "mcp": {"example": {"group": None}}, + "tool_settings": {"example": {"group": None}}, + "api_services": {"example_v1": {"group": None}}, + })) + await Config.update(ConfigInfo(theme="dark")) + raw = json.loads(Config.get_config_file().read_text()) + loaded = (await Config.get()).model_dump(exclude_none=True) + for section, name in [("agent", "example"), ("mcp", "example"), ("tool_settings", "example"), ("api_services", "example_v1")]: + assert raw[section][name]["group"] == loaded[section][name]["group"] == "" + assert raw["agent"]["example"]["model"] == "test/model" + assert raw["mcp"]["example"]["url"] == "https://example.invalid" + assert raw["tool_settings"]["example"]["enabled"] is False + + def test_legacy_todo_permission_names_migrate_to_todo(): permission = PermissionConfig.model_validate({ "todowrite": "deny", diff --git a/tests/config/test_config_writer.py b/tests/config/test_config_writer.py index b735f0a06..cdd763e50 100644 --- a/tests/config/test_config_writer.py +++ b/tests/config/test_config_writer.py @@ -348,6 +348,116 @@ def test_model_settings_preserve_other_sections(self, temp_project): assert "mcp" in data +class TestConfigWriterAgentGroups: + def test_group_only_write_preserves_raw_references_and_other_fields(self, temp_project): + from flocks.config.config_writer import ConfigWriter + + raw = { + "plugin": ["example.external.plugin"], + "provider": {"example": {"options": {"apiKey": "{secret:example_key}"}}}, + "mcp": {"example": {"url": "{env:MCP_URL}", "enabled": False}}, + "agent": { + "editable": {"prompt": "{file:prompts/custom.md}", "temperature": 0.7, "group": "Before"}, + "other": {"model": "{env:MODEL_ALIAS}", "group": "Other"}, + }, + "future_key": {"nested": [1, {"keep": True}]}, + } + path = temp_project / "flocks.json" + path.write_text(json.dumps(raw), encoding="utf-8") + + ConfigWriter.set_agent_group("editable", "Operations") + + raw["agent"]["editable"]["group"] = "Operations" + assert json.loads(path.read_text(encoding="utf-8")) == raw + + @pytest.mark.parametrize("group", [None, ""]) + def test_explicit_clear_writes_only_empty_group(self, temp_project, group): + from flocks.config.config_writer import ConfigWriter + + ConfigWriter.set_agent_group("custom-runtime", group) + + assert ConfigWriter._read_raw()["agent"] == {"custom-runtime": {"group": ""}} + + def test_uses_existing_jsonc_without_touching_json(self, temp_project): + from flocks.config.config_writer import ConfigWriter + + original_json = (temp_project / "flocks.json").read_bytes() + path = temp_project / "flocks.jsonc" + path.write_text('{/* references stay raw */ "plugin":["custom.plugin"],' + '"agent":{"editable":{"prompt":"{file:prompt.md}"}}}', encoding="utf-8") + + ConfigWriter.set_agent_group("editable", "Ops") + + assert json.loads(path.read_text(encoding="utf-8")) == { + "plugin": ["custom.plugin"], + "agent": {"editable": {"prompt": "{file:prompt.md}", "group": "Ops"}}, + } + assert (temp_project / "flocks.json").read_bytes() == original_json + + @pytest.mark.parametrize("content", ['{"agent":', '[]', '{"agent":[]}', '{"agent":{"editable":false}}']) + def test_malformed_config_is_not_overwritten(self, temp_project, content): + from flocks.config.config_writer import ConfigWriter + + path = temp_project / "flocks.json" + path.write_text(content, encoding="utf-8") + with pytest.raises(ValueError): + ConfigWriter.set_agent_group("editable", "Ops") + assert path.read_text(encoding="utf-8") == content + + +class TestConfigWriterMcpGroups: + @pytest.mark.parametrize("group", ["Operations", "", None]) + def test_replacement_preserves_omitted_group_without_mutating_input(self, temp_project, group): + from flocks.config.config_writer import ConfigWriter + + raw = ConfigWriter._read_raw() + raw["mcp"]["custom-server"] = { + "type": "local", "command": ["old-command"], "group": group, + } + ConfigWriter._write_raw(raw) + incoming = {"type": "remote", "url": "{env:MCP_URL}", "enabled": False} + + ConfigWriter.add_mcp_server("custom-server", incoming) + + assert ConfigWriter.get_mcp_server("custom-server") == {**incoming, "group": group or ""} + assert "group" not in incoming + assert ConfigWriter._read_raw()["provider"] == raw["provider"] + + @pytest.mark.parametrize("group", [None, ""]) + def test_replacement_honors_explicit_clear(self, temp_project, group): + from flocks.config.config_writer import ConfigWriter + + ConfigWriter.add_mcp_server("custom-server", {"type": "remote", "group": "Old"}) + ConfigWriter.add_mcp_server("custom-server", {"type": "remote", "group": group}) + + assert ConfigWriter.get_mcp_server("custom-server")["group"] == "" + + def test_first_save_does_not_invent_group(self, temp_project): + from flocks.config.config_writer import ConfigWriter + + incoming = {"type": "remote", "url": "http://127.0.0.1:9/unused", "enabled": False} + ConfigWriter.add_mcp_server("custom-server", incoming) + assert ConfigWriter.get_mcp_server("custom-server") == incoming + + def test_single_field_clear_preserves_connection(self, temp_project): + from flocks.config.config_writer import ConfigWriter + + ConfigWriter.add_mcp_server("custom-server", {"type": "remote", "group": "Old", "url": "{env:MCP_URL}"}) + assert ConfigWriter.update_mcp_server_field("custom-server", "group", None) + assert ConfigWriter.get_mcp_server("custom-server") == { + "type": "remote", "group": "", "url": "{env:MCP_URL}", + } + + def test_corrupt_config_aborts_without_replacement(self, temp_project): + from flocks.config.config_writer import ConfigWriter + + path = temp_project / "flocks.json" + path.write_text('{"mcp":', encoding="utf-8") + with pytest.raises(ValueError): + ConfigWriter.add_mcp_server("custom-server", {"type": "remote"}) + assert path.read_text(encoding="utf-8") == '{"mcp":' + + class TestConfigWriterToolSettings: """Test tool_settings section CRUD (user-level overlay for plugin tools).""" @@ -396,6 +506,32 @@ def test_delete_missing_returns_false(self, temp_project): from flocks.config.config_writer import ConfigWriter assert ConfigWriter.delete_tool_setting("not_set") is False + def test_group_change_on_core_tool_is_rejected_before_other_fields(self, temp_project): + from flocks.config.config_writer import ConfigWriter + + path = temp_project / "flocks.json" + original = path.read_bytes() + with pytest.raises(ValueError, match="read.only"): + ConfigWriter.set_tool_setting("get_time", {"group": "User changed", "enabled": False}) + assert path.read_bytes() == original + + def test_custom_group_clear_preserves_enable_override(self, temp_project): + from flocks.config.config_writer import ConfigWriter + + ConfigWriter.set_tool_setting("user-only-tool", {"enabled": False, "group": "Ops"}) + ConfigWriter.set_tool_setting("user-only-tool", {"group": None}) + assert ConfigWriter.get_tool_setting("user-only-tool") == {"enabled": False, "group": ""} + + @pytest.mark.parametrize("content", ['{"tool_settings":', '[]', '{"tool_settings":[]}', '{"tool_settings":{"user-only-tool":false}}']) + def test_group_update_does_not_overwrite_corrupt_config(self, temp_project, content): + from flocks.config.config_writer import ConfigWriter + + path = temp_project / "flocks.json" + path.write_text(content, encoding="utf-8") + with pytest.raises(ValueError): + ConfigWriter.set_tool_setting("user-only-tool", {"group": "Ops"}) + assert path.read_text(encoding="utf-8") == content + def test_set_empty_name_raises(self, temp_project): from flocks.config.config_writer import ConfigWriter with pytest.raises(ValueError): diff --git a/tests/hub/test_hub_catalog.py b/tests/hub/test_hub_catalog.py index 5bea2aa6d..eae717e35 100644 --- a/tests/hub/test_hub_catalog.py +++ b/tests/hub/test_hub_catalog.py @@ -375,12 +375,14 @@ def test_bundled_hub_manifest_and_files_load(): async def test_hub_installs_and_uninstalls_skill(isolated_hub_env): - record = await install_plugin("skill", "ndr-alert-analysis") - skill_dir = isolated_hub_env["home"] / ".flocks" / "plugins" / "skills" / "ndr-alert-analysis" + # Hub-only packages remain installable; a same-named readonly native Skill + # (such as ndr-alert-analysis) must not be shadowed by this install path. + record = await install_plugin("skill", "triaging-security-incident") + skill_dir = isolated_hub_env["home"] / ".flocks" / "plugins" / "skills" / "triaging-security-incident" assert (skill_dir / "SKILL.md").is_file() assert record.enabled is True - removed = await uninstall_plugin("skill", "ndr-alert-analysis") + removed = await uninstall_plugin("skill", "triaging-security-incident") assert removed is True assert not skill_dir.exists() @@ -865,17 +867,17 @@ async def noop_refresh(_plugin_type, _changed_path=None): async def test_catalog_clears_stale_skill_record_after_external_delete(isolated_hub_env): - await install_plugin("skill", "ndr-alert-analysis") - skill_dir = isolated_hub_env["home"] / ".flocks" / "plugins" / "skills" / "ndr-alert-analysis" + await install_plugin("skill", "triaging-security-incident") + skill_dir = isolated_hub_env["home"] / ".flocks" / "plugins" / "skills" / "triaging-security-incident" assert (skill_dir / "SKILL.md").is_file() import shutil shutil.rmtree(skill_dir) entries = list_catalog(plugin_type="skill") - entry = next(item for item in entries if item.id == "ndr-alert-analysis") + entry = next(item for item in entries if item.id == "triaging-security-incident") assert entry.state == "available" - assert local.get_record("skill", "ndr-alert-analysis") is None + assert local.get_record("skill", "triaging-security-incident") is None def test_hub_routes_cover_catalog_files_install_and_uninstall(isolated_hub_env): @@ -928,17 +930,20 @@ def test_hub_routes_cover_catalog_files_install_and_uninstall(isolated_hub_env): ) assert traversal.status_code == 400 - installed = client.post("/api/hub/plugins/skill/ndr-alert-analysis/install", json={"scope": "global"}) + protected = client.post("/api/hub/plugins/skill/ndr-alert-analysis/install", json={"scope": "global"}) + assert protected.status_code == 422 and "read-only" in protected.text + + installed = client.post("/api/hub/plugins/skill/triaging-security-incident/install", json={"scope": "global"}) assert installed.status_code == 200 - assert installed.json()["id"] == "ndr-alert-analysis" + assert installed.json()["id"] == "triaging-security-incident" installed_catalog = client.get("/api/hub/catalog", params={"state": "installed"}).json() - assert any(item["id"] == "ndr-alert-analysis" for item in installed_catalog) + assert any(item["id"] == "triaging-security-incident" for item in installed_catalog) - removed = client.delete("/api/hub/plugins/skill/ndr-alert-analysis") + removed = client.delete("/api/hub/plugins/skill/triaging-security-incident") assert removed.status_code == 200 available_catalog = client.get("/api/hub/catalog", params={"state": "available"}).json() - assert any(item["id"] == "ndr-alert-analysis" for item in available_catalog) + assert any(item["id"] == "triaging-security-incident" for item in available_catalog) def test_hub_paginated_facets_exclude_their_own_filter(): diff --git a/tests/hub/test_native_group_metadata.py b/tests/hub/test_native_group_metadata.py new file mode 100644 index 000000000..a934ea610 --- /dev/null +++ b/tests/hub/test_native_group_metadata.py @@ -0,0 +1,261 @@ +"""Managed package replacement retains each editable definition's own group.""" + +import json +from pathlib import Path + +import pytest +import yaml + +from flocks.hub import installer +from flocks.skill.skill import Skill +from flocks.workflow.fs_store import read_workflow_dir + + +def write(path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + +@pytest.mark.parametrize("group", ["本地分类", "", None]) +def test_user_skill_upgrade_preserves_group_and_new_body(tmp_path, group): + old, new = tmp_path / "installed", tmp_path / "package" + write(old / "SKILL.md", "---\n" + yaml.safe_dump({"name": "demo", "group": group}, allow_unicode=True) + "---\nOld body\n") + write(new / "SKILL.md", "---\nname: demo\ngroup: Package default\ndescription: New description\ncustom: retained\n---\n\nNew body\n") + installer._copy_package(new, old, plugin_type="skill", scope="global") + text = (old / "SKILL.md").read_text() + metadata = Skill._parse_frontmatter(text) + assert metadata["group"] == (group or "") + assert metadata["description"] == "New description" and metadata["custom"] == "retained" + assert text.endswith("\n\nNew body\n") + + +def test_readonly_project_skill_cannot_be_replaced_by_hub(tmp_path): + old, new = tmp_path / "installed", tmp_path / "package" + original = "---\nname: demo\ngroup: Old shipped group\n---\nOld body\n" + write(old / "SKILL.md", original) + write(new / "SKILL.md", "---\nname: demo\ngroup: New shipped group\n---\nNew body\n") + with pytest.raises(ValueError, match="read-only"): + installer._copy_package(new, old, plugin_type="skill", scope="project") + assert (old / "SKILL.md").read_text() == original + assert not list(tmp_path.glob(".installed.*")) + + +@pytest.mark.parametrize("group", ["Local", "", None]) +def test_agent_explicit_group_survives_without_retaining_old_other_fields(tmp_path, group): + old, new = tmp_path / "installed", tmp_path / "package" + write(old / "agent.yaml", yaml.safe_dump({"name": "demo", "group": group, "description": "Old"})) + write(new / "agent.yaml", "name: demo\ngroup: Default\ndescription: New\nprompt: New prompt\n") + installer._copy_package(new, old, plugin_type="agent") + data = yaml.safe_load((old / "agent.yaml").read_text()) + assert data == {"name": "demo", "group": group or "", "description": "New", "prompt": "New prompt"} + + +@pytest.mark.parametrize("group", ["Intel", "", None]) +def test_yaml_tool_metadata_preserved_in_its_own_file(tmp_path, group): + old, new = tmp_path / "installed", tmp_path / "package" + write(old / "queries" / "lookup.yaml", yaml.safe_dump({"name": "lookup", "group": group, "handler": {"type": "http"}})) + write(new / "queries" / "lookup.yaml", "name: lookup\ndescription: Updated lookup\nhandler: {type: http}\n") + write(new / "_provider.yaml", "name: Provider\ngroup: New provider default\n") + write(old / "_provider.yaml", "name: Provider\ngroup: Old provider default\n") + installer._copy_package(new, old, plugin_type="tool") + assert yaml.safe_load((old / "queries" / "lookup.yaml").read_text())["group"] == group + assert yaml.safe_load((old / "_provider.yaml").read_text())["group"] == "New provider default" + + +@pytest.mark.parametrize("group", ["Ops", "", None]) +def test_workflow_upgrade_preserves_meta_group_without_changing_graph(tmp_path, group): + old, new = tmp_path / "demo-workflow", tmp_path / "package" + write(old / "meta.json", json.dumps({"name": "Old", "group": group})) + graph = json.dumps({"name": "New flow", "start": "", "nodes": [], "edges": [], "metadata": {"group": "Package default"}}) + write(new / "workflow.json", graph) + installer._copy_package(new, old, plugin_type="workflow") + assert (old / "workflow.json").read_text() == graph + meta = json.loads((old / "meta.json").read_text()) + assert meta["group"] == (group or "") and meta["name"] == "New flow" + assert read_workflow_dir(old, "demo-workflow", "global")["group"] == (group or "") + + +def test_workflow_package_group_is_imported_into_native_metadata(tmp_path): + target, source = tmp_path / "demo-workflow", tmp_path / "package" + write(source / "workflow.json", json.dumps({"name": "Flow", "start": "", "nodes": [], "edges": [], "metadata": {"group": "Intel"}})) + installer._copy_package(source, target, plugin_type="workflow") + assert json.loads((target / "meta.json").read_text())["group"] == "Intel" + loaded = read_workflow_dir(target, "demo-workflow", "global") + assert loaded["group"] == "Intel" + assert "group" not in loaded["workflowJson"]["metadata"] + + +def test_markdown_draft_metadata_never_creates_execution_json_or_staging_name(tmp_path): + old, new = tmp_path / "demo-workflow", tmp_path / "package" + write(old / "meta.json", json.dumps({"group": "Drafts"})) + write(new / "workflow.md", "A titleless draft.\n") + installer._copy_package(new, old, plugin_type="workflow") + assert not (old / "workflow.json").exists() + assert (old / "workflow.md").read_text() == "A titleless draft.\n" + meta = json.loads((old / "meta.json").read_text()) + assert meta["name"] == "demo-workflow" and meta["group"] == "Drafts" + + +def test_invalid_saved_metadata_aborts_before_replacing_original(tmp_path): + old, new = tmp_path / "installed", tmp_path / "package" + original = "name: demo\ngroup: [invalid, value]\ndescription: Original\n" + write(old / "agent.yaml", original) + write(new / "agent.yaml", "name: demo\ndescription: New\n") + with pytest.raises(ValueError, match="group metadata"): + installer._copy_package(new, old, plugin_type="agent") + assert (old / "agent.yaml").read_text() == original + assert not list(tmp_path.glob(".installed.*")) + + +def test_first_install_keeps_packaged_default_without_an_extra_store(tmp_path): + old, new = tmp_path / "installed", tmp_path / "package" + incoming = "name: demo\ngroup: Default\ndescription: New\n" + write(new / "agent.yaml", incoming) + installer._copy_package(new, old, plugin_type="agent") + assert (old / "agent.yaml").read_text() == incoming + assert [path.name for path in old.iterdir()] == ["agent.yaml"] + + +def test_external_symlink_metadata_is_not_copied(tmp_path): + old, new, external = tmp_path / "installed", tmp_path / "package", tmp_path / "external.yaml" + write(external, "name: demo\ngroup: Outside\n") + old.mkdir() + (old / "agent.yaml").symlink_to(external) + write(new / "agent.yaml", "name: demo\ngroup: Package\n") + installer._copy_package(new, old, plugin_type="agent") + assert yaml.safe_load((old / "agent.yaml").read_text())["group"] == "Package" + assert external.read_text() == "name: demo\ngroup: Outside\n" + + +def tool_yaml(name, group): + return yaml.safe_dump({"name": name, "group": group, "handler": {"type": "http", "url": "https://offline.invalid"}}) + + +def test_tool_group_matches_declared_identity_not_filename(tmp_path): + old, new = tmp_path / "installed", tmp_path / "package" + write(old / "lookup.yaml", tool_yaml("same-tool", "Personal")) + write(new / "moved.yml", tool_yaml("same-tool", "Package")) + write(new / "lookup.yaml", tool_yaml("different-tool", "Different default")) + installer._copy_package(new, old, plugin_type="tool") + assert yaml.safe_load((old / "moved.yml").read_text())["group"] == "Personal" + assert yaml.safe_load((old / "lookup.yaml").read_text())["group"] == "Different default" + + +@pytest.mark.parametrize("relative", ["_fixtures/tool.yaml", "_fixtures/deep/tool.yaml", "queries/deeper/tool.yaml", "config.yaml"]) +def test_tool_replacement_ignores_undiscoverable_and_unrelated_yaml(tmp_path, relative): + old, new = tmp_path / "installed", tmp_path / "package" + before = tool_yaml("ignored", ["invalid group"]) + after = tool_yaml("ignored", "Package") + if relative == "config.yaml": + before = "name: config\ngroup: [unrelated, data]\nsettings: true\n" + after = "name: config\ngroup: untouched\nsettings: false\n" + write(old / relative, before) + write(new / relative, after) + installer._copy_package(new, old, plugin_type="tool") + assert (old / relative).read_text() == after + + +@pytest.mark.parametrize("subsystem", ["api", "python", "device", "mcp", "generated"]) +def test_tool_scan_uses_destination_depth_and_subsystem_exclusions(tmp_path, monkeypatch, subsystem): + root = tmp_path / "tools" + old, new = root / subsystem / "provider", tmp_path / "package" + monkeypatch.setattr(installer.local, "install_root", lambda *_args: root) + write(old / "lookup.yaml", tool_yaml("lookup", "Personal")) + write(new / "lookup.yaml", tool_yaml("lookup", "Package")) + write(old / "nested/too-deep.yaml", tool_yaml("deep", ["invalid"])) + write(new / "nested/too-deep.yaml", tool_yaml("deep", "Unchanged")) + installer._copy_package(new, old, plugin_type="tool") + expected = "Package" if subsystem in {"mcp", "generated"} else "Personal" + assert yaml.safe_load((old / "lookup.yaml").read_text())["group"] == expected + assert yaml.safe_load((old / "nested/too-deep.yaml").read_text())["group"] == "Unchanged" + + +def test_tool_metadata_preservation_does_not_import_python_handlers(tmp_path): + old, new = tmp_path / "installed", tmp_path / "package" + for package, group in ((old, "Personal"), (new, "Default")): + write(package / "tool.yaml", yaml.safe_dump({"name": "lookup", "group": group, "handler": {"type": "script", "path": "crash.py"}})) + write(package / "crash.py", "raise RuntimeError('must not import during metadata preservation')\n") + installer._copy_package(new, old, plugin_type="tool") + assert yaml.safe_load((old / "tool.yaml").read_text())["group"] == "Personal" + + +def test_external_symlink_tool_tree_is_not_read_for_group_preservation(tmp_path): + old, new, outside = tmp_path / "installed", tmp_path / "package", tmp_path / "outside" + old.mkdir() + write(outside / "lookup.yaml", tool_yaml("lookup", ["invalid"])) + (old / "queries").symlink_to(outside, target_is_directory=True) + write(new / "queries/lookup.yaml", tool_yaml("lookup", "Package")) + installer._copy_package(new, old, plugin_type="tool") + assert yaml.safe_load((old / "queries/lookup.yaml").read_text())["group"] == "Package" + assert yaml.safe_load((outside / "lookup.yaml").read_text())["group"] == ["invalid"] + + +def test_hub_skill_cannot_shadow_actual_bundled_definition(tmp_path, monkeypatch): + from flocks.project.instance import Instance + + source = tmp_path / "source" + monkeypatch.setattr(Skill, "_source_root", lambda: source) + monkeypatch.setattr(Instance, "get_directory", lambda: str(tmp_path)) + monkeypatch.setattr(Instance, "get_worktree", lambda: str(tmp_path)) + monkeypatch.setenv("HOME", str(tmp_path / "home")) + protected = source / ".flocks/plugins/skills/hub-demo/SKILL.md" + original = "---\nname: hub-demo\ndescription: Bundled\ngroup: Fixed\n---\nOriginal\n" + write(protected, original) + package, target = tmp_path / "package", tmp_path / "home/.flocks/plugins/skills/hub-demo" + write(package / "SKILL.md", "---\nname: hub-demo\ndescription: Replacement\ngroup: New\n---\nNew\n") + with pytest.raises(ValueError, match="read-only"): + installer._copy_package(package, target, plugin_type="skill") + assert protected.read_text() == original + assert not target.exists() + + +@pytest.mark.parametrize("plugin_type", ["agent", "tool", "workflow"]) +@pytest.mark.parametrize("group", ["Changed", "", None]) +def test_hub_cannot_change_actual_shipped_target_group(tmp_path, monkeypatch, plugin_type, group): + from flocks.agent import agent_factory + from flocks.tool import registry + from flocks.workflow import fs_store + + source = tmp_path / "source" + roots = {kind: source / ".flocks/plugins" / (kind + "s") for kind in ("agent", "tool", "workflow")} + monkeypatch.setattr(agent_factory, "_SYSTEM_AGENT_ROOTS", (roots["agent"],)) + monkeypatch.setattr(registry, "__file__", str(source / "flocks/tool/registry.py")) + monkeypatch.setattr(fs_store, "_SYSTEM_WORKFLOW_ROOT", roots["workflow"]) + monkeypatch.setattr(installer.local, "install_root", lambda *_args: roots["tool"]) + old, new = roots[plugin_type] / "demo", tmp_path / "package" + if plugin_type == "agent": + write(old / "agent.yaml", "name: demo\ngroup: Fixed\n") + write(new / "agent.yaml", yaml.safe_dump({"name": "demo", "group": group})) + elif plugin_type == "tool": + write(old / "tool.yaml", tool_yaml("demo", "Fixed")) + write(new / "tool.yaml", tool_yaml("demo", group)) + else: + write(old / "workflow.md", "# Original\n") + write(old / "meta.json", json.dumps({"group": "Fixed"})) + write(new / "workflow.json", json.dumps({"name": "Changed", "metadata": {"group": group}})) + before = {path.relative_to(old): path.read_bytes() for path in old.rglob("*") if path.is_file()} + with pytest.raises(ValueError, match="read-only"): + installer._copy_package(new, old, plugin_type=plugin_type, scope="project") + assert {path.relative_to(old): path.read_bytes() for path in old.rglob("*") if path.is_file()} == before + assert not list(old.parent.glob(".demo.*")) + + +@pytest.mark.parametrize("plugin_type", ["agent", "tool", "workflow"]) +def test_arbitrary_user_project_copies_keep_group_preservation(tmp_path, plugin_type): + old, new = tmp_path / "project/.flocks/plugins" / (plugin_type + "s") / "demo", tmp_path / "package" + if plugin_type == "agent": + filename = "agent.yaml" + write(old / filename, "name: demo\ngroup: Personal\n") + write(new / filename, "name: demo\ngroup: Package\n") + elif plugin_type == "tool": + filename = "lookup.yaml" + write(old / filename, tool_yaml("lookup", "Personal")) + write(new / filename, tool_yaml("lookup", "Package")) + else: + filename = "meta.json" + write(old / filename, json.dumps({"group": "Personal"})) + write(old / "workflow.md", "# Old\n") + write(new / "workflow.md", "# New\n") + write(new / filename, json.dumps({"group": "Package"})) + installer._copy_package(new, old, plugin_type=plugin_type, scope="project") + assert yaml.safe_load((old / filename).read_text())["group"] == "Personal" diff --git a/tests/mcp/test_mcp_catalog.py b/tests/mcp/test_mcp_catalog.py index b8a857b84..41f4421ea 100644 --- a/tests/mcp/test_mcp_catalog.py +++ b/tests/mcp/test_mcp_catalog.py @@ -35,6 +35,30 @@ def _reset_catalog(tmp_path, monkeypatch): Config._cached_config = None +def test_shipped_catalog_group_ignores_old_mutable_copy_without_rewriting_it(): + path = _resolve_catalog_file() + raw = json.loads(path.read_text(encoding="utf-8")) + canonical = {entry["id"]: entry["group"] for entry in raw["servers"]} + for entry in raw["servers"]: + entry["group"] = "Old user override" + entry["description"] = "Keep mutable details" + raw["servers"].append({ + "id": "user-only", "name": "User", "description": "custom", "category": "custom", + "github": "example/custom", "language": "python", "group": "User group", "group_readonly": True, + }) + path.write_text(json.dumps(raw), encoding="utf-8") + before = path.read_bytes() + catalog = McpCatalog.get() + for entry in catalog.entries: + if entry.id == "user-only": + assert entry.group == "User group" and entry.group_readonly is False + else: + assert entry.group == canonical[entry.id] + assert entry.group_readonly is True + assert entry.description == "Keep mutable details" + assert path.read_bytes() == before + + class TestCatalogDataIntegrity: """Validate the mcp_list.json / catalog_data.json file itself.""" diff --git a/tests/plugin/test_builtin_group_metadata.py b/tests/plugin/test_builtin_group_metadata.py new file mode 100644 index 000000000..c31fb968f --- /dev/null +++ b/tests/plugin/test_builtin_group_metadata.py @@ -0,0 +1,240 @@ +"""Static completeness checks for shipped definitions, never user/Hub installs. + +Read native YAML/JSON/frontmatter and Python AST only: importing tool modules can +register handlers, initialize application state, or require optional SDKs. +""" + +import ast +import json +import re +from pathlib import Path + +import pytest +import yaml + + +ROOT = Path(__file__).resolve().parents[2] +PLUGINS = ROOT / ".flocks" / "plugins" +REGISTRY = ROOT / "flocks" / "tool" / "registry.py" +ASSISTANT_GROUPS = {"系统辅助", "安全研判", "威胁情报", "平台集成"} +TOOL_GROUPS = { + "文件操作", "终端执行", "代码分析", "检索", "任务与工作流", "代理协作", "系统管理", "企业协作", +} +DEVICE_GROUPS = {"NDR", "EDR/HIDS", "SIEM", "XDR", "WAF", "网络防护", "身份访问", "威胁情报"} +MCP_GROUPS = {"威胁情报", "SIEM 与日志分析", "安全运营", "数据处理", "合规", "代码安全"} + + +@pytest.fixture(autouse=True) +def _cleanup_runtime_singletons_after_test(): + # Override the global runtime-cleanup fixture: this static suite creates no + # singletons, and must not import application/storage modules at teardown. + yield + + +def _assert_group(value, source, allowed): + assert isinstance(value, str), source + assert value == value.strip() and 0 < len(value) <= 32, source + assert not any(ord(char) < 32 or ord(char) == 127 for char in value), source + assert value in allowed, (source, value) + + +def _yaml(path): + data = yaml.safe_load(path.read_text(encoding="utf-8")) + assert isinstance(data, dict), path + return data + + +def _ast(path): + return ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + + +def _native_yaml(directory, max_depth, exclude=()): + """Match scan_directory's depth/underscore rules without importing plugins.""" + for path in sorted(directory.iterdir()): + if path.name.startswith("_"): + continue + if path.is_file() and path.suffix in {".yaml", ".yml"}: + yield path + elif max_depth and path.is_dir() and path.name not in exclude: + yield from _native_yaml(path, max_depth - 1) + + +def _tool_yaml_paths(): + # Take the scan depth and top-level exclusions from the actual extension + # declaration, so coverage follows the native loader rather than rglob'ing + # fixtures, generated tools, or the separately managed MCP templates. + declaration = next( + node for node in ast.walk(_ast(REGISTRY)) + if isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) and node.func.id == "ExtensionPoint" + and any( + kw.arg == "attr_name" and isinstance(kw.value, ast.Constant) and kw.value.value == "TOOLS" + for kw in node.keywords + ) + ) + options = {kw.arg: kw.value for kw in declaration.keywords} + assert ast.literal_eval(options["recursive"]) is True + depth = ast.literal_eval(options["max_depth"]) + excluded = options["exclude_subdirs"] + assert isinstance(excluded, ast.Call) and isinstance(excluded.func, ast.Name) + assert excluded.func.id == "frozenset" + return list(_native_yaml(PLUGINS / "tools", depth, ast.literal_eval(excluded.args[0]))) + + +def _tool_declarations(tree): + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + if ( + isinstance(node.func, ast.Name) and node.func.id == "ToolInfo" + or isinstance(node.func, ast.Attribute) and node.func.attr == "register_function" + ): + yield node + + +@pytest.mark.parametrize("directory", [ROOT / "flocks" / "agent" / "agents", PLUGINS / "agents"]) +def test_builtin_agent_groups(directory): + # _iter_agent_dirs supports immediate agents and one nested collection level. + paths = [path for path in _native_yaml(directory, 2) if path.name == "agent.yaml"] + assert len(paths) == 9 + for path in paths: + data = _yaml(path) + assert data.get("name"), path + _assert_group(data.get("group"), path, ASSISTANT_GROUPS) + + +def _skill_headers(): + headers = {} + for path in sorted((PLUGINS / "skills").rglob("SKILL.md")): + content = path.read_text(encoding="utf-8") + match = re.match(r"\A---\r?\n(.*?)^---[ \t]*(?:\r?\n|\Z)", content, re.M | re.S) + if match: + headers[path] = match[1] + return headers + + +def test_builtin_skill_frontmatter_groups(): + headers = _skill_headers() + assert len(headers) == 19 + for path, header in headers.items(): + # Inspect the literal scalar lines instead of reparsing the description: + # tool-builder has a legacy unquoted ': ' scalar supported by the native + # compatibility decoder. Do not rewrite it or any Markdown body here. + fields = {} + for key in ("name", "group"): + lines = re.findall(rf"^{key}:[^\r\n]*$", header, re.M) + assert len(lines) == 1, (path, key) + fields.update(yaml.safe_load(lines[0])) + assert re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", fields["name"]), path + assert re.search(r"^description:[ \t]+\S", header, re.M), path + _assert_group(fields.get("group"), path, ASSISTANT_GROUPS) + + +def _workflow_dirs(): + # read_workflow_dir also accepts Markdown-only drafts; meta.json alone never + # makes an empty directory discoverable. + return [ + directory for directory in sorted((PLUGINS / "workflows").iterdir()) + if directory.is_dir() and any( + (directory / name).is_file() for name in ("workflow.json", "workflow.md", "workflow.edit.md") + ) + ] + + +def test_builtin_workflow_groups(): + directories = _workflow_dirs() + assert len(directories) == 2 + for directory in directories: + meta = directory / "meta.json" + data = json.loads(meta.read_text(encoding="utf-8")) + _assert_group(data.get("group"), meta, {"安全研判"}) + + +def test_core_python_tool_groups(): + registry = _ast(REGISTRY) + registry_class = next(node for node in registry.body if isinstance(node, ast.ClassDef) and node.name == "ToolRegistry") + modules = next( + ast.literal_eval(node.value) for node in registry_class.body + if isinstance(node, ast.Assign) + and any(isinstance(target, ast.Name) and target.id == "_builtin_module_groups" for target in node.targets) + ) + paths = [ROOT.joinpath(*package.split("."), f"{module}.py") for package, names in modules for module in names] + assert len(paths) == 38 + declarations = [(path, node) for path in paths for node in _tool_declarations(_ast(path))] + assert len(declarations) == 40 + builtin_registration = next( + node for node in registry_class.body + if isinstance(node, ast.FunctionDef) and node.name == "_register_builtin_tools" + ) + time_declarations = list(_tool_declarations(builtin_registration)) + assert len(time_declarations) == 1 + assert ast.literal_eval(next(kw.value for kw in time_declarations[0].keywords if kw.arg == "name")) == "get_time" + declarations.append((REGISTRY, time_declarations[0])) + + names = set() + non_native = set() + for path, declaration in declarations: + keywords = {kw.arg: kw.value for kw in declaration.keywords} + name = ast.literal_eval(keywords["name"]) + assert name not in names, (path, name) + names.add(name) + assert "group" in keywords, (path, name) + assert isinstance(keywords["group"], ast.Constant), (path, name) + _assert_group(ast.literal_eval(keywords["group"]), (path, name), TOOL_GROUPS) + if "native" in keywords and ast.literal_eval(keywords["native"]) is False: + non_native.add(name) + assert len(names) == 41 + assert {"task", "lsp", "list_providers", "add_provider", "add_model"} <= non_native + assert "invalid" not in names # Internal failure sentinel, not a registered builtin. + + +@pytest.mark.parametrize("kind,tool_count,provider_count,allowed", [ + ("api", 37, 6, {"网络测绘", "威胁情报"}), + ("device", 80, 12, DEVICE_GROUPS), +]) +def test_builtin_yaml_tools_and_providers(kind, tool_count, provider_count, allowed): + root = PLUGINS / "tools" / kind + all_tools = _tool_yaml_paths() + assert len(all_tools) == 117 + tools = [path for path in all_tools if path.is_relative_to(root)] + assert len(tools) == tool_count + providers = sorted(root.glob("*/_provider.yaml")) + assert len(providers) == provider_count + by_identity = {} + for path in providers: + data = _yaml(path) + _assert_group(data.get("group"), path, allowed) + identity = data.get("service_id") or data["name"] + assert identity not in by_identity, path + by_identity[identity] = data + for path in tools: + data = _yaml(path) + assert data.get("name") and isinstance(data.get("handler"), dict), path + _assert_group(data.get("group"), path, allowed) + # Also checks VirusTotal's lowercase identity and the standalone device + # asset-inventory tool, neither of which should fall through defaults. + assert data["provider"] in by_identity, path + assert data["group"] == by_identity[data["provider"]]["group"], path + if kind == "api": + assert data["group"] == ("网络测绘" if data["provider"] == "fofa" else "威胁情报"), path + + +def test_builtin_mcp_catalog_groups(): + path = ROOT / ".flocks" / "mcp_list.json.example" + catalog = json.loads(path.read_text(encoding="utf-8")) + assert len(catalog["servers"]) == 11 + for item in catalog["servers"]: + _assert_group(item.get("group"), (path, item["id"]), MCP_GROUPS) + + +def test_builtin_mcp_template_groups(): + paths = list(_native_yaml(PLUGINS / "tools" / "mcp", 0)) + assert len(paths) == 3 + for path in paths: + _assert_group(_yaml(path).get("group"), path, {"威胁情报"}) + + +def test_inactive_placeholders_are_not_activated(): + assert PLUGINS / "skills" / "detect-malicious-skill" / "SKILL.md" not in _skill_headers() + assert PLUGINS / "workflows" / "wf-1" not in _workflow_dirs() + assert not (PLUGINS / "workflows" / "wf-1" / "meta.json").exists() diff --git a/tests/provider/test_api_service_management.py b/tests/provider/test_api_service_management.py index fbb1d9ebf..f487248f6 100644 --- a/tests/provider/test_api_service_management.py +++ b/tests/provider/test_api_service_management.py @@ -6,6 +6,195 @@ from flocks.tool.registry import Tool, ToolCategory, ToolInfo, ToolRegistry +class TestNativeAPIServiceGroup: + @pytest.fixture + def native_services(self, tmp_path, monkeypatch): + from types import SimpleNamespace + from flocks.config.config_writer import ConfigWriter + from flocks.config import api_versioning + from flocks.server.routes import provider as routes + from flocks.tool.schema import api_service_schema + + monkeypatch.setenv("FLOCKS_CONFIG_DIR", str(tmp_path / "config")) + descriptors = [] + for version in ("1", "2"): + path = tmp_path / version / "_provider.yaml" + path.parent.mkdir() + path.write_text(yaml.safe_dump({"name": "Native API", "service_id": "native_api", "version": version, "group": " Package "})) + descriptors.append(SimpleNamespace(storage_key=f"native_api_v{version}", service_id="native_api", provider_yaml=path)) + monkeypatch.setattr(api_versioning, "discover_api_service_descriptors", lambda **kw: descriptors) + monkeypatch.setattr(api_service_schema, "discover_api_service_descriptors", lambda **kw: descriptors) + monkeypatch.setattr(ToolRegistry, "init_async", AsyncMock()) + monkeypatch.setattr(ToolRegistry, "get_api_service_ids", lambda: {d.storage_key for d in descriptors}) + monkeypatch.setattr(routes, "_get_api_service_tool_infos", lambda name: []) + monkeypatch.setattr(routes, "_is_api_service_builtin", lambda *args: False) + monkeypatch.setattr(routes, "_read_api_service_status_cache", AsyncMock(return_value={"native_api_v1": {"status": "connected"}})) + no_toggle = MagicMock(side_effect=AssertionError("metadata cannot toggle tools")) + no_status_write = AsyncMock(side_effect=AssertionError("metadata cannot reset status")) + monkeypatch.setattr(routes, "_set_api_service_tools_enabled", no_toggle) + monkeypatch.setattr(routes, "_write_api_service_status_cache", no_status_write) + ConfigWriter.set_api_service("native_api_v1", {"enabled": True, "apiKey": "{secret:key1}", "future": {"kept": True}}) + ConfigWriter.set_api_service("native_api_v2", {"enabled": False, "apiKey": "{secret:key2}", "group": "Sibling"}) + routes._clear_api_service_summary_metadata_cache() + return routes, ConfigWriter, no_toggle, no_status_write + + @pytest.mark.asyncio + @pytest.mark.parametrize("record", [None, {}, {"enabled": False}, {"group": ""}]) + async def test_shipped_definition_vs_empty_or_disabled_instance(self, native_services, monkeypatch, tmp_path, record): + from flocks.config import api_versioning + from flocks.tool import registry + + routes, writer, _, _ = native_services + installation = tmp_path / "installation" + monkeypatch.setattr(registry, "__file__", str(installation / "flocks/tool/registry.py")) + descriptors = api_versioning.discover_api_service_descriptors() + selected = descriptors[0] + path = installation / ".flocks/plugins/tools/api/native/_provider.yaml" + path.parent.mkdir(parents=True) + path.write_bytes(selected.provider_yaml.read_bytes()) + selected.provider_yaml = path + monkeypatch.setattr(api_versioning, "discover_api_service_descriptors", lambda **kw: descriptors[:1]) + writer.remove_api_service("native_api_v1") + if record is not None: + writer.set_api_service("native_api_v1", record) + routes._clear_api_service_summary_metadata_cache() + summary = routes._build_api_service_summary("native_api_v1", {}) + assert summary.group_readonly is (record is None) + assert (await routes.get_api_service_metadata("native_api")).group_readonly is (record is None) + if record is None: + before = writer._get_config_path().read_bytes() + for group in (None, "", "Override"): + with pytest.raises(HTTPException) as exc: + await routes.update_api_service("native_api", routes.APIServiceUpdateRequest(group=group, enabled=True)) + assert exc.value.status_code == 400 + assert writer._get_config_path().read_bytes() == before + # Same-value group-only requests must not manufacture an instance. + same = await routes.update_api_service("native_api", routes.APIServiceUpdateRequest(group="Package")) + assert same.group_readonly is True + assert writer.get_api_service_raw("native_api_v1") is None + assert writer._get_config_path().read_bytes() == before + else: + changed = await routes.update_api_service("native_api", routes.APIServiceUpdateRequest(group="Instance")) + assert changed.group == "Instance" and changed.group_readonly is False + cleared = await routes.update_api_service("native_api", routes.APIServiceUpdateRequest(group=None)) + assert cleared.group == "" and cleared.group_readonly is False + assert yaml.safe_load(path.read_text())["group"] == " Package " + + @pytest.mark.asyncio + async def test_group_only_version_identity_preserves_credentials_and_enabled(self, native_services): + routes, writer, no_toggle, no_status_write = native_services + sibling = dict(writer.get_api_service_raw("native_api_v2")) + assert routes._build_api_service_summary("native_api_v1", {}).group == "Package" + result = await routes.update_api_service("native_api_v1", routes.APIServiceUpdateRequest(group=" Operations ")) + assert result.id == "native_api_v1" + assert result.group == "Operations" + assert result.enabled is True + assert writer.get_api_service_raw("native_api_v1") == { + "enabled": True, "apiKey": "{secret:key1}", "future": {"kept": True}, "group": "Operations", + } + assert writer.get_api_service_raw("native_api_v2") == sibling + for clear in (None, "", " "): + cleared = await routes.update_api_service("native_api_v1", routes.APIServiceUpdateRequest(group=clear)) + assert cleared.group == "" + assert cleared.enabled is True + assert writer.get_api_service_raw("native_api_v1")["group"] == "" + assert (await routes.get_api_service_metadata("native_api_v1")).group == "" + await routes.update_api_service("native_api_v1", routes.APIServiceUpdateRequest(verify_ssl=True)) + assert writer.get_api_service_raw("native_api_v1")["group"] == "" + assert writer.get_api_service_raw("native_api_v1")["future"] == {"kept": True} + no_toggle.assert_not_called() + no_status_write.assert_not_called() + + @pytest.mark.asyncio + async def test_single_version_alias_writes_only_canonical_native_record(self, native_services, monkeypatch): + from flocks.config import api_versioning + + routes, writer, _, _ = native_services + descriptors = api_versioning.discover_api_service_descriptors() + monkeypatch.setattr(api_versioning, "discover_api_service_descriptors", lambda **kw: descriptors[:1]) + sibling = dict(writer.get_api_service_raw("native_api_v2")) + updated = await routes.update_api_service("native_api", routes.APIServiceUpdateRequest(group="Operations")) + assert updated.id == "native_api_v1" + assert writer.list_api_services_raw()["native_api_v1"]["group"] == "Operations" + assert "native_api" not in writer.list_api_services_raw() + assert writer.get_api_service_raw("native_api_v2") == sibling + + @pytest.mark.asyncio + async def test_enabled_update_preserves_explicit_group_and_other_metadata(self, native_services, monkeypatch): + routes, writer, _, _ = native_services + await routes.update_api_service("native_api_v1", routes.APIServiceUpdateRequest(group=None)) + toggle = MagicMock(return_value=0) + monkeypatch.setattr(routes, "_set_api_service_tools_enabled", toggle) + monkeypatch.setattr(routes, "_write_api_service_status_cache", AsyncMock()) + for enabled in (False, True): + updated = await routes.update_api_service("native_api_v1", routes.APIServiceUpdateRequest(enabled=enabled)) + assert updated.enabled is enabled + assert updated.group == "" + raw = writer.get_api_service_raw("native_api_v1") + assert raw["group"] == "" + assert raw["apiKey"] == "{secret:key1}" + assert raw["future"] == {"kept": True} + assert toggle.call_count == 2 + + @pytest.mark.asyncio + async def test_metadata_only_service_does_not_become_credential_ready(self, native_services, monkeypatch): + import flocks.security + + routes, writer, _, _ = native_services + writer.remove_api_service("native_api_v1") + monkeypatch.setattr(flocks.security, "get_secret_manager", lambda: MagicMock(get=lambda key: None)) + summary = await routes.update_api_service("native_api_v1", routes.APIServiceUpdateRequest(group="Operations")) + assert summary.enabled is False + assert writer.get_api_service_raw("native_api_v1") == {"group": "Operations"} + credentials = await routes.get_service_credentials("native_api_v1") + assert credentials.has_credential is False + assert not credentials.fields + assert "group" not in credentials.model_dump() + + @pytest.mark.asyncio + async def test_ambiguous_legacy_id_cannot_edit_version_groups(self, native_services): + routes, writer, _, _ = native_services + original = writer.list_api_services_raw() + with pytest.raises(HTTPException) as exc: + await routes.update_api_service("native_api", routes.APIServiceUpdateRequest(group="Wrong identity")) + assert exc.value.status_code == 400 + assert writer.list_api_services_raw() == original + + @pytest.mark.asyncio + async def test_group_cannot_be_written_as_credentials_or_mark_configured(self, native_services, monkeypatch): + import flocks.security + + routes, writer, _, _ = native_services + raw = {"group": "Operations", "credential_fields": [ + {"key": "group", "storage": "config"}, + {"key": "alias", "storage": "secret", "config_key": "group"}, + ]} + writer.set_api_service("native_api_v1", raw) + monkeypatch.setattr(flocks.security, "get_secret_manager", lambda: MagicMock(get=lambda key: None)) + credentials = await routes.get_service_credentials("native_api_v1") + assert credentials.fields is None + assert credentials.has_credential is False + with pytest.raises(HTTPException) as exc: + await routes.set_service_credentials("native_api_v1", routes.ProviderCredentialRequest(fields={"group": "Bad"})) + assert exc.value.status_code == 400 + assert writer.get_api_service_raw("native_api_v1") == raw + writer.set_api_service("native_api_v1", {"group": "Operations"}) + save_status = AsyncMock() + monkeypatch.setattr(routes, "_save_api_service_status", save_status) + await routes._save_api_service_status_if_configured("native_api_v1", {"success": True}) + save_status.assert_not_called() + + @pytest.mark.parametrize("group", [42, {}, "x" * 33, "a\x7fb"]) + def test_group_validation(self, group): + from pydantic import ValidationError + from flocks.server.routes.provider import APIServiceUpdateRequest + + with pytest.raises(ValidationError): + APIServiceUpdateRequest(group=group) + with pytest.raises(ValidationError): + APIServiceUpdateRequest(enabled=None) + + class TestAPIServiceManagement: @pytest.mark.asyncio async def test_list_api_services_returns_enabled_state_and_bilingual_descriptions(self): diff --git a/tests/server/routes/test_agent_routes.py b/tests/server/routes/test_agent_routes.py index 2e091d87c..16225690c 100644 --- a/tests/server/routes/test_agent_routes.py +++ b/tests/server/routes/test_agent_routes.py @@ -56,6 +56,377 @@ def _isolated_delegatable_settings(tmp_path: Path, monkeypatch: pytest.MonkeyPat # List # =========================================================================== +@pytest.fixture +def native_group_agents(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + from unittest.mock import AsyncMock + + from flocks.agent import agent_factory, registry + from flocks.server.routes import agent as routes + from flocks.skill.skill import Skill + from flocks.tool.registry import ToolRegistry + + home = tmp_path / "home" + home.mkdir() + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("TMPDIR", str(tmp_path)) + monkeypatch.chdir(tmp_path) + builtin = tmp_path / "builtin-agents" + plugin = home / ".flocks" / "plugins" / "agents" + for root, name in ((builtin, "native-demo"), (plugin, "yaml-demo")): + folder = root / name + folder.mkdir(parents=True) + (folder / "agent.yaml").write_text( + f"name: {name}\ngroup: Package\ndescription: Original\nmode: subagent\nunknown: kept\n", + encoding="utf-8", + ) + (folder / "prompt.md").write_bytes(b"Original prompt\r\n keep trailing spaces \r\n") + monkeypatch.setattr(agent_factory, "_BUILTIN_AGENTS_DIR", builtin) + monkeypatch.setattr(agent_factory, "_SYSTEM_AGENT_ROOTS", (builtin,)) + monkeypatch.setattr(agent_factory, "_PLUGIN_AGENTS_DIR", plugin) + monkeypatch.setattr(registry.PluginLoader, "load_extension", lambda *args, **kwargs: None) + monkeypatch.setattr(ToolRegistry, "init_async", AsyncMock()) + monkeypatch.setattr(ToolRegistry, "list_tools", lambda: []) + monkeypatch.setattr(Skill, "list_enabled", AsyncMock(return_value=[])) + monkeypatch.setattr("flocks.workflow.center.scan_skill_workflows", AsyncMock(return_value=[])) + monkeypatch.setattr(routes, "_get_all_tool_names_async", AsyncMock(return_value=[])) + return builtin, plugin + + +class TestAgentNativeGroup: + @pytest.mark.asyncio + async def test_native_group_storage_persist_clear_and_ordinary_updates(self, client, native_group_agents): + from flocks.agent.registry import Agent + from flocks.storage.storage import Storage + + created = await client.post("/api/agent", json={**_AGENT_PAYLOAD, "group": " Team A "}) + assert created.status_code == 200, created.text + assert created.json()["group"] == "Team A" + changed = await client.put("/api/agent/test-agent", json={"description": "Changed"}) + assert changed.json()["group"] == "Team A" + assigned = await client.put("/api/agent/test-agent", json={"group": "team a"}) + assert assigned.json()["group"] == "team a" + assert assigned.json()["prompt"] == _AGENT_PAYLOAD["prompt"] + Agent._custom_agents.clear() + await client.post("/api/agent/refresh") + assert (await client.get("/api/agent/test-agent")).json()["group"] == "team a" + for value in (None, ""): + cleared = await client.put("/api/agent/test-agent", json={"group": value}) + assert cleared.status_code == 200, cleared.text + assert cleared.json()["group"] == "" + assert (await Storage.read("agent/custom/test-agent"))["group"] == "" + Agent._custom_agents.clear() + await client.post("/api/agent/refresh") + listed = (await client.get("/api/agent")).json() + assert next(item for item in listed if item["name"] == "test-agent")["group"] == "" + + @pytest.mark.asyncio + async def test_native_group_yaml_preserves_prompt_and_unknown_fields(self, client, native_group_agents): + import yaml + from flocks.agent.agent_factory import load_agent, yaml_to_agent_info + from flocks.agent.registry import Agent + + folder = native_group_agents[1] / "yaml-demo" + prompt_before = (folder / "prompt.md").read_bytes() + response = await client.put("/api/agent/yaml-demo", json={"group": " Operations "}) + assert response.status_code == 200, response.text + assert response.json()["group"] == "Operations" + assert (folder / "prompt.md").read_bytes() == prompt_before + raw = yaml.safe_load((folder / "agent.yaml").read_text()) + assert raw["group"] == "Operations" + assert raw["unknown"] == "kept" + assert load_agent(folder).group == "Operations" + assert yaml_to_agent_info(raw, folder / "agent.yaml").group == "Operations" + assert (await client.put("/api/agent/yaml-demo", json={"description": "Edited"})).json()["group"] == "Operations" + assert (await client.put("/api/agent/yaml-demo", json={"group": None})).json()["group"] == "" + await client.post("/api/agent/refresh") + assert (await client.get("/api/agent/yaml-demo")).json()["group"] == "" + assert yaml.safe_load((folder / "agent.yaml").read_text())["group"] == "" + assert (folder / "prompt.md").read_bytes() == prompt_before + + @pytest.mark.asyncio + async def test_native_group_builtin_rejects_changes_and_ignores_stale_overrides(self, client, native_group_agents): + from flocks.agent.registry import Agent + from flocks.config.config import AgentConfig, Config, ConfigInfo + + source = native_group_agents[0] / "native-demo" / "agent.yaml" + before = source.read_bytes() + await Config.update(ConfigInfo(agent={"native-demo": AgentConfig(group="Stale", temperature=0.4, options={"keep": True})})) + Agent.invalidate_cache() + config_before = Config.get_config_file().read_bytes() + for value in ("Response", None, ""): + response = await client.put("/api/agent/native-demo", json={"group": value, "prompt": "Do not write"}) + assert response.status_code == 403, response.text + current = (await client.get("/api/agent/native-demo")).json() + assert current["group"] == "Package" + assert current["group_readonly"] is True + assert current["temperature"] == 0.4 + same = await client.put("/api/agent/native-demo", json={"group": "Package"}) + assert same.status_code == 200, same.text + assert Config.get_config_file().read_bytes() == config_before + assert source.read_bytes() == before + # Existing built-in model/temperature controls remain available. + changed = await client.put("/api/agent/native-demo/model", json={"temperature": 0.7}) + assert changed.status_code == 200, changed.text + assert changed.json()["group"] == "Package" + assert changed.json()["temperature"] == 0.7 + + @pytest.mark.asyncio + async def test_native_group_yaml_config_override_survives_edit_and_reload(self, client, native_group_agents): + from flocks.agent.registry import Agent + from flocks.config.config import AgentConfig, Config, ConfigInfo + from flocks.storage.storage import Storage + + await Config.update(ConfigInfo(agent={"yaml-demo": AgentConfig(group="Override", temperature=0.4)})) + extras = {"skills": ["kept-skill"], "tools": ["kept-tool"], "x-owner": {"keep": True}} + await Storage.write("agent/custom/yaml-demo", extras) + await client.post("/api/agent/refresh") + assert (await client.get("/api/agent/yaml-demo")).json()["group"] == "Override" + for value, expected in (("Changed", "Changed"), (None, ""), ("", "")): + changed = await client.put("/api/agent/yaml-demo", json={"group": value}) + assert changed.status_code == 200, changed.text + assert changed.json()["group"] == expected + await client.post("/api/agent/refresh") + reloaded = (await client.get("/api/agent/yaml-demo")).json() + assert reloaded["group"] == expected + assert reloaded["temperature"] == 0.4 + assert reloaded["skills"] == ["kept-skill"] + assert reloaded["tools"] == ["kept-tool"] + renamed = await client.put("/api/agent/yaml-demo", json={"nameCn": "显示名称"}) + assert renamed.status_code == 200, renamed.text + assert renamed.json()["group"] == expected + assert (await Storage.read("agent/custom/yaml-demo")) == extras + mixed = await client.put("/api/agent/yaml-demo", json={"description": "Edited", "group": "Combined"}) + assert mixed.status_code == 200, mixed.text + await client.post("/api/agent/refresh") + reloaded = (await client.get("/api/agent/yaml-demo")).json() + assert reloaded["group"] == "Combined" + assert reloaded["description"] == "Edited" + assert reloaded["nameCn"] == "显示名称" + + @pytest.mark.asyncio + async def test_native_group_storage_config_override_never_replaces_definition(self, client, native_group_agents): + from flocks.agent.registry import Agent + from flocks.config.config import AgentConfig, Config, ConfigInfo + from flocks.storage.storage import Storage + + created = await client.post("/api/agent", json={**_AGENT_PAYLOAD, "group": "Original"}) + assert created.status_code == 200, created.text + await Config.update(ConfigInfo(agent={"test-agent": AgentConfig(group="Override")})) + Agent._custom_agents.clear() + await client.post("/api/agent/refresh") + reloaded = (await client.get("/api/agent/test-agent")).json() + assert reloaded["group"] == "Override" + assert reloaded["prompt"] == _AGENT_PAYLOAD["prompt"] + assert reloaded["description"] == _AGENT_PAYLOAD["description"] + changed = await client.put("/api/agent/test-agent", json={"nameCn": "显示名称"}) + assert changed.status_code == 200, changed.text + assert changed.json()["group"] == "Override" + for value, expected in (("Changed", "Changed"), (None, ""), ("", "")): + changed = await client.put("/api/agent/test-agent", json={"group": value}) + assert changed.status_code == 200, changed.text + assert changed.json()["group"] == expected + Agent._custom_agents.clear() + await client.post("/api/agent/refresh") + reloaded = (await client.get("/api/agent/test-agent")).json() + assert reloaded["group"] == expected + assert reloaded["prompt"] == _AGENT_PAYLOAD["prompt"] + assert reloaded["nameCn"] == "显示名称" + stored = await Storage.read("agent/custom/test-agent") + assert stored["prompt"] == _AGENT_PAYLOAD["prompt"] + reset = await client.put("/api/agent/test-agent/model", json={"model": None}) + assert reset.status_code == 200, reset.text + assert reset.json()["group"] == "" + + @pytest.mark.asyncio + @pytest.mark.parametrize("value", [12, False, [], {}, "a" * 33, "a\x00b", "a\nb", "a\x7fb"]) + async def test_native_group_rejects_invalid_values(self, client, native_group_agents, value): + response = await client.put("/api/agent/yaml-demo", json={"group": value}) + assert response.status_code == 422, response.text + + @pytest.mark.asyncio + async def test_native_group_missing_agent_is_not_created(self, client, native_group_agents): + response = await client.put("/api/agent/unknown", json={"group": "Team"}) + assert response.status_code == 404 + + +class TestAgentGroupRegression: + @pytest.mark.asyncio + async def test_group_override_preserves_raw_config_references(self, client, native_group_agents, tmp_path): + from flocks.agent.registry import Agent + from flocks.config.config import Config + from flocks.config.config_writer import ConfigWriter + + prompt = tmp_path / "prompt.txt" + prompt.write_text("Resolved prompt", encoding="utf-8") + raw = { + "plugin": ["custom/plugin.py"], + "x-unknown": {"env": "{env:NOT_SET}", "secret": "{secret:kept}"}, + "agent": { + "yaml-demo": {"group": "Old", "prompt": "{file:" + str(prompt) + "}", "options": {"keep": True}}, + "other": {"prompt": "Other definition", "vendor": [1, 2]}, + }, + } + path = ConfigWriter._get_config_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(raw), encoding="utf-8") + Config.clear_cache() + Agent.invalidate_cache() + for group in ("New", None): + response = await client.put("/api/agent/yaml-demo", json={"group": group}) + assert response.status_code == 200, response.text + raw["agent"]["yaml-demo"]["group"] = group or "" + assert json.loads(path.read_text()) == raw + + @pytest.mark.asyncio + async def test_malformed_raw_config_blocks_mixed_update_before_yaml_write(self, client, native_group_agents): + from flocks.config.config import AgentConfig, Config, ConfigInfo + from flocks.config.config_writer import ConfigWriter + + await Config.update(ConfigInfo(agent={"yaml-demo": AgentConfig(group="Old")})) + assert (await client.get("/api/agent/yaml-demo")).status_code == 200 + source = native_group_agents[1] / "yaml-demo" / "agent.yaml" + before = source.read_bytes() + config_path = ConfigWriter._get_config_path() + damaged = b'{"agent": { broken' + config_path.write_bytes(damaged) + response = await client.put("/api/agent/yaml-demo", json={"group": "New", "description": "Must not write"}) + assert response.status_code == 500, response.text + assert config_path.read_bytes() == damaged + assert source.read_bytes() == before + + @pytest.mark.asyncio + async def test_group_only_config_never_creates_phantom_agent(self, client, native_group_agents): + from flocks.agent.registry import Agent + from flocks.config.config import AgentConfig, Config, ConfigInfo + + await Config.update(ConfigInfo(agent={ + "gone": AgentConfig(group="Metadata", options={}), + "gone-cleared": AgentConfig(group=None), + "real-config-agent": AgentConfig(group="Custom", prompt="A real configured agent"), + })) + loaded = await Agent.refresh() + assert "gone" not in loaded + assert "gone-cleared" not in loaded + assert loaded["real-config-agent"].prompt == "A real configured agent" + assert loaded["real-config-agent"].group_readonly is False + + @pytest.mark.asyncio + @pytest.mark.parametrize("config_overlay", [False, True]) + async def test_group_change_invalidates_two_warmed_directories(self, client, native_group_agents, config_overlay): + from flocks.agent.registry import Agent + from flocks.config.config import AgentConfig, Config, ConfigInfo + from flocks.project.instance import Instance, InstanceContext, _current_instance + + if config_overlay: + await Config.update(ConfigInfo(agent={"yaml-demo": AgentConfig(group="Old")})) + contexts = [InstanceContext(name, name, None) for name in ("project-a", "project-b")] + async def get_in_context(context): + token = _current_instance.set(context) + try: + return await Agent.get("yaml-demo") + finally: + _current_instance.reset(token) + + before = [await get_in_context(context) for context in contexts] + unrelated = Instance.state(object) + token = _current_instance.set(contexts[1]) + try: + unrelated_before = unrelated() + finally: + _current_instance.reset(token) + response = await client.put("/api/agent/yaml-demo", json={"group": "Shared"}) + assert response.status_code == 200, response.text + for context, prior in zip(contexts, before): + current = await get_in_context(context) + assert current.group == "Shared" + assert current is not prior + token = _current_instance.set(contexts[1]) + try: + assert unrelated() is unrelated_before + finally: + _current_instance.reset(token) + + @pytest.mark.asyncio + async def test_custom_project_native_flag_does_not_lock_group(self, client, native_group_agents): + from flocks.agent.registry import Agent + + folder = Path.cwd() / ".flocks" / "plugins" / "agents" / "project-custom" + folder.mkdir(parents=True) + source = folder / "agent.yaml" + source.write_text("name: project-custom\ngroup: Original\nprompt: Keep\n", encoding="utf-8") + original = source.read_bytes() + Agent.invalidate_cache() + for group in ("Editable", None): + response = await client.put("/api/agent/project-custom", json={"group": group}) + assert response.status_code == 200, response.text + assert response.json()["native"] is True + assert response.json()["group_readonly"] is False + assert response.json()["group"] == (group or "") + # Preserve its original native/config override write path. + assert source.read_bytes() == original + + @pytest.mark.asyncio + async def test_shipped_yaml_same_group_echo_allows_ordinary_edit(self, client, native_group_agents, monkeypatch): + from flocks.agent import agent_factory + from flocks.agent.registry import Agent + + plugin = native_group_agents[1] + monkeypatch.setattr(agent_factory, "_SYSTEM_AGENT_ROOTS", (native_group_agents[0], plugin)) + Agent.invalidate_cache() + response = await client.put("/api/agent/yaml-demo", json={"group": "Package", "description": "Editable description"}) + assert response.status_code == 200, response.text + assert response.json()["description"] == "Editable description" + assert response.json()["group"] == "Package" + assert response.json()["group_readonly"] is True + before = (plugin / "yaml-demo" / "agent.yaml").read_bytes() + rejected = await client.put("/api/agent/yaml-demo", json={"group": "Changed", "description": "No write"}) + assert rejected.status_code == 403 + assert (plugin / "yaml-demo" / "agent.yaml").read_bytes() == before + + @pytest.mark.asyncio + async def test_runtime_group_override_does_not_mutate_registered_definition(self, client, native_group_agents): + from flocks.agent.agent import AgentInfo + from flocks.agent.registry import Agent + from flocks.config.config import Config + from flocks.config.config_writer import ConfigWriter + + source = AgentInfo(name="runtime-custom", group="Package", prompt="Runtime definition") + Agent.register(source.name, source) + response = await client.put("/api/agent/runtime-custom", json={"group": "Override"}) + assert response.status_code == 200, response.text + assert response.json()["group"] == "Override" + assert source.group == "Package" + config_path = ConfigWriter._get_config_path() + raw = json.loads(config_path.read_text()) + del raw["agent"][source.name]["group"] + config_path.write_text(json.dumps(raw)) + Config.clear_cache() + Agent.invalidate_cache() + assert (await Agent.get(source.name)).group == "Package" + + @pytest.mark.asyncio + async def test_disabled_alias_group_validation_still_uses_selected_definition(self, client, native_group_agents, monkeypatch): + from flocks.agent.registry import Agent, AGENT_ALIASES + from flocks.config.config import AgentConfig, Config, ConfigInfo + + monkeypatch.setitem(AGENT_ALIASES, "old-name", "native-demo") + await Config.update(ConfigInfo(agent={"native-demo": AgentConfig(disable=True)})) + Agent.invalidate_cache() + assert await Agent.get("native-demo") is None + selected = await Agent.get_group_definition("old-name") + assert selected.group_readonly is True + assert selected.group == "Package" + await Agent.validate_group_settings({"old-name": {"group": "Package"}}) + await Agent.validate_group_settings({"old-name": {"temperature": 0.3}}) + for group in ("Changed", "", None): + with pytest.raises(ValueError, match="read-only"): + await Agent.validate_group_settings({"old-name": {"group": group}}) + for name in ("native-demo", "old-name"): + created = await client.post("/api/agent", json={**_AGENT_PAYLOAD, "name": name, "group": "Shadow"}) + assert created.status_code == 409, created.text + assert name not in Agent._custom_agents + + class TestAgentList: @pytest.mark.asyncio async def test_tool_name_lookup_uses_async_registry_init(self, monkeypatch: pytest.MonkeyPatch): diff --git a/tests/server/routes/test_mcp_routes.py b/tests/server/routes/test_mcp_routes.py index 282b2e201..e40c2ec52 100644 --- a/tests/server/routes/test_mcp_routes.py +++ b/tests/server/routes/test_mcp_routes.py @@ -11,6 +11,183 @@ from flocks.tool import tool_loader +class TestNativeMcpGroup: + @pytest.mark.parametrize("group", ["Instance", "", None]) + def test_native_writers_preserve_group_yml_metadata_and_replace_connection(self, tmp_path, monkeypatch, group): + import yaml + from flocks.config.config_writer import ConfigWriter + + monkeypatch.setenv("FLOCKS_CONFIG_DIR", str(tmp_path / "config")) + monkeypatch.setattr(tool_loader, "_MCP_SUBDIR", tmp_path / "mcp") + yaml_path = tmp_path / "mcp/native-server.yml" + yaml_path.parent.mkdir() + yaml_path.write_text(yaml.safe_dump({ + "name": "native-server", "type": "local", "command": ["old"], "cwd": "/old", + "environment": {"KEY": "{secret:old}"}, "args": ["old"], "group": "Old YAML", + "description": "Native template metadata", "future_metadata": {"keep": True}, + })) + ConfigWriter.add_mcp_server("native-server", {"type": "local", "command": ["old"], "group": group}) + replacement = {"type": "remote", "url": "https://example.invalid/new", "enabled": False} + # Exercise the native writer boundary without the route wrapper. + ConfigWriter.add_mcp_server("native-server", replacement) + result = tool_loader.save_mcp_config("native-server", replacement) + assert result == yaml_path + assert "group" not in replacement + assert ConfigWriter.get_mcp_server("native-server") == {**replacement, "group": group or ""} + assert yaml.safe_load(yaml_path.read_text()) == { + "name": "native-server", **replacement, "group": group or "", + "description": "Native template metadata", "future_metadata": {"keep": True}, + } + assert list(yaml_path.parent.iterdir()) == [yaml_path] + for clear in (None, ""): + saved = tool_loader.save_mcp_config("native-server", {**replacement, "group": clear}) + assert yaml.safe_load(saved.read_text())["group"] == "" + + def test_yaml_only_group_is_preserved_without_json_record(self, tmp_path, monkeypatch): + import yaml + from flocks.config.config_writer import ConfigWriter + + monkeypatch.setenv("FLOCKS_CONFIG_DIR", str(tmp_path / "config")) + monkeypatch.setattr(tool_loader, "_MCP_SUBDIR", tmp_path / "mcp") + first = tool_loader.save_mcp_config("yaml-only", {"type": "remote", "url": "https://example.invalid", "group": "YAML"}) + saved = tool_loader.save_mcp_config("yaml-only", {"type": "local", "command": ["new"]}) + assert saved == first + assert yaml.safe_load(saved.read_text()) == {"name": "yaml-only", "type": "local", "command": ["new"], "group": "YAML"} + assert ConfigWriter.get_mcp_server("yaml-only") is None + + @pytest.mark.asyncio + @pytest.mark.parametrize("group", [" Operations ", "", None]) + async def test_group_only_persists_raw_and_yaml_without_connection_changes(self, tmp_path, monkeypatch, group): + import yaml + from flocks.config.config_writer import ConfigWriter + + monkeypatch.setenv("FLOCKS_CONFIG_DIR", str(tmp_path / "config")) + monkeypatch.setattr(tool_loader, "_MCP_SUBDIR", tmp_path / "mcp") + original = { + "type": "sse", "url": "https://example.invalid/mcp", "enabled": False, + "transport": "http", "oauth": {"clientId": "keep"}, + "auth": {"type": "apikey", "value": "{secret:keep}"}, + "headers": {"Authorization": "{secret:header}"}, + "env": {"OTHER": "value"}, "unknown": {"keep": True}, "group": "Package", + } + ConfigWriter.add_mcp_server("native-group", original) + forbidden = AsyncMock(side_effect=AssertionError("metadata cannot change runtime state")) + for method in ("connect", "disconnect", "remove", "status"): + monkeypatch.setattr(mcp_routes.MCP, method, forbidden) + result = await mcp_routes.update_mcp_server( + "native-group", mcp_routes.McpUpdateRequest(config={"group": group}), + ) + expected = {**original, "group": (group or "").strip()} + assert ConfigWriter.get_mcp_server("native-group") == expected + saved = yaml.safe_load((tmp_path / "mcp" / "native_group.yaml").read_text()) + assert saved == {"name": "native-group", **expected} + assert result["config"]["group"] == expected["group"] + assert result["reconnected"] is False + forbidden.assert_not_called() + # An omitted group remains unchanged and also cannot trigger a reconnect. + await mcp_routes.update_mcp_server("native-group", mcp_routes.McpUpdateRequest(config={})) + assert ConfigWriter.get_mcp_server("native-group") == expected + + @pytest.mark.asyncio + async def test_group_only_preserves_yaml_fields_and_original_filename(self, tmp_path, monkeypatch): + import yaml + from flocks.config.config_writer import ConfigWriter + + monkeypatch.setenv("FLOCKS_CONFIG_DIR", str(tmp_path / "config")) + monkeypatch.setattr(tool_loader, "_MCP_SUBDIR", tmp_path / "mcp") + original = {"type": "remote", "url": "https://example.invalid/runtime", "group": "Before"} + ConfigWriter.add_mcp_server("native-group", original) + yaml_path = tmp_path / "mcp" / "native-group.yml" + yaml_path.parent.mkdir() + source = {"name": "native-group", "type": "remote", "url": "https://example.invalid/source", "group": "Before", "future_metadata": {"kept": True}} + yaml_path.write_text(yaml.safe_dump(source)) + await mcp_routes.update_mcp_server("native-group", mcp_routes.McpUpdateRequest(config={"group": None})) + assert yaml.safe_load(yaml_path.read_text()) == {**source, "group": ""} + assert ConfigWriter.get_mcp_server("native-group") == {**original, "group": ""} + assert sorted(path.name for path in yaml_path.parent.iterdir()) == ["native-group.yml"] + + @pytest.mark.parametrize("group", ["Operations", "", None]) + def test_native_config_replacement_preserves_omitted_group(self, tmp_path, monkeypatch, group): + import yaml + from flocks.config.config_writer import ConfigWriter + + monkeypatch.setenv("FLOCKS_CONFIG_DIR", str(tmp_path / "config")) + monkeypatch.setattr(tool_loader, "_MCP_SUBDIR", tmp_path / "mcp") + ConfigWriter.add_mcp_server("native", {"type": "remote", "url": "https://example.invalid/old", "group": group}) + replacement = {"type": "remote", "url": "https://example.invalid/new", "enabled": False} + mcp_routes._persist_mcp_server_config("native", replacement) + expected = {**replacement, "group": group or ""} + assert ConfigWriter.get_mcp_server("native") == expected + assert yaml.safe_load((tmp_path / "mcp" / "native.yaml").read_text()) == {"name": "native", **expected} + assert "group" not in replacement + + @pytest.mark.asyncio + @pytest.mark.parametrize("saved_group", ["Operations", "", None]) + async def test_catalog_reinstall_preserves_native_group(self, tmp_path, monkeypatch, saved_group): + from types import SimpleNamespace + from flocks.config.config_writer import ConfigWriter + + monkeypatch.setenv("FLOCKS_CONFIG_DIR", str(tmp_path / "config")) + monkeypatch.setattr(tool_loader, "_MCP_SUBDIR", tmp_path / "mcp") + ConfigWriter.add_mcp_server("native", {"type": "local", "command": ["old"], "group": saved_group}) + entry = SimpleNamespace( + name="Native", group="Package", required_env_vars={}, + to_mcp_config=lambda *args, **kwargs: {"type": "local", "command": ["new"]}, + ) + monkeypatch.setattr(mcp_routes.McpCatalog, "get", lambda: SimpleNamespace(get_entry=lambda name: entry)) + result = await mcp_routes.install_from_catalog(mcp_routes.CatalogInstallRequest(server_id="native", skip_package_install=True)) + assert result["config"]["group"] == (saved_group or "") + assert ConfigWriter.get_mcp_server("native")["group"] == (saved_group or "") + + @pytest.mark.asyncio + async def test_unconfigured_catalog_group_write_rejected(self, monkeypatch): + from fastapi import HTTPException + + monkeypatch.setattr(mcp_routes.ConfigWriter, "get_mcp_server", lambda name: None) + monkeypatch.setattr(mcp_routes, "_persist_mcp_server_config", lambda *a: pytest.fail("must not install")) + with pytest.raises(HTTPException) as exc: + await mcp_routes.update_mcp_server("catalog-only", mcp_routes.McpUpdateRequest(config={"group": "G"})) + assert exc.value.status_code == 404 + + @pytest.mark.asyncio + async def test_status_and_info_config_clear_outranks_catalog_default(self, monkeypatch): + from types import SimpleNamespace + + monkeypatch.setattr(mcp_routes.McpCatalog, "get", lambda: SimpleNamespace(get_entry=lambda name: SimpleNamespace(group="Package"))) + monkeypatch.setattr(mcp_routes.MCP, "status", AsyncMock(return_value={ + "cleared": McpStatusInfo(status=McpStatus.CONNECTED), + })) + configs = { + "cleared": {"type": "remote", "url": "https://example.invalid", "group": ""}, + "inherited": {"type": "remote", "url": "https://example.invalid"}, + } + monkeypatch.setattr(mcp_routes.ConfigWriter, "list_mcp_servers", lambda: configs) + monkeypatch.setattr(mcp_routes.ConfigWriter, "get_mcp_server", configs.get) + monkeypatch.setattr(mcp_routes.MCP, "get_server_info", AsyncMock(return_value=None)) + statuses = await mcp_routes.get_mcp_status() + assert statuses["cleared"]["group"] == "" + assert statuses["inherited"]["group"] == "Package" + info = await mcp_routes.get_mcp_server_info("cleared") + assert info["group"] == info["config"]["group"] == info["status"]["group"] == "" + + @pytest.mark.asyncio + @pytest.mark.parametrize("method,path,payload", [ + ("PUT", "/api/mcp/validation-only", {"config": {"group": "a\x00b"}}), + ("PATCH", "/api/tools/validation-only", {"group": 42}), + ("PATCH", "/api/provider/api-services/validation-only", {"group": "x" * 33}), + ]) + async def test_native_group_http_validation_errors_are_serializable(self, client, method, path, payload): + response = await client.request(method, path, json=payload) + assert response.status_code == 422, response.text + + @pytest.mark.parametrize("group", [42, [], "x" * 33, "a\x00b"]) + def test_group_validation_rejects_invalid_config(self, group): + from pydantic import ValidationError + + with pytest.raises(ValidationError): + mcp_routes.McpUpdateRequest(config={"group": group}) + + class TestMcpRoutes: @pytest.mark.asyncio diff --git a/tests/server/routes/test_remaining_routes.py b/tests/server/routes/test_remaining_routes.py index 1698167b1..c5a8d51aa 100644 --- a/tests/server/routes/test_remaining_routes.py +++ b/tests/server/routes/test_remaining_routes.py @@ -11,7 +11,7 @@ import pytest from fastapi import APIRouter, HTTPException, status from httpx import AsyncClient -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, Mock # --------------------------------------------------------------------------- @@ -59,7 +59,7 @@ async def _wait_for_execution_terminal_state( def isolated_workflow_filesystem(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): """Redirect workflow route filesystem writes into a per-test temp dir.""" from flocks.server.routes import workflow as workflow_routes - from flocks.workflow import fs_store + from flocks.workflow import center, fs_store workspace_root = tmp_path / "workspace" project_root = workspace_root / ".flocks" / "plugins" / "workflows" @@ -86,17 +86,17 @@ def isolated_workflow_filesystem(tmp_path: Path, monkeypatch: pytest.MonkeyPatch monkeypatch.setattr(workflow_routes, "_global_workflow_dir", lambda workflow_id: global_root / workflow_id) monkeypatch.setattr(fs_store, "_workspace_root", workspace_root, raising=False) monkeypatch.setattr(fs_store, "find_workspace_root", lambda: workspace_root) + # fs_store delegates to center.resolve_workflow_scan_roots; patch its real + # root resolvers rather than silently creating unused fs_store attributes. monkeypatch.setattr( - fs_store, + center, "resolve_project_workflow_roots", - lambda workspace=None: [legacy_project_main, legacy_project_plugin, project_root], - raising=False, + lambda workspace=None: [legacy_project_plugin, legacy_project_main, project_root], ) monkeypatch.setattr( - fs_store, + center, "resolve_global_workflow_roots", - lambda: [legacy_global_main, legacy_global_plugin, global_root], - raising=False, + lambda: [legacy_global_plugin, legacy_global_main, global_root], ) yield { @@ -377,11 +377,12 @@ async def test_cancel_running_workflow_execution(self, client: AsyncClient): }, ], "edges": [ - {"from": "step1", "to": "step2"}, + {"from": "step1", "to": "step2", "mapping": {"value": "value"}}, ], }, } create_resp = await client.post("/api/workflow", json=payload) + assert create_resp.status_code == status.HTTP_201_CREATED, create_resp.text wf_id = create_resp.json()["id"] run_resp = await client.post(f"/api/workflow/{wf_id}/run", json={"inputs": {}}) @@ -565,6 +566,96 @@ def _invalidate(channel_id: str | None = None) -> None: assert resp.status_code == status.HTTP_200_OK, resp.text assert invalidate_calls == ["weixin", "feishu"] + @pytest.mark.asyncio + @pytest.mark.parametrize("section,name", [ + ("agent", "rex"), ("agent", "sisyphus"), ("mode", "sisyphus"), + ("tool_settings", "get_time"), + ]) + @pytest.mark.parametrize("group", ["Changed", "", None]) + async def test_locked_group_preflight_precedes_config_and_secret_writes( + self, monkeypatch, section, name, group, + ): + from flocks.agent.registry import Agent + from flocks.server.routes import config as config_routes + from flocks.security import channel_secrets + from flocks.tool.registry import ToolRegistry + + update = AsyncMock() + extract = Mock() + agent_check = AsyncMock(side_effect=ValueError("System definition group is read-only")) + tool_check = Mock(side_effect=ValueError("System definition group is read-only")) + monkeypatch.setattr(config_routes.Config, "update", update) + monkeypatch.setattr(channel_secrets, "extract_channel_secrets", extract) + monkeypatch.setattr(Agent, "validate_group_settings", agent_check) + monkeypatch.setattr(ToolRegistry, "validate_group_settings", tool_check) + payload = { + section: {name: {"group": group}}, + "channels": {"slack": {"botToken": "synthetic-not-a-real-token"}}, + } + + with pytest.raises(HTTPException) as exc: + await config_routes.update_config(payload) + + assert exc.value.status_code == status.HTTP_403_FORBIDDEN + update.assert_not_awaited() + extract.assert_not_called() + if section == "tool_settings": + tool_check.assert_called_once_with(payload[section]) + agent_check.assert_not_awaited() + else: + agent_check.assert_awaited_once_with(payload[section]) + tool_check.assert_not_called() + + @pytest.mark.asyncio + async def test_non_group_settings_and_user_instances_do_not_enter_definition_guard( + self, monkeypatch, + ): + from flocks.agent.registry import Agent + from flocks.server.routes import config as config_routes + from flocks.tool.registry import ToolRegistry + + agent_check = AsyncMock(side_effect=AssertionError("No definition group was supplied")) + tool_check = Mock(side_effect=AssertionError("No definition group was supplied")) + update = AsyncMock() + monkeypatch.setattr(Agent, "validate_group_settings", agent_check) + monkeypatch.setattr(ToolRegistry, "validate_group_settings", tool_check) + monkeypatch.setattr(config_routes.Config, "update", update) + monkeypatch.setattr(config_routes.Config, "clear_cache", lambda: None) + monkeypatch.setattr(config_routes, "get_config", AsyncMock(return_value={"saved": True})) + payload = { + "agent": {"rex": {"temperature": 0.2}}, + "tool_settings": {"get_time": {"enabled": False}}, + "mcp": {"configured-server": {"type": "remote", "url": "http://127.0.0.1:9/unused", "group": "Personal"}}, + "api_services": {"configured_v1": {"group": None}}, + } + + assert await config_routes.update_config(payload) == {"saved": True} + + agent_check.assert_not_awaited() + tool_check.assert_not_called() + update.assert_awaited_once() + saved = update.await_args.args[0] + assert saved.agent["rex"].temperature == 0.2 + assert saved.api_services["configured_v1"]["group"] == "" + + @pytest.mark.asyncio + async def test_invalid_group_fails_before_secret_extraction(self, monkeypatch): + from flocks.server.routes import config as config_routes + from flocks.security import channel_secrets + + update = AsyncMock() + extract = Mock() + monkeypatch.setattr(config_routes.Config, "update", update) + monkeypatch.setattr(channel_secrets, "extract_channel_secrets", extract) + with pytest.raises(HTTPException) as exc: + await config_routes.update_config({ + "agent": {"custom": {"group": ["invalid"]}}, + "channels": {"slack": {"botToken": "synthetic-not-a-real-token"}}, + }) + assert exc.value.status_code == status.HTTP_400_BAD_REQUEST + update.assert_not_awaited() + extract.assert_not_called() + @pytest.mark.asyncio async def test_config_has_expected_top_level_keys(self, client: AsyncClient): """Config response contains expected top-level keys.""" diff --git a/tests/server/routes/test_skill_routes.py b/tests/server/routes/test_skill_routes.py index 2148dbf7e..9280f5a36 100644 --- a/tests/server/routes/test_skill_routes.py +++ b/tests/server/routes/test_skill_routes.py @@ -29,3 +29,257 @@ async def test_ordinary_request_keeps_bundled_skills_visible( ] assert bundled_skills assert any(skill["source"] == "project" for skill in bundled_skills) + + +@pytest.fixture +def native_group_skills(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + from unittest.mock import AsyncMock + from flocks.server.routes import skill as routes + + home = tmp_path / "home" + home.mkdir() + source = tmp_path / "source" + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("TMPDIR", str(tmp_path)) + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(Skill, "_source_root", lambda: source) + prompt_refresh = AsyncMock() + monkeypatch.setattr(routes, "_refresh_agents_for_skill_change", prompt_refresh) + user_path = home / ".flocks" / "plugins" / "skills" / "user-skill" / "SKILL.md" + builtin_path = source / ".flocks" / "plugins" / "skills" / "core-skill" / "SKILL.md" + legacy_path = source / ".flocks" / "skills" / "legacy-core" / "SKILL.md" + for path, name in ((user_path, "user-skill"), (builtin_path, "core-skill"), (legacy_path, "legacy-core")): + path.parent.mkdir(parents=True) + path.write_bytes(( + f"---\r\nname: {name}\r\ndescription: Analyze: alerts\r\ngroup: Original\r\n" + "category: system\r\nx-vendor:\r\n nested: [one, two]\r\n" + "metadata:\r\n flocks:\r\n requires:\r\n env: [TEST_SKILL_DEP]\r\n" + "---\r\n\r\n# Skill body\r\n whitespace stays \r\n\r\n" + ).encode("utf-8")) + Skill.clear_cache() + yield user_path, builtin_path, prompt_refresh + Skill.clear_cache() + + +@pytest.mark.asyncio +async def test_native_group_skill_patch_preserves_body_metadata_and_prompts(client, native_group_skills): + import yaml + + path, _, prompt_refresh = native_group_skills + before = path.read_bytes() + body = before.split(b"---\r\n", 2)[2] + for value, expected in ((" Team A ", "Team A"), (None, ""), ("", "")): + response = await client.patch("/api/skills/user-skill", json={"group": value}) + assert response.status_code == 200, response.text + assert response.json()["group"] == expected + assert response.json()["group_readonly"] is False + assert response.json()["description"] == "Analyze: alerts" + after = path.read_bytes() + assert after.split(b"---\r\n", 2)[2] == body + metadata = yaml.safe_load(after.decode().split("---", 2)[1]) + assert metadata["x-vendor"] == {"nested": ["one", "two"]} + assert metadata["metadata"]["flocks"]["requires"]["env"] == ["TEST_SKILL_DEP"] + assert metadata["group"] == expected + assert (await client.get("/api/skills/user-skill")).json()["group"] == expected + for endpoint in ("/api/skills", "/api/skills/status"): + listed = (await client.get(endpoint)).json() + assert next(item for item in listed if item["name"] == "user-skill")["group"] == expected + unchanged = path.read_bytes() + assert (await client.patch("/api/skills/user-skill", json={})).status_code == 200 + assert path.read_bytes() == unchanged + prompt_refresh.assert_not_awaited() + assert not Skill.settings_path().exists() + + +@pytest.mark.asyncio +async def test_native_group_skill_ordinary_edit_rename_keeps_unknown_frontmatter(client, native_group_skills): + path, _, _ = native_group_skills + payload = {"name": "user-skill", "description": "Edited: description", "content": "# New body\n"} + response = await client.put("/api/skills/user-skill", json=payload) + assert response.status_code == 200, response.text + assert response.json()["group"] == "Original" + renamed = await client.put("/api/skills/user-skill", json={**payload, "name": "renamed-skill"}) + assert renamed.status_code == 200, renamed.text + assert renamed.json()["group"] == "Original" + renamed_path = Path(renamed.json()["location"]) + assert not path.exists() + data = Skill._parse_frontmatter(renamed_path.read_text()) + assert data["group"] == "Original" + assert data["x-vendor"] == {"nested": ["one", "two"]} + assert data["category"] == "system" + assert (await client.get("/api/skills/renamed-skill")).json()["group"] == "Original" + cleared = await client.put("/api/skills/renamed-skill", json={**payload, "name": "renamed-skill", "group": None}) + assert cleared.json()["group"] == "" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("method,payload", [ + ("patch", {"group": "Team"}), + ("patch", {"group": None}), + ("patch", {"group": ""}), + ("put", {"name": "core-skill", "description": "Changed", "content": "# Changed"}), + ("put", {"name": "renamed-core", "description": "Changed", "content": "# Changed", "group": "Team"}), + ("put", {"name": "core-skill", "description": "Changed", "content": "# Changed", "group": None}), +]) +async def test_native_group_builtin_skill_immutable_even_for_admin(client, native_group_skills, method, payload): + user_path, builtin_path, prompt_refresh = native_group_skills + before = builtin_path.read_bytes() + response = await getattr(client, method)("/api/skills/core-skill", json=payload) + assert response.status_code == 403, response.text + assert "read-only" in response.text + assert builtin_path.read_bytes() == before + assert not (user_path.parent.parent / "core-skill").exists() + assert not (user_path.parent.parent / "renamed-core").exists() + prompt_refresh.assert_not_awaited() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("method,payload", [ + ("patch", {"group": "Team"}), + ("patch", {"group": None}), + ("patch", {"group": ""}), + ("put", {"name": "legacy-core", "description": "Changed", "content": "Changed"}), + ("put", {"name": "legacy-core", "description": "Changed", "content": "Changed", "group": "Team"}), + ("put", {"name": "renamed-legacy", "description": "Changed", "content": "Changed", "group": None}), +]) +async def test_native_group_legacy_flocks_builtin_is_readonly(client, native_group_skills, method, payload): + user_path, _, prompt_refresh = native_group_skills + skill = await Skill.get("legacy-core") + assert skill.source == "flocks" + assert skill.native is False # The discovery flag does not override built-in ownership. + path = Path(skill.location) + before = path.read_bytes() + response = await getattr(client, method)("/api/skills/legacy-core", json=payload) + assert response.status_code == 403, response.text + assert "read-only" in response.text + assert path.read_bytes() == before + assert not (user_path.parent.parent / "legacy-core").exists() + assert not (user_path.parent.parent / "renamed-legacy").exists() + prompt_refresh.assert_not_awaited() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("name", ["core-skill", "legacy-core"]) +async def test_native_group_builtin_skill_cannot_be_deleted(client, native_group_skills, name): + skill = await Skill.get(name) + path = Path(skill.location) + before = path.read_bytes() + response = await client.delete(f"/api/skills/{name}") + assert response.status_code == 403 + assert path.read_bytes() == before + + +@pytest.mark.asyncio +async def test_native_group_builtin_skill_immutable_for_regular_user(native_group_skills): + from fastapi import HTTPException + from flocks.server.routes import skill as routes + + with pytest.raises(HTTPException) as patch_error: + await routes.update_skill_metadata("core-skill", routes.SkillMetadataUpdateRequest(group="Team"), _user={"role": "user"}) + assert patch_error.value.status_code == 403 + with pytest.raises(HTTPException) as put_error: + await routes.update_skill("core-skill", routes.SkillCreateRequest(name="core-skill", description="Changed", content="Body"), _user={"role": "user"}) + assert put_error.value.status_code == 403 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("value", [1, False, [], {}, "x" * 33, "a\x00b", "a\nb", "a\x7fb"]) +async def test_native_group_skill_invalid_values_do_not_write(client, native_group_skills, value): + path, _, _ = native_group_skills + before = path.read_bytes() + response = await client.patch("/api/skills/user-skill", json={"group": value}) + assert response.status_code == 422 + assert path.read_bytes() == before + + +@pytest.mark.asyncio +@pytest.mark.parametrize("target", ["core-skill", "legacy-core"]) +@pytest.mark.parametrize("method", ["post", "put"]) +async def test_native_group_skill_cannot_shadow_builtin_name(client, native_group_skills, target, method): + user_path, _, prompt_refresh = native_group_skills + builtin = await Skill.get(target) + builtin_path = Path(builtin.location) + builtin_before, user_before = builtin_path.read_bytes(), user_path.read_bytes() + endpoint = "/api/skills" if method == "post" else "/api/skills/user-skill" + response = await getattr(client, method)(endpoint, json={ + "name": target, "description": "Replacement", "content": "Replacement body", "group": "Replacement", + }) + assert response.status_code == 403, response.text + assert builtin_path.read_bytes() == builtin_before + assert user_path.read_bytes() == user_before + assert not (user_path.parent.parent / target).exists() + assert (await Skill.get(target)).location == str(builtin_path) + prompt_refresh.assert_not_awaited() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("method", ["post", "put"]) +async def test_native_group_skill_name_collision_preserves_custom_definition(client, native_group_skills, method): + path, _, prompt_refresh = native_group_skills + other = path.parent.parent / "other-skill" / "SKILL.md" + other.parent.mkdir() + original = "---\nname: other-skill\ndescription: Other skill\ngroup: Other\n---\nOther body\n" + other.write_text(original, encoding="utf-8") + Skill.clear_cache() + before = path.read_bytes() + endpoint = "/api/skills" if method == "post" else "/api/skills/user-skill" + response = await getattr(client, method)(endpoint, json={ + "name": "other-skill", "description": "Replacement", "content": "Replacement body", + }) + assert response.status_code == 409, response.text + assert other.read_text(encoding="utf-8") == original + assert path.read_bytes() == before + prompt_refresh.assert_not_awaited() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("name", ["../core-skill", "absolute-path", "", "user-skill/../../core-skill"]) +async def test_native_group_skill_rejects_noncanonical_write_names(client, native_group_skills, name): + path, _, prompt_refresh = native_group_skills + before = path.read_bytes() + if name == "absolute-path": + name = str(path.parents[4] / "outside-skill") + response = await client.put("/api/skills/user-skill", json={ + "name": name, "description": "Changed", "content": "Changed", + }) + assert response.status_code == 422, response.text + assert path.read_bytes() == before + prompt_refresh.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_native_group_skill_unknown_lookup_and_full_put_requirements(client, native_group_skills): + assert (await client.patch("/api/skills/unknown", json={"group": "Team"})).status_code == 404 + assert (await client.put("/api/skills/user-skill", json={"group": "Team"})).status_code == 422 + + +async def test_skill_group_readonly_is_computed_and_keeps_project_definition_rule( + client, native_group_skills, tmp_path, +): + custom_project = tmp_path / ".flocks/plugins/skills/custom-project/SKILL.md" + custom_project.parent.mkdir(parents=True) + original = "---\nname: custom-project\ndescription: Project\ngroup: Project group\ngroup_readonly: false\n---\nBody\n" + custom_project.write_text(original) + Skill.clear_cache() + assert not Skill.is_system_shipped(custom_project) + for endpoint in ("/api/skills", "/api/skills/status"): + rows = {row["name"]: row for row in (await client.get(endpoint)).json()} + assert rows["user-skill"]["group_readonly"] is False + for name in ("core-skill", "legacy-core", "custom-project"): + assert rows[name]["group_readonly"] is True + detail = await client.get(f"/api/skills/{name}") + assert detail.json()["group_readonly"] is True + blocked = await client.patch("/api/skills/custom-project", json={"group": "Changed"}) + assert blocked.status_code == 403 + assert custom_project.read_text() == original + + +async def test_install_api_cannot_shadow_shipped_skill(client, native_group_skills, tmp_path): + user_path, builtin_path, _ = native_group_skills + original = builtin_path.read_bytes() + incoming = tmp_path / "replacement.md" + incoming.write_text("---\nname: core-skill\ndescription: Replacement\ngroup: Changed\n---\nReplacement\n") + result = await client.post("/api/skills/install", json={"source": str(incoming)}) + assert result.status_code == 422 and "read-only" in result.text + assert builtin_path.read_bytes() == original + assert not (user_path.parent.parent / "core-skill").exists() diff --git a/tests/server/routes/test_workflow_routes_visibility.py b/tests/server/routes/test_workflow_routes_visibility.py index 0773c329b..06b9052fc 100644 --- a/tests/server/routes/test_workflow_routes_visibility.py +++ b/tests/server/routes/test_workflow_routes_visibility.py @@ -53,3 +53,356 @@ def test_list_workflows_from_fs_skips_hidden_templates( items = workflow_routes._list_workflows_from_fs() assert [item["id"] for item in items] == ["visible"] + + +@pytest.fixture +def native_group_workflows(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + from unittest.mock import AsyncMock, Mock + from flocks.workflow import fs_store + + home = tmp_path / "home" + home.mkdir() + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("TMPDIR", str(tmp_path)) + monkeypatch.chdir(tmp_path) + project = tmp_path / "legacy-project-workflows" + user = home / ".flocks" / "plugins" / "workflows" + project.mkdir() + user.mkdir(parents=True) + roots = [(project, "project"), (user, "global")] + monkeypatch.setattr(fs_store, "resolve_workflow_scan_roots", lambda _workspace: roots) + monkeypatch.setattr(workflow_routes, "_all_scan_dirs", lambda: roots) + monkeypatch.setattr(workflow_routes, "_global_workflow_dir", lambda name: user / name) + monkeypatch.setattr(workflow_routes, "_migrate_storage_to_filesystem", AsyncMock()) + monkeypatch.setattr(workflow_routes, "_get_workflow_stats", AsyncMock(return_value={})) + monkeypatch.setattr(workflow_routes, "publish_event", AsyncMock()) + monkeypatch.setattr(workflow_routes, "run_workflow", Mock(side_effect=AssertionError("Metadata must not execute"))) + monkeypatch.setattr(workflow_routes, "_get_workflow_integration_status", AsyncMock(return_value={ + "api": {"configured": False, "state": "unconfigured"}, + "trigger": {"configured": False, "state": "unconfigured"}, + })) + return project, user + + +@pytest.mark.asyncio +async def test_native_group_workflow_draft_changes_only_meta(client, native_group_workflows): + project, _ = native_group_workflows + folder = project / "draft-id" + folder.mkdir() + markdown = b"# Draft title\r\n\r\n Do not rewrite this body \r\n" + (folder / "workflow.md").write_bytes(markdown) + (folder / "meta.json").write_text(json.dumps({"x-owner": {"keep": [1, 2]}, "hidden": False})) + for value, expected in ((" Team A ", "Team A"), (None, ""), ("", "")): + response = await client.put("/api/workflow/draft-id", json={"group": value}) + assert response.status_code == 200, response.text + assert response.json()["group"] == expected + assert response.json()["status"] == "draft" + assert response.json()["id"] == "draft-id" + assert response.json()["source"] == "project" + assert response.json()["group_readonly"] is False + assert (folder / "workflow.md").read_bytes() == markdown + assert not (folder / "workflow.json").exists() + meta = json.loads((folder / "meta.json").read_text()) + assert meta["x-owner"] == {"keep": [1, 2]} + assert meta["group"] == expected + assert (await client.get("/api/workflow/draft-id")).json()["group"] == expected + renamed = await client.put("/api/workflow/draft-id", json={"name": "Renamed draft"}) + assert renamed.status_code == 200 + assert renamed.json()["group"] == "" + assert not (folder / "workflow.json").exists() + + +@pytest.mark.asyncio +async def test_native_group_workflow_preserves_selected_source_definition_and_unknown_meta(client, native_group_workflows): + project, user = native_group_workflows + for root, group in ((project, "Project"), (user, "User")): + _write_workflow(root, "shared-id", name="Shared", meta={"group": group, "custom": {"keep": True}}) + folder = user / "shared-id" + json_before = (folder / "workflow.json").read_bytes() + response = await client.put("/api/workflow/shared-id", json={"group": " Operations "}) + assert response.status_code == 200, response.text + assert response.json()["group"] == "Operations" + assert (folder / "workflow.json").read_bytes() == json_before + assert json.loads((project / "shared-id" / "meta.json").read_text())["group"] == "Project" + definition = json.loads(json_before) + definition["metadata"] = {"group": "Must not override meta", "custom-runtime": True} + renamed = await client.put("/api/workflow/shared-id", json={"name": "Renamed", "workflowJson": definition}) + assert renamed.status_code == 200, renamed.text + assert renamed.json()["group"] == "Operations" + assert "group" not in renamed.json()["workflowJson"]["metadata"] + stored = json.loads((folder / "workflow.json").read_text()) + assert "group" not in stored["metadata"] + assert stored["metadata"]["custom-runtime"] is True + assert json.loads((folder / "meta.json").read_text())["custom"] == {"keep": True} + _write_workflow(user, "__hidden", name="Hidden", meta={"group": "Hidden", "hidden": True}) + for endpoint in ("/api/workflow", "/api/workflow-summaries"): + listed = await client.get(endpoint) + assert listed.status_code == 200, listed.text + assert [item["id"] for item in listed.json()] == ["shared-id"] + assert listed.json()[0]["group"] == "Operations" + + +@pytest.mark.asyncio +async def test_native_group_workflow_patch_does_not_materialize_or_normalize_other_metadata(client, native_group_workflows): + _, user = native_group_workflows + original = { + "id": "legacy-metadata-id", + "nameI18n": {"zh-CN": " 保留原始值 "}, + "source": "vendor-value", + "stats": {"vendor": True}, + "custom": {"keep": [1, 2]}, + } + _write_workflow(user, "partial-id", name="Derived name", meta=original) + folder = user / "partial-id" + source_before = (folder / "workflow.json").read_bytes() + response = await client.put("/api/workflow/partial-id", json={"group": "Team"}) + assert response.status_code == 200, response.text + saved = json.loads((folder / "meta.json").read_text()) + assert saved.pop("updatedAt") > 0 + assert saved == {**original, "group": "Team"} + assert (folder / "workflow.json").read_bytes() == source_before + renamed = await client.put("/api/workflow/partial-id", json={"name": "Renamed"}) + assert renamed.status_code == 200, renamed.text + saved = json.loads((folder / "meta.json").read_text()) + saved.pop("updatedAt") + assert saved == {**original, "group": "Team", "name": "Renamed"} + + +@pytest.mark.asyncio +@pytest.mark.parametrize("group", ["Case Sensitive", "", None]) +async def test_native_group_workflow_export_import_roundtrip(client, native_group_workflows, group): + _, user = native_group_workflows + _write_workflow(user, "export-id", name="Exported", meta={"group": group}) + before = (user / "export-id" / "workflow.json").read_bytes() + exported = await client.get("/api/workflow/export-id/export") + assert exported.status_code == 200, exported.text + assert exported.json()["metadata"]["group"] == (group or "") + assert (user / "export-id" / "workflow.json").read_bytes() == before + imported = await client.post("/api/workflow/import", json=exported.json()) + assert imported.status_code == 201, imported.text + data = imported.json() + assert data["group"] == (group or "") + assert "group" not in data["workflowJson"]["metadata"] + folder = user / data["id"] + assert json.loads((folder / "meta.json").read_text())["group"] == (group or "") + assert "group" not in json.loads((folder / "workflow.json").read_text())["metadata"] + assert (await client.get(f"/api/workflow/{data['id']}")).json()["group"] == (group or "") + + +@pytest.mark.asyncio +@pytest.mark.parametrize("value", [10, False, [], {}, "a" * 33, "a\x00b", "a\nb", "a\x7fb"]) +async def test_native_group_workflow_rejects_invalid_values(client, native_group_workflows, value): + _, user = native_group_workflows + _write_workflow(user, "valid-id", name="Valid") + response = await client.put("/api/workflow/valid-id", json={"group": value}) + assert response.status_code == 422 + assert not (user / "valid-id" / "meta.json").exists() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("value", ["Changed", "", None]) +@pytest.mark.parametrize("surface", ["top", "json", "both"]) +async def test_shipped_workflow_group_guards_every_edit_surface_before_writes( + client, native_group_workflows, monkeypatch, value, surface, +): + from flocks.workflow import fs_store + + project, _user = native_group_workflows + monkeypatch.setattr(fs_store, "_SYSTEM_WORKFLOW_ROOT", project) + _write_workflow(project, "system", name="System", meta={"group": "Fixed", "x-owner": {"keep": True}}) + folder = project / "system" + (folder / "workflow.md").write_bytes(b"# Keep this body\r\n") + before = {path: path.read_bytes() for path in folder.iterdir()} + payload = {"name": "Must not write", "markdownContent": "Must not write"} + if surface in {"top", "both"}: + payload["group"] = "Fixed" if surface == "both" else value + if surface in {"json", "both"}: + definition = json.loads(before[folder / "workflow.json"]) + definition["metadata"] = {"group": value, "keep": True} + payload["workflowJson"] = definition + response = await client.put("/api/workflow/system", json=payload) + assert response.status_code == 403, response.text + assert {path: path.read_bytes() for path in folder.iterdir()} == before + workflow_routes.publish_event.assert_not_called() + workflow_routes._get_workflow_stats.assert_not_called() + detail = (await client.get("/api/workflow/system")).json() + assert detail["group"] == "Fixed" + assert detail["group_readonly"] is True + + +@pytest.mark.asyncio +async def test_shipped_workflow_allows_unchanged_group_and_ordinary_graph_edits(client, native_group_workflows, monkeypatch): + from flocks.workflow import fs_store + + project, _user = native_group_workflows + monkeypatch.setattr(fs_store, "_SYSTEM_WORKFLOW_ROOT", project) + _write_workflow(project, "system", name="System", meta={"group": "Fixed", "unknown": {"keep": 1}}) + folder = project / "system" + for echo_group in (False, True): + definition = json.loads((folder / "workflow.json").read_text()) + definition["nodes"][0]["code"] = "outputs['edited'] = True" + definition["metadata"] = {"unknown-runtime": {"keep": True}} + payload = {"workflowJson": definition, "name": "Ordinary edit"} + if echo_group: + payload["group"] = "Fixed" + definition["metadata"]["group"] = "Fixed" + response = await client.put("/api/workflow/system", json=payload) + assert response.status_code == 200, response.text + assert response.json()["group"] == "Fixed" + assert response.json()["group_readonly"] is True + stored = json.loads((folder / "workflow.json").read_text()) + assert stored["nodes"][0]["code"] == "outputs['edited'] = True" + assert stored["metadata"] == {"unknown-runtime": {"keep": True}} + meta = json.loads((folder / "meta.json").read_text()) + assert meta["group"] == "Fixed" + assert meta["unknown"] == {"keep": 1} + assert "group_readonly" not in meta + for endpoint in ("/api/workflow", "/api/workflow-summaries"): + response = await client.get(endpoint) + assert response.status_code == 200, response.text + assert response.json()[0]["group_readonly"] is True + + +@pytest.mark.asyncio +async def test_user_workflow_selected_over_shipped_same_id_keeps_group_writable(client, native_group_workflows, monkeypatch): + from flocks.workflow import fs_store + + project, user = native_group_workflows + monkeypatch.setattr(fs_store, "_SYSTEM_WORKFLOW_ROOT", project) + for root in (project, user): + _write_workflow(root, "shared", name="Shared", meta={"group": "Original"}) + response = await client.put("/api/workflow/shared", json={"group": "Custom"}) + assert response.status_code == 200, response.text + assert response.json()["group_readonly"] is False + assert json.loads((project / "shared" / "meta.json").read_text())["group"] == "Original" + assert json.loads((user / "shared" / "meta.json").read_text())["group"] == "Custom" + + +@pytest.mark.asyncio +async def test_metadata_update_reads_selected_graph_and_body_only_once(client, native_group_workflows, monkeypatch): + _project, user = native_group_workflows + _write_workflow(user, "selected", name="Selected") + folder = user / "selected" + (folder / "workflow.md").write_text("# Keep\n") + read_text = Path.read_text + reads = [] + def tracked_read(path, *args, **kwargs): + reads.append(path) + return read_text(path, *args, **kwargs) + monkeypatch.setattr(Path, "read_text", tracked_read) + response = await client.put("/api/workflow/selected", json={"group": "Team"}) + assert response.status_code == 200, response.text + assert reads.count(folder / "workflow.json") == 1 + assert reads.count(folder / "workflow.md") == 1 + assert "group_readonly" not in json.loads((folder / "meta.json").read_text()) + + +@pytest.fixture +def workflow_migration_env(tmp_path, monkeypatch): + from unittest.mock import AsyncMock + from flocks.workflow.center import resolve_project_workflow_roots, resolve_global_workflow_roots + + workspace = tmp_path / "workspace" + (workspace / ".flocks").mkdir(parents=True) + home = tmp_path / "home" + home.mkdir() + monkeypatch.setenv("HOME", str(home)) + roots = [(root, "project") for root in resolve_project_workflow_roots(workspace)] + roots += [(root, "global") for root in resolve_global_workflow_roots()] + monkeypatch.setattr(workflow_routes, "_find_workspace_root", lambda: workspace) + monkeypatch.setattr(workflow_routes, "_all_scan_dirs", lambda: roots) + monkeypatch.setattr(workflow_routes.Storage, "list_keys", AsyncMock(return_value=["workflow/shared"])) + legacy = {"name": "Stale Storage", "group": "Migrated", "workflowJson": {"nodes": [], "edges": [], "start": ""}} + monkeypatch.setattr(workflow_routes.Storage, "read", AsyncMock(return_value=legacy)) + return workspace, roots, legacy + + +@pytest.mark.asyncio +@pytest.mark.parametrize("root_index", range(6)) +@pytest.mark.parametrize("filename", ["workflow.json", "workflow.md", "workflow.edit.md"]) +async def test_storage_migration_never_overwrites_any_discovered_definition(workflow_migration_env, root_index, filename): + workspace, roots, _legacy = workflow_migration_env + folder = roots[root_index][0] / "shared" + folder.mkdir(parents=True) + original = b'{"name":"Current","nodes":[]}' if filename.endswith(".json") else b"# Current draft\r\n" + (folder / filename).write_bytes(original) + (folder / "meta.json").write_bytes(b'{"group":"Current","unknown":true}') + before = {path: path.read_bytes() for path in folder.iterdir()} + await workflow_routes._migrate_storage_to_filesystem() + assert {path: path.read_bytes() for path in folder.iterdir()} == before + workflow_routes.Storage.read.assert_not_called() + destination = workspace / ".flocks" / "plugins" / "workflows" / "shared" + if destination != folder: + assert not destination.exists() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("root_index", [2, 5]) +async def test_storage_migration_rechecks_after_storage_await(workflow_migration_env, monkeypatch, root_index): + workspace, roots, legacy = workflow_migration_env + folder = roots[root_index][0] / "shared" + async def read_and_create(_key): + folder.mkdir(parents=True) + (folder / "workflow.md").write_bytes(b"# Concurrent draft\r\n") + return legacy + monkeypatch.setattr(workflow_routes.Storage, "read", read_and_create) + await workflow_routes._migrate_storage_to_filesystem() + assert (folder / "workflow.md").read_bytes() == b"# Concurrent draft\r\n" + assert not (folder / "workflow.json").exists() + destination = workspace / ".flocks" / "plugins" / "workflows" / "shared" + if destination != folder: + assert not destination.exists() + + +@pytest.mark.asyncio +async def test_storage_migration_create_only_destination_resists_concurrent_create(workflow_migration_env, monkeypatch): + workspace, _roots, _legacy = workflow_migration_env + original_write = workflow_routes._write_workflow_to_fs + destination = workspace / ".flocks" / "plugins" / "workflows" / "shared" + def concurrent_create(*args, **kwargs): + assert kwargs["target_dir"] == destination + assert kwargs["create_only"] is True + destination.mkdir(parents=True) + (destination / "workflow.json").write_bytes(b'{"name":"Concurrent"}') + (destination / "meta.json").write_bytes(b'{"group":"Keep"}') + original_write(*args, **kwargs) + monkeypatch.setattr(workflow_routes, "_write_workflow_to_fs", concurrent_create) + await workflow_routes._migrate_storage_to_filesystem() + assert (destination / "workflow.json").read_bytes() == b'{"name":"Concurrent"}' + assert (destination / "meta.json").read_bytes() == b'{"group":"Keep"}' + + +@pytest.mark.asyncio +async def test_create_only_migration_never_deletes_concurrently_created_legacy_draft(workflow_migration_env, monkeypatch): + import builtins + + workspace, _roots, _legacy = workflow_migration_env + destination = workspace / ".flocks" / "plugins" / "workflows" / "shared" + original_open = builtins.open + def create_legacy_draft(path, mode="r", *args, **kwargs): + if Path(path) == destination / "workflow.json" and mode == "x": + (destination / "workflow.edit.md").write_bytes(b"# Concurrent legacy draft\r\n") + return original_open(path, mode, *args, **kwargs) + monkeypatch.setattr(builtins, "open", create_legacy_draft) + await workflow_routes._migrate_storage_to_filesystem() + assert (destination / "workflow.edit.md").read_bytes() == b"# Concurrent legacy draft\r\n" + + +@pytest.mark.asyncio +async def test_storage_migration_uses_explicit_destination_without_update_redirect(workflow_migration_env, monkeypatch): + from unittest.mock import Mock + + workspace, _roots, legacy = workflow_migration_env + monkeypatch.setattr(workflow_routes, "_existing_workflow_dir", Mock(side_effect=AssertionError("No update redirect"))) + await workflow_routes._migrate_storage_to_filesystem() + destination = workspace / ".flocks" / "plugins" / "workflows" / "shared" + assert json.loads((destination / "workflow.json").read_text()) == legacy["workflowJson"] + assert json.loads((destination / "meta.json").read_text())["group"] == "Migrated" + + +@pytest.mark.asyncio +async def test_native_group_workflow_unknown_lookup_does_not_create(client, native_group_workflows): + _, user = native_group_workflows + response = await client.put("/api/workflow/unknown", json={"group": "Team"}) + assert response.status_code == 404 + assert not (user / "unknown").exists() diff --git a/tests/server/routes/test_workflow_run_route.py b/tests/server/routes/test_workflow_run_route.py index 69c213656..01ed433fa 100644 --- a/tests/server/routes/test_workflow_run_route.py +++ b/tests/server/routes/test_workflow_run_route.py @@ -1,4 +1,5 @@ import asyncio +from pathlib import Path from types import SimpleNamespace from unittest.mock import AsyncMock, Mock @@ -212,7 +213,10 @@ async def test_update_workflow_rejects_unmapped_edges_when_strict( "editMarkdownContent": None, } - monkeypatch.setattr(workflow_module, "_read_workflow_from_fs", lambda _workflow_id: dict(existing)) + monkeypatch.setattr( + workflow_module, "resolve_workflow_from_fs", + lambda _workflow_id: (Path("unused"), {**existing, "group_readonly": False}), + ) monkeypatch.setattr(workflow_module, "_write_workflow_to_fs", write_workflow) req = workflow_module.WorkflowUpdateRequest( diff --git a/tests/server/test_tool_setting_routes.py b/tests/server/test_tool_setting_routes.py index 8ee244019..4d6d8f9f1 100644 --- a/tests/server/test_tool_setting_routes.py +++ b/tests/server/test_tool_setting_routes.py @@ -22,6 +22,7 @@ from flocks.auth.context import AuthUser from flocks.server.auth import require_admin +from flocks.tool.code.lsp_tool import lsp_tool from flocks.tool.registry import ( Tool, ToolCategory, @@ -162,6 +163,206 @@ def test_tool_mutation_routes_require_admin(): assert [response.status_code for response in responses] == [403, 403, 403, 403] +class TestNativeToolGroup: + def test_editor_and_reload_cannot_shadow_selected_core_tool(self, tool_client, tmp_path, monkeypatch): + import yaml + from flocks.tool import tool_loader + + client, original, _ = tool_client + core = Tool(ToolInfo(name=original.info.name, description="Core", native=False, group="Canonical"), lsp_tool) + ToolRegistry.register(core) + shadow = tmp_path / "shadow.yaml" + shadow.write_text(yaml.safe_dump({ + "name": core.info.name, "description": "Shadow", "group": "User", + "handler": {"type": "http", "url": "https://example.invalid"}, + })) + before = shadow.read_bytes() + monkeypatch.setattr(tool_loader, "_find_yaml_file", lambda name: shadow) + response = client.put(f"/api/tools/{core.info.name}", json={"description": "changed"}) + assert response.status_code == 400, response.text + response = client.post(f"/api/tools/{core.info.name}/reload") + assert response.status_code == 400, response.text + assert ToolRegistry._tools[core.info.name] is core + assert shadow.read_bytes() == before + + def test_removing_group_override_restores_registration_default(self, tool_client): + from flocks.config.config_writer import ConfigWriter + from flocks.server.routes.tool import _invalidate_tool_summary_cache + + client, tool, _ = tool_client + tool.info.group = "Package" + response = client.patch(f"/api/tools/{tool.info.name}", json={"group": "User"}) + assert response.json()["group"] == "User" + assert tool.info.group == "Package" + ConfigWriter.delete_tool_setting(tool.info.name, field="group") + ToolRegistry._apply_tool_settings() + _invalidate_tool_summary_cache() + assert client.get(f"/api/tools/{tool.info.name}").json()["group"] == "Package" + rows = client.get("/api/tools/page").json()["items"] + assert next(row for row in rows if row["name"] == tool.info.name)["group"] == "Package" + + @pytest.mark.parametrize("group", [None, "", "Override"]) + def test_shipped_group_rejection_precedes_enabled_and_yaml_changes(self, tool_client, tmp_path, monkeypatch, group): + import yaml + from flocks.config.config_writer import ConfigWriter + from flocks.tool import registry, tool_loader + + client, original, _ = tool_client + installation = tmp_path / "installation" + monkeypatch.setattr(registry, "__file__", str(installation / "flocks/tool/registry.py")) + path = installation / ".flocks/plugins/tools/api/source.yaml" + path.parent.mkdir(parents=True) + raw = {"name": original.info.name, "description": "before", "group": "Canonical", "enabled": True, + "handler": {"type": "http", "url": "https://example.invalid"}} + path.write_text(yaml.safe_dump(raw)) + tool = tool_loader.yaml_to_tool(raw, path) + ToolRegistry.register(tool) + config = ConfigWriter._read_raw() + config["tool_settings"] = {tool.info.name: {"group": "Stale"}} + ConfigWriter._write_raw(config) + before_config = ConfigWriter._get_config_path().read_bytes() + before_yaml = path.read_bytes() + response = client.patch(f"/api/tools/{tool.info.name}", json={"group": group, "enabled": False}) + assert response.status_code == 400, response.text + assert ConfigWriter._get_config_path().read_bytes() == before_config + assert tool.info.enabled is True + response = client.put(f"/api/tools/{tool.info.name}", json={"group": group, "description": "changed"}) + assert response.status_code == 400, response.text + assert path.read_bytes() == before_yaml + assert ConfigWriter._get_config_path().read_bytes() == before_config + response = client.get(f"/api/tools/{tool.info.name}") + assert response.json()["group"] == "Canonical" + assert response.json()["group_readonly"] is True + rows = client.get("/api/tools/page").json()["items"] + row = next(row for row in rows if row["name"] == tool.info.name) + assert row["group"] == "Canonical" and row["group_readonly"] is True + # Same fixed value is accepted without changing the stale saved overlay. + response = client.patch(f"/api/tools/{tool.info.name}", json={"group": "Canonical"}) + assert response.status_code == 200, response.text + assert ConfigWriter._get_config_path().read_bytes() == before_config + + def test_group_only_clear_and_enabled_reset_preserve_metadata(self, tool_client, monkeypatch): + from flocks.config.config_writer import ConfigWriter + + client, tool, _ = tool_client + _set_service(enabled=True) + monkeypatch.setattr(ToolRegistry, "refresh_plugin_tools", lambda *a, **k: pytest.fail("metadata must not rescan")) + name = tool.info.name + tool.info.group = "Pack default" + ConfigWriter.set_tool_setting(name, {"future_metadata": {"kept": True}}) + response = client.patch(f"/api/tools/{name}", json={"group": " Operations "}) + assert response.status_code == 200, response.text + assert response.json()["group"] == "Operations" + assert response.json()["enabled"] is True + assert response.json()["enabled_customized"] is False + assert tool.info.group == "Pack default" + + client.patch(f"/api/tools/{name}", json={"enabled": False}) + assert _read_settings()[name]["group"] == "Operations" + reset = client.post(f"/api/tools/{name}/reset") + assert reset.json()["enabled_customized"] is False + assert _read_settings()[name] == {"group": "Operations", "future_metadata": {"kept": True}} + client.patch(f"/api/tools/{name}", json={"enabled": False}) + client.patch(f"/api/tools/{name}", json={"enabled": True}) + assert _read_settings()[name]["group"] == "Operations" + assert "enabled" not in _read_settings()[name] + + for clear in (None, "", " "): + response = client.patch(f"/api/tools/{name}", json={"group": clear}) + assert response.status_code == 200, response.text + assert response.json()["group"] == "" + assert _read_settings()[name]["group"] == "" + assert _read_settings()[name]["future_metadata"] == {"kept": True} + + def test_yaml_editor_preserves_effective_group_and_only_changes_metadata(self, tool_client, tmp_path, monkeypatch): + import yaml + from flocks.tool import tool_loader + + client, tool, _ = tool_client + _set_service(enabled=True) + path = tmp_path / "native.yaml" + data = { + "name": tool.info.name, "description": "before", "group": "Package", + "handler": {"type": "http", "url": "https://example.invalid", "method": "GET"}, + "future_metadata": {"kept": True}, + } + path.write_text(yaml.safe_dump(data)) + monkeypatch.setattr(tool_loader, "_find_yaml_file", lambda name: path) + client.patch(f"/api/tools/{tool.info.name}", json={"group": "User", "enabled": False}) + response = client.put(f"/api/tools/{tool.info.name}", json={"description": "after"}) + assert response.status_code == 200, response.text + assert response.json()["group"] == "User" + assert yaml.safe_load(path.read_text())["group"] == "Package" + live = ToolRegistry.get(tool.info.name) + monkeypatch.setattr(tool_loader, "yaml_to_tool", lambda *args: pytest.fail("metadata-only must not reload handler")) + cleared = client.put(f"/api/tools/{tool.info.name}", json={"group": None}) + assert cleared.status_code == 200, cleared.text + assert cleared.json()["group"] == "" + assert cleared.json()["enabled"] is False + assert ToolRegistry.get(tool.info.name) is live + saved = yaml.safe_load(path.read_text()) + assert saved["group"] == "" + assert saved["future_metadata"] == {"kept": True} + assert saved["handler"] == data["handler"] + assert _read_settings()[tool.info.name]["group"] == "" + + def test_auto_disable_keeps_group(self, tool_client): + client, tool, _ = tool_client + _set_service(enabled=True) + client.patch(f"/api/tools/{tool.info.name}", json={"group": "Operations"}) + for _ in range(ToolRegistry._failure_disable_threshold): + ToolRegistry._record_failure(tool, {}, "upstream failure") + assert _read_settings()[tool.info.name] == {"group": "Operations", "enabled": False} + + def test_filter_before_paging_and_full_query_group_facets(self, tool_client): + from flocks.config.config_writer import ConfigWriter + + client, first, second = tool_client + _set_service(enabled=True) + # Prime the existing index, then update metadata without reloading tools. + client.get("/api/tools/page") + client.patch(f"/api/tools/{first.info.name}", json={"group": "A, B"}) + for idx in range(3): + tool = _stub_api_tool(f"matching_{idx}", enabled=True) + tool.info.group = "A, B" if idx < 2 else "Elsewhere" + ToolRegistry.register(tool) + response = client.get("/api/tools/page", params={"group": " A, B ", "limit": 1, "offset": 1, "sort_by": "name"}) + body = response.json() + assert response.status_code == 200, body + assert body["total"] == 3 + assert len(body["items"]) == 1 + assert body["items"][0]["group"] == "A, B" + assert body["facets"]["group"] == {"A, B": 3, "Elsewhere": 1, "": 1} + assert body["facets"]["source_groups"] == {"api": 1} + assert body["facets"]["category"] == {"custom": 3} + assert body["facets"]["source"] == {"api": 3} + assert body["facets"]["source_name"] == {_TEST_SERVICE_ID: 3} + assert body["facets"]["enabled"] == {"true": 3} + ungrouped = client.get("/api/tools/page", params={"group": ""}).json() + assert [row["name"] for row in ungrouped["items"]] == [second.info.name] + searched = client.get("/api/tools/page", params={"q": "matching", "group": "A, B", "limit": 1}).json() + assert searched["total"] == 2 + assert searched["facets"]["group"] == {"A, B": 2, "Elsewhere": 1} + assert len(client.get("/api/tools").json()) == 5 + assert client.get(f"/api/tools/{first.info.name}").json()["group"] == "A, B" + # A write from a different worker must invalidate the original summary cache too. + ConfigWriter.set_tool_setting(first.info.name, {"group": "Elsewhere"}) + assert client.get("/api/tools/page", params={"group": "A, B"}).json()["total"] == 2 + + @pytest.mark.parametrize("group", [42, [], {}, "x" * 33, "a\x00b", "a\x7fb"]) + def test_invalid_group_rejected_without_mutation(self, tool_client, group): + client, tool, _ = tool_client + response = client.patch(f"/api/tools/{tool.info.name}", json={"group": group}) + assert response.status_code == 422 + assert _read_settings() == {} + + def test_group_uses_original_admin_guard_and_not_device_override(self, tool_client): + client, tool, _ = tool_client + assert _viewer_client().patch(f"/api/tools/{tool.info.name}", json={"group": "G"}).status_code == 403 + assert client.patch(f"/api/tools/{tool.info.name}?device_id=example", json={"group": "G"}).status_code == 400 + assert _read_settings() == {} + + class TestToolInfoResponse: def test_lists_factory_default_and_no_setting_initially(self, tool_client): client, _, disabled_tool = tool_client diff --git a/tests/skill/test_installer.py b/tests/skill/test_installer.py index 6a806d6b1..3d8b77a3c 100644 --- a/tests/skill/test_installer.py +++ b/tests/skill/test_installer.py @@ -38,8 +38,14 @@ def clear_skill_cache(): @pytest.fixture -def tmp_skills_dir(tmp_path: Path): - """Temp directory to serve as the user skills root.""" +def tmp_skills_dir(tmp_path: Path, monkeypatch): + """Keep install/discovery tests independent of actual bundled skill names.""" + from flocks.project.instance import Instance + + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setattr(Skill, "_source_root", lambda: tmp_path / "source") + monkeypatch.setattr(Instance, "get_directory", lambda: str(tmp_path / "workspace")) + monkeypatch.setattr(Instance, "get_worktree", lambda: str(tmp_path / "workspace")) d = tmp_path / ".flocks" / "plugins" / "skills" d.mkdir(parents=True) return d @@ -980,3 +986,132 @@ def test_download_kind_returns_none(self): spec = SkillInstallSpec(kind="download", url="https://example.com/tool") cmd = SkillInstaller._build_install_command(spec) assert cmd is None + + +INSTALL_GROUP_FAMILIES = [ + "skills-sh", "safeskill", "clawhub", "github-zip", "contents-api", "github-raw", "url", "local", "raw", +] + + +async def _install_group_package(family, content, tmp_path, monkeypatch, *, scope="global"): + """Exercise each real replacement boundary with offline download responses.""" + import httpx + + def response(text="", *, payload=None, data=None): + return MagicMock(status_code=200, text=text, content=payload or text.encode(), headers={}, json=lambda: data) + + if family in {"skills-sh", "safeskill"}: + root = ".agents/skills" if family == "skills-sh" else ".flocks/plugins/skills" + staged = tmp_path / "staging" / root / "group-demo" + staged.mkdir(parents=True, exist_ok=True) + (staged / "SKILL.md").write_bytes(content.encode()) + (staged / "asset.txt").write_text("New asset") + try: + imported = SkillInstaller._import_staged_skill_dirs(tmp_path / "staging", scope) + return SkillInstallResult(success=bool(imported)) + except ValueError as exc: + return SkillInstallResult(success=False, error=str(exc)) + if family == "local": + source = tmp_path / "incoming.md" + source.write_bytes(content.encode()) + return await SkillInstaller._install_from_local(str(source), scope) + if family == "raw": + return SkillInstaller._save_skill_content(content, scope) + + client = AsyncMock() + client.__aenter__.return_value = client + monkeypatch.setattr(httpx, "AsyncClient", lambda **_kwargs: client) + if family in {"clawhub", "github-zip"}: + archive = io.BytesIO() + prefix = "repository-main/skills/group-demo/" if family == "github-zip" else "" + with zipfile.ZipFile(archive, "w") as zf: + zf.writestr(prefix + "SKILL.md", content) + zf.writestr(prefix + "asset.txt", "New asset") + if family == "clawhub": + client.get.return_value = response(payload=archive.getvalue()) + return await SkillInstaller._install_from_clawhub("group-demo", scope) + with zipfile.ZipFile(io.BytesIO(archive.getvalue())) as zf: + return SkillInstaller._import_skill_from_github_zip( + zf, "owner", "repository", "main", ["skills/group-demo"], "group-demo", scope, + ) + if family == "contents-api": + entries = [ + {"type": "file", "name": "SKILL.md", "download_url": "https://offline.invalid/skill"}, + {"type": "file", "name": "asset.txt", "download_url": "https://offline.invalid/asset"}, + ] + async def get(url): + if "api.github.com" in url: + return response(data=entries) + return response(content if url.endswith("/skill") else "New asset") + client.get.side_effect = get + return await SkillInstaller._download_github_dir(client, "owner", "repository", "main", "", scope) + client.get.return_value = response(content) + if family == "github-raw": + return await SkillInstaller._download_github_skill_md_raw(client, "owner", "repository", "main", "", scope) + return await SkillInstaller._install_from_url("https://offline.invalid/skill", scope) + + +@pytest.mark.parametrize("family", INSTALL_GROUP_FAMILIES) +@pytest.mark.parametrize("old_metadata", [{"group": "Personal"}, {"group": ""}, {"group": None}, {}, None]) +async def test_all_installer_families_preserve_explicit_groups_before_replacement( + tmp_path, tmp_skills_dir, monkeypatch, family, old_metadata, +): + import yaml + + destination = tmp_skills_dir / "group-demo" / "SKILL.md" + if old_metadata is not None: + destination.parent.mkdir() + destination.write_text("---\n" + yaml.safe_dump({"name": "group-demo", "description": "Old", **old_metadata}) + "---\nOld body\n") + incoming = ( + "---\r\nname: group-demo\r\ndescription: Analyze: alerts\r\ngroup: Package\r\n" + "metadata:\r\n extra:\r\n values: [1, 2]\r\n---\r\n\r\nNew body \r\n" + ) + result = await _install_group_package(family, incoming, tmp_path, monkeypatch) + assert result.success, result.error + saved = destination.read_bytes().decode() + metadata = Skill._parse_frontmatter(saved) + expected = (old_metadata["group"] or "") if old_metadata is not None and "group" in old_metadata else "Package" + assert metadata["group"] == expected + assert metadata["description"] == "Analyze: alerts" + assert metadata["metadata"] == {"extra": {"values": [1, 2]}} + assert saved.endswith("---\r\n\r\nNew body \r\n") + assert "\n" not in saved.replace("\r\n", "") + + +@pytest.mark.parametrize("family", INSTALL_GROUP_FAMILIES) +@pytest.mark.parametrize("old_header", ["group: [not, scalar]\n", "metadata: [unclosed\n"]) +async def test_all_installer_families_fail_before_overwriting_unrecoverable_metadata( + tmp_path, tmp_skills_dir, monkeypatch, family, old_header, +): + destination = tmp_skills_dir / "group-demo" / "SKILL.md" + destination.parent.mkdir() + original = f"---\nname: group-demo\ndescription: Old\n{old_header}---\nOld body\n".encode() + destination.write_bytes(original) + asset = destination.parent / "asset.txt" + asset.write_text("Original asset") + incoming = "---\nname: group-demo\ndescription: New\ngroup: Package\n---\nNew body\n" + result = await _install_group_package(family, incoming, tmp_path, monkeypatch) + assert not result.success + assert destination.read_bytes() == original + assert asset.read_text() == "Original asset" + + +@pytest.mark.parametrize("family", INSTALL_GROUP_FAMILIES) +@pytest.mark.parametrize("protected_source", ["shipped", "project", "flocks"]) +async def test_all_installer_families_cannot_shadow_readonly_skills( + tmp_path, tmp_skills_dir, monkeypatch, family, protected_source, +): + roots = { + "shipped": tmp_path / "source/.flocks/plugins/skills", + "project": tmp_path / "workspace/.flocks/plugins/skills", + "flocks": tmp_path / ".flocks/skills", + } + protected = roots[protected_source] / "group-demo" / "SKILL.md" + protected.parent.mkdir(parents=True) + original = "---\nname: group-demo\ndescription: Original\ngroup: Fixed\n---\nOld body\n" + protected.write_text(original) + incoming = "---\nname: group-demo\ndescription: Replacement\ngroup: Replacement\n---\nNew body\n" + result = await _install_group_package(family, incoming, tmp_path, monkeypatch) + assert not result.success and "read-only" in result.error + assert protected.read_text() == original + assert not (tmp_skills_dir / "group-demo").exists() diff --git a/tests/skill/test_skill.py b/tests/skill/test_skill.py index 85aa87596..3f7778c07 100644 --- a/tests/skill/test_skill.py +++ b/tests/skill/test_skill.py @@ -704,15 +704,34 @@ def test_parse_frontmatter_unclosed_returns_empty(): assert data == {} -def test_parse_frontmatter_yaml_failure_fallback(): - """When yaml.safe_load fails, falls back to simple key:value parser.""" - # Force yaml.safe_load to raise an exception - with patch("yaml.safe_load", side_effect=Exception("yaml error")): - content = "---\nname: fallback-skill\ndescription: Fallback test\n---\n" - data = Skill._parse_frontmatter(content) - - assert data.get("name") == "fallback-skill" - assert data.get("description") == "Fallback test" +def test_parse_frontmatter_recovers_legacy_flat_scalar_without_flattening_metadata(): + content = ( + "---\r\nname: fallback-skill\r\ndescription: Analyze: alerts\r\n" + "metadata:\r\n unknown:\r\n values: [one, two]\r\n---\r\nBody \r\n" + ) + data = Skill._parse_frontmatter(content) + assert data["description"] == "Analyze: alerts" + assert data["metadata"] == {"unknown": {"values": ["one", "two"]}} + rendered = Skill.render_frontmatter(content, {"group": "Ops"}) + assert Skill._parse_frontmatter(rendered) == {**data, "group": "Ops"} + assert rendered.endswith("---\r\nBody \r\n") + assert "\n" not in rendered.replace("\r\n", "") + + +@pytest.mark.parametrize("header", [ + "name: broken\ndescription: Analyze: alerts\nmetadata: [unclosed\n", + "name: broken\ndescription: [unclosed\n", + "name: broken\ndescription: Analyze: alerts\nmetadata:\n nested: Invalid: value\n", + "- name: not-a-mapping\n", +]) +def test_frontmatter_unrecoverable_write_keeps_original_bytes(tmp_path, header): + path = tmp_path / "SKILL.md" + original = f"---\n{header}---\nOriginal body\n".encode() + path.write_bytes(original) + assert Skill._parse_frontmatter(original.decode()) == {} + with pytest.raises(ValueError): + Skill.update_frontmatter(path, {"group": "Ops"}) + assert path.read_bytes() == original # ============================================================================= diff --git a/tests/tool/test_api_service_schema.py b/tests/tool/test_api_service_schema.py index a56b1e6fd..2d528eb2f 100644 --- a/tests/tool/test_api_service_schema.py +++ b/tests/tool/test_api_service_schema.py @@ -1,6 +1,19 @@ from flocks.tool.schema.api_service_schema import _build_api_service_credential_schema +def test_native_group_never_enters_credential_schema() -> None: + fields = _build_api_service_credential_schema( + "example_api", + {"group": "Operations", "credential_fields": [ + {"key": "group", "storage": "config", "config_key": "group"}, + {"key": "alias", "storage": "secret", "config_key": "group"}, + {"key": "username", "storage": "config", "config_key": "username"}, + ]}, + ) + assert [field.key for field in fields] == ["username"] + assert _build_api_service_credential_schema("example_api", {"group": "Operations"}) == [] + + def test_credential_schema_preserves_internal_fields() -> None: fields = _build_api_service_credential_schema( "webcli_device", diff --git a/tests/tool/test_apply_tool_settings.py b/tests/tool/test_apply_tool_settings.py index 23f303d56..c0a8c6231 100644 --- a/tests/tool/test_apply_tool_settings.py +++ b/tests/tool/test_apply_tool_settings.py @@ -6,6 +6,10 @@ import pytest +# Import before fixture snapshots so registration side effects cannot be lost +# while the modules remain cached for later built-in registry tests. +from flocks.tool.code.lsp_tool import lsp_tool +from flocks.tool.wecom.wecom_mcp import wecom_mcp from flocks.tool.registry import ( Tool, ToolCategory, @@ -54,6 +58,11 @@ def isolated_registry(monkeypatch): so tests exercising the unregister paths can't leak names into the real registry when the test process later runs unrelated tests. """ + from flocks.config import api_versioning + + # Fake providers in this unit suite must not resolve through real project + # descriptors simply because pytest was launched from the repository root. + monkeypatch.setattr(api_versioning, "discover_api_service_descriptors", lambda **kwargs: []) saved_tools = dict(ToolRegistry._tools) saved_defaults = dict(ToolRegistry._enabled_defaults) saved_plugin_names = list(ToolRegistry._plugin_tool_names) @@ -78,6 +87,171 @@ def _set_api_service(name: str, *, enabled: bool) -> None: }) +def test_native_group_declarative_python_registration(isolated_registry, monkeypatch, tmp_path): + from flocks.plugin import PluginLoader + + extensions = [] + monkeypatch.setattr(PluginLoader, "register_extension_point", extensions.append) + ToolRegistry._register_plugin_extension_point() + definition = { + "name": "declarative_group_tool", "description": "Python definition", + "group": " Package ", "handler": lambda ctx: ToolResult(success=True), + "parameters": [{"name": "value", "type": "string", "description": "Runtime value"}], + } + assert extensions[0].consumer([definition], str(tmp_path / "tool.py")) == [] + info = ToolRegistry._tools[definition["name"]].info + assert info.group == "Package" + assert info.source == "plugin_py" + assert list(info.get_schema().properties) == ["value"] + assert definition["group"] == " Package " + + +def test_native_group_python_registration_and_overlay(temp_config, isolated_registry): + from flocks.config.config_writer import ConfigWriter + + @ToolRegistry.register_function(name="group_python_tool", description="test", group=" Package ") + async def group_python_tool(ctx): + return ToolResult(success=True) + + tool = ToolRegistry._tools["group_python_tool"] + assert tool.info.group == "Package" + assert "group" not in tool.info.get_schema().properties + ConfigWriter.set_tool_setting(tool.info.name, {"group": "User"}) + ToolRegistry._apply_tool_settings() + assert tool.info.group == "Package" + assert ConfigWriter.get_tool_setting(tool.info.name)["group"] == "User" + assert tool.info.enabled is True + ConfigWriter.set_tool_setting(tool.info.name, {"group": ""}) + ToolRegistry._apply_tool_settings() + assert tool.info.group == "Package" + assert ConfigWriter.get_tool_setting(tool.info.name)["group"] == "" + ConfigWriter.delete_tool_setting(tool.info.name, field="group") + ToolRegistry._apply_tool_settings() + assert tool.info.group == "Package" + assert tool.info.enabled is True + + +@pytest.mark.parametrize("name", ["get_time", "lsp", "task", "list_providers", "add_provider", "add_model", "wecom_mcp"]) +def test_group_preflight_is_cold_and_source_based(name, isolated_registry, monkeypatch): + monkeypatch.setattr(ToolRegistry, "init", lambda: pytest.fail("must not initialize registry")) + from flocks.config.config_writer import ConfigWriter + monkeypatch.setattr(ConfigWriter, "_read_raw", lambda: pytest.fail("must not read config")) + readonly, group = ToolRegistry._unloaded_definition_group(name) + assert readonly and group + ToolRegistry.validate_group_settings({name: {"group": group}}) + for clear_or_change in (None, "", "Override"): + with pytest.raises(ValueError, match="read-only"): + ToolRegistry.validate_group_settings({name: {"group": clear_or_change, "enabled": False}}) + # The actual registered definition wins over a core name or native=True. + custom = _stub_tool(name, enabled=True, native=True) + ToolRegistry.register(custom) + assert custom.info.group_readonly is False + ToolRegistry.validate_group_settings({name: {"group": "Custom"}}) + + +def test_create_cannot_shadow_a_cold_core_definition(tmp_path, isolated_registry, monkeypatch): + from flocks.tool import tool_loader + + monkeypatch.setattr(tool_loader, "_TOOLS_SUBDIR", tmp_path / "tools") + monkeypatch.setattr(tool_loader, "_yaml_tool_search_roots", lambda: [tmp_path / "tools"]) + monkeypatch.setattr(ToolRegistry, "init", lambda: pytest.fail("must not initialize registry")) + raw = {"name": "get_time", "group": "User", "handler": {"type": "http", "url": "https://example.invalid"}} + with pytest.raises(ValueError, match="Cannot shadow system"): + tool_loader.create_yaml_tool(raw) + assert not (tmp_path / "tools").exists() + + +def test_enabled_preflight_does_not_discover_any_definitions(isolated_registry, monkeypatch): + monkeypatch.setattr(ToolRegistry, "_unloaded_definition_group", lambda name: pytest.fail("no discovery")) + ToolRegistry.validate_group_settings({"read": {"enabled": False}, "unknown": {"enabled": True}}) + + +def test_preimported_core_keeps_search_flags_and_readonly(isolated_registry, monkeypatch): + import importlib + + lsp = Tool(ToolInfo(name="lsp", description="LSP", native=False, group="代码分析"), lsp_tool) + wecom = Tool(ToolInfo(name="wecom_mcp", description="WeCom", category=ToolCategory.CUSTOM, group="企业协作"), wecom_mcp) + ToolRegistry.register(lsp) + ToolRegistry.register(wecom) + # Simulate modules already imported before the initialization pass; no new + # registration delta exists, so provenance must be checked on existing tools. + monkeypatch.setattr(importlib, "import_module", lambda name: None) + ToolRegistry._register_builtin_tools() + assert lsp.info.native is False and lsp.info.group_readonly is True + assert wecom.info.category == ToolCategory.CUSTOM + assert wecom.info.group_readonly is True + assert ToolRegistry._tools["get_time"].info.group == "系统管理" + assert ToolRegistry._tools["get_time"].info.group_readonly is True + + +def test_shipped_yaml_group_guards_source_update_and_direct_reload(tmp_path, monkeypatch, isolated_registry): + import yaml + from flocks.tool import registry, tool_loader + + installation = tmp_path / "installation" + monkeypatch.setattr(registry, "__file__", str(installation / "flocks" / "tool" / "registry.py")) + path = installation / ".flocks/plugins/tools/api/provider/tool.yaml" + path.parent.mkdir(parents=True) + raw = { + "name": "declared_name", "description": "before", "group": "Canonical", + "handler": {"type": "http", "url": "https://example.invalid"}, + } + path.write_text(yaml.safe_dump(raw), encoding="utf-8") + tool = tool_loader.yaml_to_tool(raw, path) + assert tool.info.group_readonly is True + ToolRegistry.register(tool) + assert tool_loader.find_yaml_tool("declared_name") == path + before = path.read_bytes() + for group in (None, "", "Changed"): + with pytest.raises(ValueError, match="read-only"): + tool_loader.update_yaml_tool("declared_name", {"group": group, "description": "after"}) + assert path.read_bytes() == before + assert tool_loader.update_yaml_tool("declared_name", {"group": "Canonical", "description": "after"}) + reloaded = tool_loader.yaml_to_tool(yaml.safe_load(path.read_text()), path) + assert reloaded.info.group_readonly is True + assert reloaded.info.group == "Canonical" + + custom_path = tmp_path / "project/.flocks/plugins/tools/custom.yaml" + custom_path.parent.mkdir(parents=True) + custom_path.write_text(yaml.safe_dump(raw), encoding="utf-8") + custom = tool_loader.yaml_to_tool(raw, custom_path) + custom.info.native = True + ToolRegistry.register(custom) + assert custom.info.group_readonly is False + assert tool_loader.update_yaml_tool("declared_name", {"group": None}) + assert yaml.safe_load(custom_path.read_text())["group"] == "" + assert yaml.safe_load(path.read_text())["group"] == "Canonical" + + symlink = path.parent / "user_symlink.yaml" + symlink.symlink_to(custom_path) + assert tool_loader.yaml_to_tool(raw, symlink).info.group_readonly is False + + +def test_native_group_yaml_default_and_editor_preservation(tmp_path, monkeypatch): + import yaml + from flocks.tool import tool_loader + + path = tmp_path / "standalone.yaml" + raw = { + "name": "yaml_group_tool", "description": "before", "group": " Package ", + "unknown": {"keep": True}, + "handler": {"type": "http", "url": "https://example.invalid", "method": "GET"}, + } + path.write_text(yaml.safe_dump(raw), encoding="utf-8") + monkeypatch.setattr(tool_loader, "_find_yaml_file", lambda name: path) + tool = tool_loader.yaml_to_tool(raw, path) + assert tool.info.group == "Package" + assert tool_loader.update_yaml_tool(raw["name"], {"description": "after"}) + saved = yaml.safe_load(path.read_text(encoding="utf-8")) + assert saved["group"] == " Package " + assert saved["unknown"] == {"keep": True} + assert tool_loader.update_yaml_tool(raw["name"], {"group": None}) + saved = yaml.safe_load(path.read_text(encoding="utf-8")) + assert saved["group"] == "" + assert saved["handler"] == raw["handler"] + assert tool_loader.yaml_to_tool(saved, path).info.group == "" + + def test_apply_tool_settings_enables_disabled_tool(temp_config, isolated_registry): from flocks.config.config_writer import ConfigWriter diff --git a/tests/tool/test_device_group_attribute.py b/tests/tool/test_device_group_attribute.py new file mode 100644 index 000000000..0b0df9c1b --- /dev/null +++ b/tests/tool/test_device_group_attribute.py @@ -0,0 +1,152 @@ +"""Business grouping is an attribute of a device, never its room or credentials.""" + +import json +from unittest.mock import AsyncMock + +import pytest +import pytest_asyncio +from pydantic import ValidationError + +from flocks.storage.storage import Storage +from flocks.tool.device import intake, store +from flocks.tool.device.models import ( + DEFAULT_GROUP_ID, + DeviceIntegrationCreate, + DeviceIntegrationUpdate, +) + + +@pytest_asyncio.fixture +async def devices(tmp_path, monkeypatch): + Storage._initialized = False + Storage._init_pid = None + Storage._db_path = None + await Storage.init(tmp_path / "native-devices.db") + await store.ensure_default_group() + sync = AsyncMock() + monkeypatch.setattr(intake, "sync_service_tool_state", sync) + monkeypatch.setattr(intake, "_forget_auto_instance_ignore", AsyncMock()) + monkeypatch.setattr(intake, "storage_key_to_service_id", lambda key: "example") + monkeypatch.setattr(store, "storage_key_to_service_id", lambda key: "example") + monkeypatch.setattr(intake, "persist_fields", lambda device_id, storage_key, fields, **kwargs: dict(fields)) + yield sync + await Storage.shutdown() + Storage._initialized = False + Storage._init_pid = None + Storage._db_path = None + + +async def make_device(name="Device", group=None): + return await intake.create_device(DeviceIntegrationCreate( + name=name, storage_key="example_v1", group=group, + enabled=False, verify_ssl=True, + fields={"base_url": "https://device.invalid", "api_key": "test-secret"}, + )) + + +@pytest.mark.asyncio +async def test_group_is_native_record_attribute_and_not_room(devices): + device = await make_device(group=" 日常巡检 ") + assert device.group == "日常巡检" + assert device.group_id == DEFAULT_GROUP_ID + row = await store.fetch_device(device.id) + assert row["plugin_group"] == "日常巡检" + assert "group" not in json.loads(row["fields"]) + assert "plugin_group" not in json.loads(row["fields"]) + assert (await store.list_devices())[0].group == "日常巡检" + + +@pytest.mark.asyncio +async def test_group_only_update_does_not_rewrite_configuration_or_sync(devices): + device = await make_device(group="Before") + before = dict(await store.fetch_device(device.id)) + devices.reset_mock() + updated = await intake.update_device(device.id, DeviceIntegrationUpdate(group=" After ")) + after = dict(await store.fetch_device(device.id)) + assert updated.group == "After" + assert devices.await_count == 0 + unchanged = set(before) - {"plugin_group", "updated_at"} + assert {key: before[key] for key in unchanged} == {key: after[key] for key in unchanged} + assert updated.group_id == device.group_id + assert updated.enabled is False and updated.verify_ssl is True + + +@pytest.mark.asyncio +async def test_omission_preserves_and_explicit_null_or_empty_clears(devices): + device = await make_device(group="Ops") + updated = await intake.update_device(device.id, DeviceIntegrationUpdate(name="Renamed")) + assert updated.name == "Renamed" and updated.group == "Ops" + cleared = await intake.update_device(device.id, DeviceIntegrationUpdate(group=None)) + assert cleared.group == "" + await intake.update_device(device.id, DeviceIntegrationUpdate(group="New")) + cleared = await intake.update_device(device.id, DeviceIntegrationUpdate(group=" ")) + assert cleared.group == "" + + +@pytest.mark.asyncio +async def test_group_only_write_preserves_status_and_room_changes(devices): + device = await make_device(group="Ops") + room = await store.create_group("Another room", None, 1) + await intake.update_device(device.id, DeviceIntegrationUpdate(group_id=room.id)) + await store.record_test_result(device.id, success=True, message="checked", latency_ms=12) + devices.reset_mock() + updated = await intake.update_device(device.id, DeviceIntegrationUpdate(group="Audit")) + assert updated.group_id == room.id + assert updated.status == "ok" and updated.message == "checked" and updated.latency_ms == 12 + assert updated.group == "Audit" + assert devices.await_count == 0 + + +@pytest.mark.asyncio +async def test_room_filter_preserves_business_group_metadata(devices): + alpha = await make_device(name="Alpha", group="Ops") + beta = await make_device(name="Beta", group="Other") + ungrouped = await make_device(name="Ungrouped") + room = await store.create_group("Second room", None, 1) + await intake.update_device(beta.id, DeviceIntegrationUpdate(group_id=room.id)) + assert {device.id for device in await store.list_devices()} == {alpha.id, beta.id, ungrouped.id} + listed = await store.list_devices(room.id) + assert [device.id for device in listed] == [beta.id] + assert listed[0].group == "Other" + assert listed[0].group_readonly is False + + +@pytest.mark.asyncio +async def test_native_update_does_not_overwrite_a_newer_group_when_omitted(devices, monkeypatch): + device = await make_device(group="Before") + stale = await store.fetch_device(device.id) + await store.update_device_metadata(device.id, group="Latest") + original = intake.fetch_device + first = True + + async def stale_then_current(device_id): + nonlocal first + if first: + first = False + return stale + return await original(device_id) + + monkeypatch.setattr(intake, "fetch_device", stale_then_current) + updated = await intake.update_device(device.id, DeviceIntegrationUpdate(name="New name")) + assert updated.name == "New name" and updated.group == "Latest" + + +@pytest.mark.asyncio +async def test_group_independent_of_single_room_feature_flag(devices, monkeypatch): + monkeypatch.setattr(intake, "MULTI_GROUP_ENABLED", False) + device = await make_device(group="Ops") + changed = await intake.update_device(device.id, DeviceIntegrationUpdate(group="Personal work")) + assert changed.group == "Personal work" + assert changed.group_id == DEFAULT_GROUP_ID + + +@pytest.mark.parametrize("value", [12, True, [], {}, "x" * 33, "a\x00b", "a\nb", "a\x7fb"]) +def test_invalid_group_rejected_without_coercion(value): + with pytest.raises(ValidationError): + DeviceIntegrationUpdate(group=value) + + +def test_unicode_group_and_field_presence(): + assert DeviceIntegrationUpdate(group=" 安全 🧑‍💻 ").group == "安全 🧑‍💻" + assert "group" not in DeviceIntegrationUpdate(name="Name").model_fields_set + assert "group" in DeviceIntegrationUpdate(group=None).model_fields_set diff --git a/tests/tool/test_device_plugin_index.py b/tests/tool/test_device_plugin_index.py index 678159d80..2b2e2d203 100644 --- a/tests/tool/test_device_plugin_index.py +++ b/tests/tool/test_device_plugin_index.py @@ -58,6 +58,40 @@ def _reset_env(monkeypatch, tmp_path): return home, data, project +def test_template_group_follows_selected_definition_not_catalog_native_label(monkeypatch, tmp_path): + from flocks.config.api_versioning import ApiServiceDescriptor + from flocks.tool import registry + from flocks.tool.device import plugin_index + + _reset_env(monkeypatch, tmp_path) + installation = tmp_path / "installation" + monkeypatch.setattr(registry, "__file__", str(installation / "flocks/tool/registry.py")) + shipped = installation / ".flocks/plugins/tools/device/device_v1" + user = tmp_path / "project/.flocks/plugins/tools/device/device_v1" + provider = {"name": "Demo", "service_id": "demo_api", "version": "1", "integration_type": "device", "group": "Canonical"} + _write_provider(shipped, provider) + _write_provider(user, {**provider, "group": "Editable project"}) + entry = SimpleNamespace( + id="device_v1", name="Demo", version="1", installedVersion=None, + description=None, descriptionCn=None, state="installed", source="system", installPath=str(shipped), + ) + monkeypatch.setattr(plugin_index.hub_catalog, "list_catalog", lambda **kw: [entry]) + monkeypatch.setattr(plugin_index, "_device_tool_counts", lambda: {}) + descriptor = ApiServiceDescriptor(service_id="demo_api", version="1", storage_key="demo_api_v1", provider_yaml=shipped / "_provider.yaml") + monkeypatch.setattr(plugin_index, "discover_api_service_descriptors", lambda **kw: [descriptor]) + plugin_index.clear_device_template_cache() + template = plugin_index.list_device_templates()[0] + assert template.group == "Canonical" and template.group_readonly is True + + selected = ApiServiceDescriptor(service_id="demo_api", version="1", storage_key="demo_api_v1", provider_yaml=user / "_provider.yaml") + monkeypatch.setattr(plugin_index, "discover_api_service_descriptors", lambda **kw: [selected]) + plugin_index.clear_device_template_cache() + template = plugin_index.list_device_templates()[0] + assert template.group == "Editable project" and template.group_readonly is False + assert yaml.safe_load((shipped / "_provider.yaml").read_text())["group"] == "Canonical" + plugin_index.clear_device_template_cache() + + def test_device_plugin_index_filters_and_shapes_templates(monkeypatch, tmp_path): from flocks.tool.device import plugin_index diff --git a/tests/tool/test_device_schema_migration.py b/tests/tool/test_device_schema_migration.py index 40bd9be68..3f88910d9 100644 --- a/tests/tool/test_device_schema_migration.py +++ b/tests/tool/test_device_schema_migration.py @@ -54,12 +54,51 @@ async def test_device_schema_fresh_init_does_not_warn_duplicate_group_id(monkeyp assert device_models.DEFAULT_GROUP_ID == "default-room" assert "group_id" in await _device_columns(db_path) + assert "plugin_group" in await _device_columns(db_path) assert "idx_device_group" in await _device_indexes(db_path) assert _extension_ddl_warnings(warnings) == [] finally: await _shutdown_storage() +@pytest.mark.asyncio +async def test_plugin_group_migration_preserves_existing_room_and_connection(monkeypatch, tmp_path: Path) -> None: + warnings = _capture_storage_warnings(monkeypatch) + db_path = tmp_path / "existing-room.db" + with sqlite3.connect(db_path) as db: + db.executescript(""" + CREATE TABLE device_integrations ( + id TEXT PRIMARY KEY, group_id TEXT NOT NULL, name TEXT NOT NULL, + storage_key TEXT NOT NULL, service_id TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, verify_ssl INTEGER NOT NULL DEFAULT 0, + fields TEXT NOT NULL DEFAULT '{}', status TEXT NOT NULL DEFAULT 'unknown', + message TEXT, latency_ms INTEGER, checked_at INTEGER, + created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL + ); + INSERT INTO device_integrations + (id, group_id, name, storage_key, service_id, fields, created_at, updated_at) + VALUES ('device', 'room-west', 'Existing', 'example_v1', 'example', + '{"base_url":"https://example.invalid","api_key":"{secret:device_key}"}', 10, 20); + """) + original = db.execute("SELECT * FROM device_integrations WHERE id='device'").fetchone() + + _reset_storage_state() + try: + for _ in range(2): + await Storage.init(db_path) + async with Storage.connect(db_path) as db: + db.row_factory = sqlite3.Row + cursor = await db.execute("SELECT * FROM device_integrations WHERE id='device'") + row = await cursor.fetchone() + assert tuple(row)[:-1] == original + assert row["group_id"] == "room-west" + assert row["plugin_group"] == "" + await _shutdown_storage() + assert _extension_ddl_warnings(warnings) == [] + finally: + await _shutdown_storage() + + @pytest.mark.asyncio async def test_device_schema_old_integrations_table_gets_group_id(monkeypatch, tmp_path: Path) -> None: warnings = _capture_storage_warnings(monkeypatch) @@ -88,6 +127,7 @@ async def test_device_schema_old_integrations_table_gets_group_id(monkeypatch, t await Storage.init(db_path) assert "group_id" in await _device_columns(db_path) + assert "plugin_group" in await _device_columns(db_path) assert "idx_device_group" in await _device_indexes(db_path) assert _extension_ddl_warnings(warnings) == [] finally: diff --git a/tests/workflow/test_fs_store.py b/tests/workflow/test_fs_store.py index f5c7b4c31..878dfd892 100644 --- a/tests/workflow/test_fs_store.py +++ b/tests/workflow/test_fs_store.py @@ -14,6 +14,107 @@ def reset_workspace_root_cache(monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(fs_store, "_workspace_root", None) +def test_all_discoverable_shipped_workflows_have_native_nonempty_group(): + from flocks.workflow.visibility import is_hidden_workflow_data + + loaded = [] + for folder in fs_store._SYSTEM_WORKFLOW_ROOT.iterdir(): + if not folder.is_dir(): + continue + data = fs_store.read_workflow_dir(folder, folder.name, "project") + if data is None or is_hidden_workflow_data(data): + continue + loaded.append(data) + assert data.get("group"), folder + assert data["group_readonly"] is True, folder + assert len(loaded) >= 2 + + +@pytest.mark.parametrize("draft", [False, True]) +def test_native_group_metadata_helper_handles_package_without_meta(tmp_path: Path, draft: bool): + folder = tmp_path / "installed-workflow" + folder.mkdir() + source = folder / ("workflow.md" if draft else "workflow.json") + payload = b"# Installed draft\r\n preserve \r\n" if draft else b'{"name":"Installed","start":"n1","nodes":[],"edges":[]}' + source.write_bytes(payload) + result = fs_store.patch_workflow_metadata(folder, {"group": " Response ", "vendor": {"keep": 1}}) + assert result["group"] == "Response" + assert source.read_bytes() == payload + assert (folder / "workflow.json").exists() is not draft + fs_store.patch_workflow_metadata(folder, {"group": None}) + metadata = json.loads((folder / "meta.json").read_text()) + assert metadata["group"] == "" + assert metadata["vendor"] == {"keep": 1} + assert fs_store.read_workflow_dir(folder, folder.name, "global")["group"] == "" + + +def test_native_group_metadata_staging_uses_destination_id(tmp_path: Path): + folder = tmp_path / ".plugin-random-staging" + folder.mkdir() + (folder / "workflow.md").write_text("Draft without a title\n", encoding="utf-8") + result = fs_store.patch_workflow_metadata(folder, {"group": "Preserved"}, workflow_id="real-workflow") + assert result["name"] == "real-workflow" + assert not (folder / "workflow.json").exists() + assert ".plugin-random-staging" not in (folder / "meta.json").read_text() + + +def test_native_group_meta_is_only_local_authority(tmp_path: Path): + folder = tmp_path / "workflow" + folder.mkdir() + source = folder / "workflow.json" + source.write_text(json.dumps({"name": "Example", "metadata": {"group": "Transport only"}})) + before = source.read_bytes() + loaded = fs_store.read_workflow_dir(folder, "workflow", "global") + assert not loaded.get("group") + assert "group" not in loaded["workflowJson"]["metadata"] + assert source.read_bytes() == before + assert not (folder / "meta.json").exists() + + +@pytest.mark.parametrize("group", ["Changed", "", None]) +def test_metadata_writer_rejects_shipped_group_before_other_metadata_changes(tmp_path, monkeypatch, group): + folder = tmp_path / "shipped" + folder.mkdir() + (folder / "workflow.md").write_text("# Shipped\n") + (folder / "meta.json").write_bytes(b'{"group":"Fixed","name":"Original"}') + monkeypatch.setattr(fs_store, "_SYSTEM_WORKFLOW_ROOT", tmp_path) + before = (folder / "meta.json").read_bytes() + with pytest.raises(ValueError, match="read-only"): + fs_store.patch_workflow_metadata(folder, {"group": group, "name": "Must not write"}) + assert (folder / "meta.json").read_bytes() == before + fs_store.patch_workflow_metadata(folder, {"group": "Fixed", "name": "Allowed"}) + assert fs_store.read_workflow_dir(folder, "shipped", "global")["group_readonly"] is True + assert json.loads((folder / "meta.json").read_text())["name"] == "Allowed" + + +def test_group_readonly_comes_from_selected_source_not_project_or_stored_flag(tmp_path, monkeypatch): + folder = tmp_path / "custom" + folder.mkdir() + (folder / "workflow.json").write_text('{"metadata":{"group":"Stale overlay"}}') + (folder / "meta.json").write_text('{"group":"Native","group_readonly":true}') + loaded = fs_store.read_workflow_dir(folder, "custom", "project") + assert loaded["group_readonly"] is False + assert loaded["group"] == "Native" + assert "group" not in loaded["workflowJson"]["metadata"] + monkeypatch.setattr(fs_store, "_SYSTEM_WORKFLOW_ROOT", tmp_path) + (folder / "meta.json").write_text('{"group":"Native","group_readonly":false}') + assert fs_store.read_workflow_dir(folder, "custom", "global")["group_readonly"] is True + + +def test_group_lock_follows_real_definition_file_not_symlink_directory(tmp_path, monkeypatch): + shipped = tmp_path / "shipped" + folder = shipped / "alias" + folder.mkdir(parents=True) + external = tmp_path / "custom.json" + external.write_text('{"name":"Custom"}') + (folder / "workflow.json").symlink_to(external) + monkeypatch.setattr(fs_store, "_SYSTEM_WORKFLOW_ROOT", shipped) + assert fs_store.is_system_workflow_definition(folder) is False + empty = shipped / "empty" + empty.mkdir() + assert fs_store.is_system_workflow_definition(empty) is False + + def _write_workflow(base_dir: Path, workflow_id: str, name: str) -> None: workflow_dir = base_dir / ".flocks" / "plugins" / "workflows" / workflow_id workflow_dir.mkdir(parents=True, exist_ok=True) diff --git a/webui/src/api/agent.ts b/webui/src/api/agent.ts index b68dfaeb1..0e47296b1 100644 --- a/webui/src/api/agent.ts +++ b/webui/src/api/agent.ts @@ -1,6 +1,9 @@ import client from './client'; export interface Agent { + /** Server-computed ownership of the selected definition, never a writable setting. */ + group_readonly?: boolean; + group?: string | null; name: string; /** Chinese display name; canonical `name` remains the stable identifier. */ nameCn?: string; @@ -70,6 +73,7 @@ export const agentAPI = { client.post('/api/agent', data), update: (name: string, data: { + group?: string | null; nameCn?: string; description?: string; descriptionCn?: string; diff --git a/webui/src/api/device.ts b/webui/src/api/device.ts index e6b34dd4f..f1c5382aa 100644 --- a/webui/src/api/device.ts +++ b/webui/src/api/device.ts @@ -33,6 +33,8 @@ export interface DeviceGroupUpdate { // --------------------------------------------------------------------------- export interface DeviceIntegration { + /** Business group, independent of the room group_id. */ + group?: string | null; id: string; group_id: string; name: string; @@ -75,6 +77,7 @@ export interface DeviceIntegrationCreate { } export interface DeviceIntegrationUpdate { + group?: string | null; name?: string; group_id?: string; enabled?: boolean; @@ -101,6 +104,8 @@ export interface DeviceTestRequest { } export interface DeviceTemplate { + group?: string | null; + group_readonly?: boolean; plugin_id: string; storage_key: string; service_id: string; diff --git a/webui/src/api/provider.ts b/webui/src/api/provider.ts index 56addd533..6727fbc27 100644 --- a/webui/src/api/provider.ts +++ b/webui/src/api/provider.ts @@ -163,7 +163,7 @@ export const providerAPI = { getServiceMetadata: (id: string) => client.get(`/api/provider/${id}/metadata`), - updateApiService: (id: string, data: { enabled: boolean; verify_ssl?: boolean }) => + updateApiService: (id: string, data: { enabled?: boolean; verify_ssl?: boolean; group?: string | null }) => client.patch(`/api/provider/api-services/${id}`, data) .then((response) => { invalidateApiServicesListCache(); diff --git a/webui/src/api/skill.ts b/webui/src/api/skill.ts index 00c2f9dad..0b3f52f98 100644 --- a/webui/src/api/skill.ts +++ b/webui/src/api/skill.ts @@ -17,6 +17,8 @@ export interface SkillInstallSpec { } export interface Skill { + group_readonly?: boolean; + group?: string | null; name: string; description: string; location: string; @@ -32,6 +34,10 @@ export interface Skill { install_specs?: SkillInstallSpec[]; } +/** Package/core definitions remain immutable, regardless of the user's role. */ +export const isSkillDefinitionReadOnly = (skill: Pick): boolean => + skill.source === 'project' || skill.source === 'flocks'; + export interface Command { name: string; canonical_name: string; @@ -102,6 +108,9 @@ export const skillAPI = { }) => client.put(`/api/skills/${name}`, data), + updateGroup: (name: string, group: string | null) => + client.patch(`/api/skills/${encodeURIComponent(name)}`, { group }), + delete: (name: string) => client.delete(`/api/skills/${name}`), diff --git a/webui/src/api/tool.test.ts b/webui/src/api/tool.test.ts index 5d6d3f056..1b09f8805 100644 --- a/webui/src/api/tool.test.ts +++ b/webui/src/api/tool.test.ts @@ -1,12 +1,15 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const getMock = vi.fn(); +const patchMock = vi.fn(); +const putMock = vi.fn(); vi.mock('./client', () => ({ default: { get: (...args: unknown[]) => getMock(...args), post: vi.fn(), - patch: vi.fn(), + patch: (...args: unknown[]) => patchMock(...args), + put: (...args: unknown[]) => putMock(...args), delete: vi.fn(), }, })); @@ -78,4 +81,64 @@ describe('listAllToolPages', () => { expect(result).toEqual([]); expect(getMock).toHaveBeenCalledTimes(1); }); + + it('refuses an incomplete inventory for a whole-group mutation scope', async () => { + getMock.mockResolvedValue({ data: { items: [], total: 50, offset: 0, limit: 200, facets: {} } }); + const { listAllToolPages } = await import('./tool'); + await expect(listAllToolPages({ group: 'Ops' }, { requireComplete: true })).rejects.toThrow('before all members could be loaded'); + expect(getMock).toHaveBeenCalledExactlyOnceWith('/api/tools/page', { params: expect.objectContaining({ group: 'Ops', offset: 0, limit: 200 }) }); + }); +}); + +describe('native group metadata clients', () => { + beforeEach(() => { + getMock.mockReset().mockResolvedValue({ data: {} }); + patchMock.mockReset().mockResolvedValue({ data: {} }); + putMock.mockReset().mockResolvedValue({ data: {} }); + }); + + it('writes only group through each original native identity and envelope', async () => { + const { agentAPI } = await import('./agent'); + const { workflowAPI } = await import('./workflow'); + const { skillAPI } = await import('./skill'); + const { toolAPI } = await import('./tool'); + const { mcpAPI } = await import('./mcp'); + const { providerAPI } = await import('./provider'); + const { deviceAPI } = await import('./device'); + await agentAPI.update('analyst', { group: 'Ops' }); + await workflowAPI.update('draft', { group: null }); + await skillAPI.updateGroup('custom', 'Ops'); + await toolAPI.updateGroup('native_tool', null); + await mcpAPI.update('configured-server', { group: 'Ops' }); + await providerAPI.updateApiService('service__v2_0', { group: null }); + await deviceAPI.update('device-1', { group: 'Ops' }); + expect(putMock.mock.calls).toEqual([ + ['/api/agent/analyst', { group: 'Ops' }], + ['/api/workflow/draft', { group: null }], + ['/api/mcp/configured-server', { config: { group: 'Ops' } }], + ['/api/devices/device-1', { group: 'Ops' }], + ]); + expect(patchMock.mock.calls).toEqual([ + ['/api/skills/custom', { group: 'Ops' }], + ['/api/tools/native_tool', { group: null }], + ['/api/provider/api-services/service__v2_0', { group: null }], + ]); + expect(getMock).not.toHaveBeenCalled(); + }); + + it('omits group for All and sends the empty scalar only for Ungrouped', async () => { + const { toolAPI } = await import('./tool'); + await toolAPI.listPage({ q: 'needle' }); + await toolAPI.listPage({ q: 'needle', group: '' }); + await toolAPI.listPage({ group: 'One, exact name' }); + expect(getMock.mock.calls.map((call) => call[1].params.group)).toEqual([undefined, '', 'One, exact name']); + }); + + it('keeps ordinary content forms unpolluted by group metadata', async () => { + const { skillAPI } = await import('./skill'); + const form = { name: 'custom-renamed', description: 'Edited', content: '# Body' }; + await skillAPI.update('custom', form); + expect(putMock).toHaveBeenCalledExactlyOnceWith('/api/skills/custom', form); + expect(putMock.mock.calls[0][1]).not.toHaveProperty('group'); + }); }); diff --git a/webui/src/api/tool.ts b/webui/src/api/tool.ts index 36729ddb2..bb7aff58e 100644 --- a/webui/src/api/tool.ts +++ b/webui/src/api/tool.ts @@ -26,6 +26,8 @@ export type ToolListSortField = 'category' | 'source' | 'source_name' | 'enabled export type ToolListSortDir = 'asc' | 'desc'; export interface ToolListPageParams { + /** Omitted = All; empty string = Ungrouped; otherwise one exact name. */ + group?: string; source?: string; category?: string; sourceName?: string; @@ -38,6 +40,8 @@ export interface ToolListPageParams { } export interface ToolListFacets { + /** Counts before group selection and pagination; '' denotes Ungrouped. */ + group?: Record; category: Record; source: Record; source_groups: Record; @@ -74,6 +78,7 @@ export const toolAPI = { listPage: (params?: ToolListPageParams) => client.get('/api/tools/page', { params: { + group: params?.group, source: params?.source, category: params?.category, source_name: params?.sourceName, @@ -108,10 +113,10 @@ export const toolAPI = { options?.device_id ? { params: { device_id: options.device_id } } : undefined, ), - /** - * Remove the user-level setting and restore the YAML/registration default - * for this tool (currently only the `enabled` flag is overlaid). - */ + updateGroup: (name: string, group: string | null) => + client.patch(`/api/tools/${encodeURIComponent(name)}`, { group }), + + /** Restore the enabled default without removing other native settings. */ resetSetting: (name: string) => client.post(`/api/tools/${name}/reset`), @@ -119,7 +124,7 @@ export const toolAPI = { client.delete(`/api/tools/${name}`), }; -export async function listAllToolPages(params: ToolListPageParams): Promise { +export async function listAllToolPages(params: ToolListPageParams, options?: { requireComplete?: boolean }): Promise { const pageSize = 200; const items: Tool[] = []; let offset = 0; @@ -133,6 +138,9 @@ export async function listAllToolPages(params: ToolListPageParams): Promise= response.data.total) break; } diff --git a/webui/src/api/workflow.ts b/webui/src/api/workflow.ts index dc6ed9230..e38d0952e 100644 --- a/webui/src/api/workflow.ts +++ b/webui/src/api/workflow.ts @@ -212,6 +212,8 @@ export interface WorkflowStats { } export interface WorkflowSummary { + group_readonly?: boolean; + group?: string | null; id: string; name: string; nameI18n?: Record; @@ -227,6 +229,8 @@ export interface WorkflowSummary { } export interface Workflow { + group_readonly?: boolean; + group?: string | null; id: string; name: string; nameI18n?: Record; @@ -459,6 +463,7 @@ export const workflowAPI = { client.post('/api/workflow', data), update: (id: string, data: { + group?: string | null; name?: string; description?: string; category?: string; diff --git a/webui/src/components/plugin-groups/GroupNav.test.tsx b/webui/src/components/plugin-groups/GroupNav.test.tsx new file mode 100644 index 000000000..05384d63f --- /dev/null +++ b/webui/src/components/plugin-groups/GroupNav.test.tsx @@ -0,0 +1,253 @@ +import { useState } from 'react'; +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createInstance } from 'i18next'; +import { I18nextProvider, initReactI18next } from 'react-i18next'; +import GroupNav, { useGroupDrag } from './GroupNav'; +import { deriveGroupNav, type GroupNavItem, type GroupSelection } from './groupView'; +import messages from '@/locales/en-US/pluginGroups.json'; + +const move = vi.fn(); +const rename = vi.fn(); +const remove = vi.fn(); +function Example({ inventory, showItems = true, onCreate }: { inventory: GroupNavItem[]; showItems?: boolean; onCreate?: (key: string, group: string) => Promise }) { + const [items, setItems] = useState(inventory); + const [selection, setSelection] = useState(null); + const drag = useGroupDrag(items); + return <> + { await move(key, group); setItems((current) => current.map((item) => item.key === key ? { ...item, group } : item)); }} + onCreate={onCreate} onRename={rename} onDelete={remove} {...drag} /> + {JSON.stringify(selection)} + {showItems && items.map((item) =>
{item.name}
)} + ; +} +async function mount(items: GroupNavItem[], onCreate?: (key: string, group: string) => Promise) { + const i18n = createInstance(); + await i18n.use(initReactI18next).init({ lng: 'en-US', resources: { 'en-US': { pluginGroups: messages } }, interpolation: { escapeValue: false } }); + const view = render(); + return { ...view, showItems: (showItems: boolean) => view.rerender() }; +} +function transfer() { + const values = new Map(); + return { types: [] as string[], effectAllowed: '', + setData(type: string, value: string) { values.set(type, value); this.types.push(type); }, + getData(type: string) { return values.get(type) ?? ''; }, + }; +} + +beforeEach(() => { + vi.restoreAllMocks(); + vi.clearAllMocks(); + localStorage.clear(); + move.mockResolvedValue(undefined); + rename.mockResolvedValue(undefined); + remove.mockResolvedValue(undefined); +}); + +describe('pure native GroupNav', () => { + it('only clears a missing selection for an authoritative unfiltered inventory', async () => { + const i18n = createInstance(); + await i18n.use(initReactI18next).init({ lng: 'en-US', resources: { 'en-US': { pluginGroups: messages } } }); + const select = vi.fn(); + const props = { preferenceKey: 'facets', selection: 'Alpha', onSelect: select, + onMove: move, onRename: rename, onDelete: remove, items: [], total: 1, ungroupedCount: 0 }; + const { rerender } = render(); + expect(screen.getByRole('button', { name: 'Alpha 0' })).toHaveAttribute('aria-pressed', 'true'); + expect(select).not.toHaveBeenCalled(); + rerender(); + expect(select).not.toHaveBeenCalled(); + expect(screen.getByRole('button', { name: 'Alpha 0' })).toHaveAttribute('aria-pressed', 'true'); + rerender(); + expect(select).toHaveBeenCalledExactlyOnceWith(null); + }); + + it('requires the first editable plugin and never persists an empty group', async () => { + await mount([{ key: 'a', name: 'Alpha' }, { key: 'builtin', name: 'Built-in', readOnlyReason: 'Read-only definition' }]); + fireEvent.click(screen.getByRole('button', { name: 'New group' })); + fireEvent.change(screen.getByRole('textbox', { name: 'Group name' }), { target: { value: ' Ops ' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + expect(screen.getByRole('alert')).toHaveTextContent(messages.validation.firstPlugin); + expect(move).not.toHaveBeenCalled(); + expect(localStorage.length).toBe(0); + const select = screen.getByRole('combobox', { name: 'First editable plugin' }); + expect(within(select).queryByRole('option', { name: 'Built-in' })).not.toBeInTheDocument(); + fireEvent.change(select, { target: { value: 'a' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + await screen.findByRole('button', { name: 'Ops 1' }); + expect(move).toHaveBeenCalledExactlyOnceWith('a', 'Ops'); + expect(localStorage.length).toBe(0); + fireEvent.click(screen.getByRole('button', { name: 'Ops 1' })); + fireEvent.keyDown(screen.getByTestId('a'), { key: 'm', altKey: true }); + fireEvent.change(screen.getByRole('combobox'), { target: { value: '' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + await waitFor(() => expect(screen.queryByRole('button', { name: 'Ops 1' })).not.toBeInTheDocument()); + expect(screen.getByTestId('selection')).toHaveTextContent('null'); + expect(move).toHaveBeenLastCalledWith('a', null); + }); + + it('lets the native page reject a new name outside the displayed group scope', async () => { + const create = vi.fn().mockRejectedValue(new Error(messages.validation.duplicate)); + await mount([{ key: 'a', name: 'Alpha' }], create); + fireEvent.click(screen.getByRole('button', { name: 'New group' })); + fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Outside current filters' } }); + fireEvent.change(screen.getByRole('combobox'), { target: { value: 'a' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent(messages.validation.duplicate)); + expect(create).toHaveBeenCalledExactlyOnceWith('a', 'Outside current filters'); + expect(move).not.toHaveBeenCalled(); + expect(within(screen.getByRole('complementary')).queryByRole('status')).not.toBeInTheDocument(); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + + it('rejects duplicate names without merging or issuing native writes', async () => { + await mount([{ key: 'a', name: 'A', group: 'Alpha' }, { key: 'b', name: 'B', group: 'Beta' }]); + fireEvent.click(screen.getByRole('button', { name: 'Rename group Alpha' })); + fireEvent.change(screen.getByRole('textbox'), { target: { value: ' Beta ' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + expect(screen.getByRole('alert')).toHaveTextContent(messages.validation.duplicate); + expect(rename).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + fireEvent.click(screen.getByRole('button', { name: 'New group' })); + fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Alpha' } }); + fireEvent.change(screen.getByRole('combobox'), { target: { value: 'b' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + expect(screen.getByRole('alert')).toHaveTextContent(messages.validation.duplicate); + expect(move).not.toHaveBeenCalled(); + }); + + it('keeps a failed native save visible and only reports success after a successful retry', async () => { + move.mockRejectedValueOnce(new Error('Alpha: disk full')); + await mount([{ key: 'a', name: 'Alpha', group: 'Operations' }]); + fireEvent.keyDown(screen.getByTestId('a'), { key: 'm', altKey: true }); + fireEvent.change(screen.getByRole('combobox'), { target: { value: '' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Alpha: disk full')); + expect(within(screen.getByRole('complementary')).queryByRole('status')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Operations 1' })).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + expect(within(screen.getByRole('complementary')).getByRole('status')).toHaveTextContent(messages.saved); + expect(screen.queryByRole('button', { name: 'Operations 1' })).not.toBeInTheDocument(); + expect(move).toHaveBeenCalledTimes(2); + }); + + it('sorts only current names as a personal preference without native writes', async () => { + localStorage.setItem('flocks:plugin-group-order:test', JSON.stringify(['Ghost', 'Beta'])); + await mount([{ key: 'a', name: 'A', group: 'Alpha' }, { key: 'b', name: 'B', group: 'Beta' }]); + expect(screen.queryByText('Ghost')).not.toBeInTheDocument(); + fireEvent.keyDown(screen.getByRole('button', { name: 'Reorder group Alpha' }), { key: 'ArrowUp' }); + expect(JSON.parse(localStorage.getItem('flocks:plugin-group-order:test')!)).toEqual(['Alpha', 'Beta']); + const dataTransfer = transfer(); + fireEvent.dragStart(screen.getByRole('button', { name: 'Reorder group Beta' }), { dataTransfer }); + fireEvent.drop(screen.getByRole('button', { name: 'Alpha 1' }).parentElement!, { dataTransfer }); + expect(JSON.parse(localStorage.getItem('flocks:plugin-group-order:test')!)).toEqual(['Beta', 'Alpha']); + expect(move).not.toHaveBeenCalled(); + expect(rename).not.toHaveBeenCalled(); + expect(remove).not.toHaveBeenCalled(); + }); + + it('keeps All distinct from Ungrouped and groups literally called all/new', async () => { + await mount([{ key: 'a', name: 'A', group: 'all' }, { key: 'b', name: 'B', group: 'new' }, { key: 'c', name: 'C' }]); + fireEvent.click(screen.getByRole('button', { name: 'Ungrouped 1' })); + expect(screen.getByTestId('selection')).toHaveTextContent('""'); + fireEvent.click(screen.getByRole('button', { name: 'all 1' })); + expect(screen.getByTestId('selection')).toHaveTextContent('"all"'); + fireEvent.click(screen.getByRole('button', { name: 'All 3' })); + expect(screen.getByTestId('selection')).toHaveTextContent('null'); + const dataTransfer = transfer(); + fireEvent.dragStart(screen.getByTestId('c'), { dataTransfer }); + fireEvent.drop(screen.getByRole('button', { name: 'new 1' }).parentElement!, { dataTransfer }); + await waitFor(() => expect(move).toHaveBeenCalledWith('c', 'new')); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('supports dropping onto New group, ignores external IDs and preserves native controls', async () => { + await mount([{ key: 'a', name: 'A' }]); + const dataTransfer = transfer(); + dataTransfer.setData('application/x-flocks-visible-plugin', 'not-visible'); + fireEvent.drop(screen.getByRole('button', { name: 'Ungrouped 1' }).parentElement!, { dataTransfer }); + expect(move).not.toHaveBeenCalled(); + expect(fireEvent.dragStart(screen.getByRole('button', { name: 'Original action A' }), { dataTransfer })).toBe(false); + fireEvent.dragStart(screen.getByTestId('a'), { dataTransfer }); + fireEvent.drop(screen.getByRole('button', { name: 'New group' }), { dataTransfer }); + expect(screen.getByRole('combobox', { name: 'First editable plugin' })).toHaveValue('a'); + fireEvent.change(screen.getByRole('textbox'), { target: { value: 'First' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + await waitFor(() => expect(move).toHaveBeenCalledWith('a', 'First')); + }); + + it('rejects forged visible keys, cancelled drags and unmounted sources', async () => { + const view = await mount([{ key: 'a', name: 'A', group: 'Alpha' }, { key: 'b', name: 'B', group: 'Beta' }]); + const target = screen.getByRole('button', { name: 'Beta 1' }).parentElement!; + const forged = transfer(); + forged.setData('application/x-flocks-visible-plugin', 'a'); + fireEvent.drop(target, { dataTransfer: forged }); + const reorderForgery = transfer(); + reorderForgery.setData('application/x-flocks-group-order', 'Alpha'); + fireEvent.drop(target, { dataTransfer: reorderForgery }); + expect(localStorage.length).toBe(0); + const cancelled = transfer(); + fireEvent.dragStart(screen.getByTestId('a'), { dataTransfer: cancelled }); + fireEvent.dragEnd(screen.getByTestId('a')); + fireEvent.drop(target, { dataTransfer: cancelled }); + const unmounted = transfer(); + fireEvent.dragStart(screen.getByTestId('a'), { dataTransfer: unmounted }); + view.showItems(false); + fireEvent.drop(target, { dataTransfer: unmounted }); + expect(move).not.toHaveBeenCalled(); + expect(rename).not.toHaveBeenCalled(); + }); + + it('traps modal keyboard focus and restores it on Escape without saving', async () => { + await mount([{ key: 'a', name: 'A', group: 'Alpha' }]); + const source = screen.getByTestId('a'); + source.focus(); + fireEvent.keyDown(source, { key: 'm', altKey: true }); + const dialog = screen.getByRole('dialog'); + expect(screen.getByRole('combobox')).toHaveFocus(); + const close = within(dialog).getByRole('button', { name: 'Close' }); + const save = within(dialog).getByRole('button', { name: 'Save' }); + save.focus(); + fireEvent.keyDown(save, { key: 'Tab' }); + expect(close).toHaveFocus(); + fireEvent.keyDown(close, { key: 'Tab', shiftKey: true }); + expect(save).toHaveFocus(); + fireEvent.keyDown(dialog, { key: 'Escape' }); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(source).toHaveFocus(); + expect(move).not.toHaveBeenCalled(); + }); + + it('supports Option+M on macOS and focuses the name when switching to group creation', async () => { + await mount([{ key: 'a', name: 'Alpha' }]); + const source = screen.getByTestId('a'); + const input = document.createElement('input'); + source.appendChild(input); + input.focus(); + expect(fireEvent.keyDown(input, { key: 'µ', code: 'KeyM', altKey: true })).toBe(true); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + source.focus(); + fireEvent.keyDown(source, { key: 'µ', code: 'KeyM', altKey: true }); + expect(screen.getByRole('combobox', { name: 'Destination group' })).toHaveFocus(); + fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'New group' })); + expect(screen.getByRole('textbox', { name: 'Group name' })).toHaveFocus(); + expect(screen.getByRole('combobox', { name: 'First editable plugin' })).toHaveValue('a'); + fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' }); + expect(source).toHaveFocus(); + expect(move).not.toHaveBeenCalled(); + }); + + it('blocks whole-group edits with read-only members but permits moving editable ones', async () => { + await mount([{ key: 'builtin', name: 'Built-in', group: 'Mixed', readOnlyReason: 'Built-in even for admin' }, { key: 'custom', name: 'Custom', group: 'Mixed' }]); + expect(screen.getByRole('button', { name: 'Rename group Mixed' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Delete group Mixed' })).toBeDisabled(); + expect(screen.getByTestId('builtin')).toHaveAttribute('draggable', 'false'); + fireEvent.keyDown(screen.getByTestId('builtin'), { key: 'm', altKey: true }); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + fireEvent.keyDown(screen.getByTestId('custom'), { key: 'm', altKey: true }); + fireEvent.change(screen.getByRole('combobox'), { target: { value: '' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + await waitFor(() => expect(move).toHaveBeenCalledExactlyOnceWith('custom', null)); + }); +}); diff --git a/webui/src/components/plugin-groups/GroupNav.tsx b/webui/src/components/plugin-groups/GroupNav.tsx new file mode 100644 index 000000000..930f653f1 --- /dev/null +++ b/webui/src/components/plugin-groups/GroupNav.tsx @@ -0,0 +1,323 @@ +import { useEffect, useId, useRef, useState, type HTMLAttributes, type ReactNode } from 'react'; +import { GripVertical, Pencil, Plus, Trash2, X } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { GroupActionCancelled, groupItemLabel, groupName, orderGroupNames, type GroupNavEntry, type GroupNavItem, type GroupSelection } from './groupView'; + +const RESOURCE_MIME = 'application/x-flocks-visible-plugin'; +const ORDER_MIME = 'application/x-flocks-group-order'; + +function readOrder(key: string): unknown { + try { return JSON.parse(window.localStorage.getItem(key) ?? '[]'); } catch { return []; } +} + +/** Only transient drag/keyboard state; no plugin membership or backend state. */ +export function useGroupDrag(items: readonly GroupNavItem[]) { + const { t } = useTranslation('pluginGroups'); + const [movingKey, setMovingKey] = useState(null); + const gestureScope = useId(); + const gestureSequence = useRef(0); + const activeDrag = useRef<{ token: string; key: string; element: HTMLElement } | null>(null); + const movingItem = items.find((item) => item.key === movingKey && !item.readOnlyReason) ?? null; + useEffect(() => () => { activeDrag.current = null; }, []); + const consumeResourceDrag = (token: string): GroupNavItem | undefined => { + const gesture = activeDrag.current; + activeDrag.current = null; + if (!gesture || gesture.token !== token || !gesture.element.isConnected) return undefined; + const item = items.find((row) => row.key === gesture.key); + return item?.readOnlyReason ? undefined : item; + }; + const dragProps = (key: string, options?: { allowPrimaryButton?: boolean }): HTMLAttributes => { + const item = items.find((row) => row.key === key); + if (!item) return {}; + if (item.readOnlyReason) return { draggable: false, title: item.readOnlyReason, 'aria-description': item.readOnlyReason }; + return { + draggable: true, + tabIndex: 0, + 'aria-label': t('drag.sourceLabel', { name: item.name }), + 'aria-keyshortcuts': 'Alt+m', + onDragStart: (event) => { + activeDrag.current = null; + // Do not turn text selection or native form controls into a plugin drag. + if ((event.target as HTMLElement).closest(options?.allowPrimaryButton ? 'input, textarea, select, a' : 'button, input, textarea, select, a')) { + event.preventDefault(); + return; + } + const token = `${gestureScope}-${++gestureSequence.current}`; + activeDrag.current = { token, key, element: event.currentTarget }; + event.dataTransfer.setData(RESOURCE_MIME, token); + event.dataTransfer.effectAllowed = 'move'; + }, + onDragEnd: () => { activeDrag.current = null; }, + onKeyDown: (event) => { + if (event.key === 'Escape') activeDrag.current = null; + if (event.altKey && (event.code === 'KeyM' || event.key.toLowerCase() === 'm')) { + if ((event.target as HTMLElement).closest('input, textarea, select, [contenteditable="true"]')) return; + event.preventDefault(); + event.stopPropagation(); + setMovingKey(key); + } + }, + }; + }; + return { dragProps, movingItem, clearMovingItem: () => setMovingKey(null), consumeResourceDrag }; +} +export type GroupDrag = ReturnType; + +export interface GroupNavProps { + preferenceKey: string; + groups: readonly GroupNavEntry[]; + total: number; + ungroupedCount: number; + /** Only a successful, complete, unfiltered inventory can prove a group disappeared. */ + inventoryComplete?: boolean; + /** Visible native inventory (current page for paged Tools), used only for moving/creation. */ + items: readonly GroupNavItem[]; + selection: GroupSelection; + onSelect: (name: GroupSelection) => void; + onMove: (key: string, group: string | null) => Promise; + /** Paged/filtered pages can check new names against their complete native inventory. */ + onCreate?: (key: string, group: string) => Promise; + /** Page callbacks must load the complete original native scope before writing. */ + onRename: (from: string, to: string) => Promise; + onDelete: (name: string) => Promise; + movingItem?: GroupNavItem | null; + clearMovingItem?: () => void; + /** Accept only a gesture started by this page's currently mounted resource. */ + consumeResourceDrag?: (token: string) => GroupNavItem | undefined; + className?: string; +} + +type Dialog = { kind: 'create'; key: string } | { kind: 'rename'; name: string } | null; + +function GroupDialog({ titleId, onClose, busy, children }: { + titleId: string; + onClose: () => void; + busy: boolean; + children: ReactNode; +}) { + const ref = useRef(null); + useEffect(() => { + const dialog = ref.current!; + const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null; + const nav = dialog.closest('aside'); + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + // Native top-layer modality makes the background inert and traps focus. + // The fallback keeps the same keyboard contract in DOM test environments. + if (typeof dialog.showModal === 'function') dialog.showModal(); + else dialog.setAttribute('open', ''); + (dialog.querySelector('input, select') ?? dialog).focus(); + return () => { + if (typeof dialog.close === 'function') dialog.close(); + document.body.style.overflow = previousOverflow; + if (previousFocus?.isConnected) previousFocus.focus(); + else nav?.querySelector('button[aria-current="page"]')?.focus(); + }; + }, []); + return { event.preventDefault(); if (!busy) onClose(); }} + onKeyDown={(event) => { + if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); if (!busy) onClose(); } + if (event.key !== 'Tab') return; + const controls = ref.current?.querySelectorAll('button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), a[href], [tabindex="0"]'); + const first = controls?.[0]; + const last = controls?.[controls.length - 1]; + if (!first) { event.preventDefault(); ref.current?.focus(); } + else if (event.shiftKey && (document.activeElement === first || document.activeElement === ref.current)) { + event.preventDefault(); last?.focus(); + } else if (!event.shiftKey && (document.activeElement === last || document.activeElement === ref.current)) { + event.preventDefault(); first.focus(); + } + }}>{children}; +} + +/** Prop-driven native-group navigation. Only dialogs, feedback and personal order live here. */ +export default function GroupNav({ + preferenceKey, groups, total, ungroupedCount, inventoryComplete = false, items, selection, onSelect, + onMove, onCreate, onRename, onDelete, movingItem, clearMovingItem, consumeResourceDrag, className = '', +}: GroupNavProps) { + const { t } = useTranslation('pluginGroups'); + const dialogTitleId = useId(); + const [dialog, setDialog] = useState(null); + const [name, setName] = useState(''); + const [destination, setDestination] = useState(''); + const [busy, setBusy] = useState(false); + const busyRef = useRef(false); + const orderGesture = useRef<{ token: string; name: string; element: HTMLElement } | null>(null); + const orderSequence = useRef(0); + const [message, setMessage] = useState(''); + const [error, setError] = useState(''); + const names = groups.filter((entry) => entry.count > 0 && entry.name).map((entry) => entry.name); + const storageKey = `flocks:plugin-group-order:${preferenceKey}`; + const [personalOrder, setPersonalOrder] = useState(() => ({ key: storageKey, value: readOrder(storageKey) })); + const saved = personalOrder.key === storageKey ? personalOrder.value : readOrder(storageKey); + if (personalOrder.key !== storageKey) setPersonalOrder({ key: storageKey, value: saved }); + // A selected filter can have zero matches without its native group disappearing. + const displayedNames = selection && !inventoryComplete && !names.includes(selection) ? [...names, selection] : names; + const ordered = orderGroupNames(displayedNames, saved); + const editableItems = items.filter((item) => !item.readOnlyReason); + const nameSignature = JSON.stringify(names); + + useEffect(() => { + if (inventoryComplete && selection && !names.includes(selection)) onSelect(null); + }, [inventoryComplete, nameSignature, selection, onSelect]); + + useEffect(() => { + if (movingItem) setDestination(groupName(movingItem.group)); + }, [movingItem?.key]); + + const reorder = (source: string, target: string) => { + if (source === target || !ordered.includes(source) || !ordered.includes(target)) return; + const next = ordered.filter((value) => value !== source); + next.splice(ordered.indexOf(target), 0, source); + try { window.localStorage.setItem(storageKey, JSON.stringify(next)); } catch { /* browser-only preference */ } + setPersonalOrder({ key: storageKey, value: next }); + setMessage(t('drag.orderPlaced')); + }; + + const run = async (action: () => Promise, close = true) => { + if (busyRef.current) return; + busyRef.current = true; + setBusy(true); + setError(''); + setMessage(''); + try { + await action(); + if (close) { setDialog(null); clearMovingItem?.(); } + setMessage(t('saved')); + } catch (err) { + if (err instanceof GroupActionCancelled) { + setDialog(null); + clearMovingItem?.(); + setMessage(t('drag.cancelled')); + } else setError(err instanceof Error ? err.message : String(err)); + } finally { + busyRef.current = false; + setBusy(false); + } + }; + + const openCreate = (key = '') => { setName(''); setError(''); setDialog({ kind: 'create', key }); }; + const dropProps = (target?: string, orderTarget?: string): HTMLAttributes => ({ + onDragOver: (event) => { + if (event.dataTransfer.types.includes(RESOURCE_MIME) || (orderTarget && event.dataTransfer.types.includes(ORDER_MIME))) event.preventDefault(); + }, + onDrop: (event) => { + event.preventDefault(); + if (busy) return; + const orderToken = event.dataTransfer.getData(ORDER_MIME); + const orderSource = orderGesture.current; + orderGesture.current = null; + if (orderToken) { + if (orderTarget && orderSource?.token === orderToken && orderSource.element.isConnected) reorder(orderSource.name, orderTarget); + return; + } + // Payload alone (even a valid visible key) cannot authorize a local gesture. + const item = consumeResourceDrag?.(event.dataTransfer.getData(RESOURCE_MIME)); + if (!item || item.readOnlyReason || !items.some((row) => row.key === item.key && !row.readOnlyReason)) return; + if (target === undefined) openCreate(item.key); + else void run(() => onMove(item.key, target || null)); + }, + }); + + const submit = () => { + if (!dialog) return; + const next = name.trim(); + if (!next || [...next].length > 32 || /[\u0000-\u001f\u007f]/.test(next)) { setError(t('validation.length')); return; } + if (names.includes(next)) { setError(t('validation.duplicate')); return; } + if (dialog.kind === 'rename') { void run(() => onRename(dialog.name, next)); return; } + const first = editableItems.find((item) => item.key === dialog.key); + if (!first) { setError(t('validation.firstPlugin')); return; } + void run(() => (onCreate ?? onMove)(first.key, next)); + }; + + const modalOpen = !!dialog || !!movingItem; + const closeModal = () => { if (!busy) { setDialog(null); clearMovingItem?.(); setError(''); } }; + const navigationButton = (value: GroupSelection, label: string, count: number) => ( + + ); + + return ( + + ); +} diff --git a/webui/src/components/plugin-groups/PluginViewToggle.test.tsx b/webui/src/components/plugin-groups/PluginViewToggle.test.tsx new file mode 100644 index 000000000..81c7f2d88 --- /dev/null +++ b/webui/src/components/plugin-groups/PluginViewToggle.test.tsx @@ -0,0 +1,35 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { I18nextProvider } from 'react-i18next'; +import { createInstance, type ResourceKey } from 'i18next'; +import { I18N_NAMESPACES, INITIAL_NAMESPACES, readLazyLocale } from '../../i18nResources'; +import PluginViewToggle from './PluginViewToggle'; + +const load = (language: string) => new Promise((resolve, reject) => { + readLazyLocale(language, 'pluginGroups', (error, data) => { + if (error) reject(error); + else resolve(data as ResourceKey); + }); +}); + +describe('PluginViewToggle and lazy group translations', () => { + it.each([ + ['en-US', 'Card view', 'List view'], + ['zh-CN', '卡片视图', '列表视图'], + ])('lazily loads %s labels and exposes compact accessible pressed buttons', async (language, cards, list) => { + expect(I18N_NAMESPACES).toContain('pluginGroups'); + expect(INITIAL_NAMESPACES).not.toContain('pluginGroups'); + const i18n = createInstance(); + await i18n.init({ lng: language, fallbackLng: false, resources: { [language]: { pluginGroups: await load(language) } } }); + const onChange = vi.fn(); + const { rerender } = render(); + expect(screen.getByRole('button', { name: cards })).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getByRole('button', { name: list })).toHaveAttribute('aria-pressed', 'false'); + fireEvent.click(screen.getByRole('button', { name: list })); + expect(onChange).toHaveBeenCalledExactlyOnceWith('list'); + rerender(); + expect(screen.getByRole('button', { name: list })).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getAllByRole('button')).toHaveLength(2); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); +}); diff --git a/webui/src/components/plugin-groups/PluginViewToggle.tsx b/webui/src/components/plugin-groups/PluginViewToggle.tsx new file mode 100644 index 000000000..d3e1fd672 --- /dev/null +++ b/webui/src/components/plugin-groups/PluginViewToggle.tsx @@ -0,0 +1,27 @@ +import { LayoutGrid, List } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import type { PluginViewMode } from '../../hooks/usePluginViewMode'; + +export default function PluginViewToggle({ value, onChange }: { + value: PluginViewMode; + onChange: (mode: PluginViewMode) => void; +}) { + const { t } = useTranslation('pluginGroups'); + return ( +
+ {([{ mode: 'cards', Icon: LayoutGrid }, { mode: 'list', Icon: List }] as const).map(({ mode, Icon }) => ( + + ))} +
+ ); +} diff --git a/webui/src/components/plugin-groups/groupView.test.ts b/webui/src/components/plugin-groups/groupView.test.ts new file mode 100644 index 000000000..495f330f9 --- /dev/null +++ b/webui/src/components/plugin-groups/groupView.test.ts @@ -0,0 +1,79 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createInstance } from 'i18next'; +import enGroups from '@/locales/en-US/pluginGroups.json'; +const i18n = createInstance(); +const t = i18n.t.bind(i18n); +import { deriveGroupNav, GroupActionCancelled, matchesGroup, orderGroupNames, saveGroupItems } from './groupView'; + +beforeEach(async () => { + vi.restoreAllMocks(); + await i18n.init({ lng: 'en-US', resources: { 'en-US': { pluginGroups: enGroups } } }); +}); + +describe('native group view helpers', () => { + it('derives trimmed nonempty names and keeps All distinct from Ungrouped and literal all', () => { + const result = deriveGroupNav([ + { key: 'a', name: 'A', group: ' Operations ' }, + { key: 'b', name: 'B', group: 'Operations', readOnlyReason: 'read-only' }, + { key: 'c', name: 'C', group: ' ' }, + { key: 'd', name: 'D', group: null }, + { key: 'e', name: 'E', group: 'all' }, + ]); + expect(result).toEqual({ total: 5, ungroupedCount: 2, groups: [ + { name: 'Operations', count: 2, readOnlyReason: 'read-only' }, { name: 'all', count: 1 }, + ] }); + expect(matchesGroup('all', null)).toBe(true); + expect(matchesGroup('all', '')).toBe(false); + expect(matchesGroup(undefined, '')).toBe(true); + expect(matchesGroup(' Operations ', 'Operations')).toBe(true); + expect(orderGroupNames(['Operations', 'all'], ['missing', 'all', 'all'])).toEqual(['all', 'Operations']); + expect(orderGroupNames([], ['Operations'])).toEqual([]); + }); + + it('prechecks every member and blocks the whole operation before any write', async () => { + const save = vi.fn(); + const reload = vi.fn(); + const confirm = vi.spyOn(window, 'confirm').mockReturnValue(true); + await expect(saveGroupItems([ + { key: 'custom', name: 'Custom' }, + { key: 'builtin', name: 'Built-in', readOnlyReason: 'definition is read-only even for admin' }, + ], 'New', save, reload, t, true)).rejects.toThrow('Built-in'); + expect(save).not.toHaveBeenCalled(); + expect(confirm).not.toHaveBeenCalled(); + }); + + it('confirms the exact scope and reports failed items while reloading real partial results', async () => { + const confirm = vi.spyOn(window, 'confirm').mockReturnValue(true); + const calls: string[] = []; + const save = vi.fn(async (key: string) => { + calls.push(key); + if (key === 'b') throw new Error('disk full'); + }); + const reload = vi.fn(async () => { calls.push('reload'); }); + const result = saveGroupItems([ + { key: 'a', name: 'Alpha' }, { key: 'b', name: 'Beta' }, { key: 'c', name: 'Gamma' }, + ], null, save, reload, t, true); + await expect(result).rejects.toThrow('Failed plugins:\nBeta (b): disk full'); + await expect(result).rejects.toThrow('Saved plugins: Alpha (a), Gamma (c)'); + await expect(result).rejects.toThrow('Saved 2; failed 1'); + expect(confirm.mock.lastCall?.[0]).toContain('3 visible plugins'); + expect(confirm.mock.lastCall?.[0]).toContain('Alpha (a), Beta (b), Gamma (c)'); + expect(calls).toEqual(['a', 'b', 'c', 'reload']); + expect(save.mock.calls.map((call) => call[0])).toEqual(['a', 'b', 'c']); + expect(reload).toHaveBeenCalledOnce(); + }); + + it('reports refresh failure and never claims a rollback or cancels successful writes', async () => { + const save = vi.fn().mockResolvedValue(undefined); + const reload = vi.fn().mockRejectedValue(new Error('offline')); + await expect(saveGroupItems([{ key: 'a', name: 'Alpha' }], 'Ops', save, reload, t)).rejects.toThrow('Saved 1; failed 0'); + expect(save).toHaveBeenCalledExactlyOnceWith('a', 'Ops'); + }); + + it('does not write or report saved when the actual-scope confirmation is cancelled', async () => { + vi.spyOn(window, 'confirm').mockReturnValue(false); + const save = vi.fn(); + await expect(saveGroupItems([{ key: 'a', name: 'A' }], null, save, vi.fn(), t, true)).rejects.toBeInstanceOf(GroupActionCancelled); + expect(save).not.toHaveBeenCalled(); + }); +}); diff --git a/webui/src/components/plugin-groups/groupView.ts b/webui/src/components/plugin-groups/groupView.ts new file mode 100644 index 000000000..abdef72cf --- /dev/null +++ b/webui/src/components/plugin-groups/groupView.ts @@ -0,0 +1,100 @@ +import type { TFunction } from 'i18next'; +import { extractErrorMessage } from '@/utils/error'; + +/** Presentation rows only. Keys are the current page's native item identities. */ +export interface GroupNavItem { + key: string; + name: string; + group?: string | null; + readOnlyReason?: string; +} + +export interface GroupNavEntry { + name: string; + count: number; + readOnlyReason?: string; +} + +/** null is All; the empty string is Ungrouped. Neither is a group name. */ +export type GroupSelection = string | null; +export const groupItemLabel = (item: GroupNavItem): string => item.name === item.key ? item.name : `${item.name} (${item.key})`; +export const groupName = (value?: string | null): string => value?.trim() ?? ''; +export const matchesGroup = (value: string | null | undefined, selection: GroupSelection): boolean => ( + selection === null || groupName(value) === selection +); + +export function deriveGroupNav(items: readonly GroupNavItem[]) { + const entries = new Map(); + let ungroupedCount = 0; + for (const item of items) { + const name = groupName(item.group); + if (!name) { ungroupedCount += 1; continue; } + const entry = entries.get(name) ?? { name, count: 0 }; + entry.count += 1; + if (item.readOnlyReason) entry.readOnlyReason = item.readOnlyReason; + entries.set(name, entry); + } + return { groups: [...entries.values()], total: items.length, ungroupedCount }; +} + +/** Apply a personal ordering to *currently derived* names, never create empty groups. */ +export function orderGroupNames(names: string[], saved: unknown): string[] { + const order = Array.isArray(saved) ? saved.filter((name): name is string => typeof name === 'string' && names.includes(name)) : []; + return [...new Set([...order, ...names])]; +} + +/** + * Bounded native saves, not a group persistence layer. The page supplies its + * complete visible operation scope and its own native save/reload callbacks. + * Precheck every member before writing; never fake an atomic rollback. + */ +export class GroupActionCancelled extends Error {} + +export function assertGroupItemsEditable(items: readonly GroupNavItem[], t: TFunction): void { + const readOnly = items.filter((item) => item.readOnlyReason); + if (readOnly.length) { + throw new Error(t('pluginGroups:errors.readOnlyMembers', { + items: readOnly.map((item) => `${groupItemLabel(item)}: ${item.readOnlyReason}`).join('; '), + })); + } +} + +export async function saveGroupItems( + items: readonly GroupNavItem[], + group: string | null, + save: (key: string, group: string | null) => Promise, + reload: () => Promise, + t: TFunction, + confirmScope = false, +): Promise { + if (!items.length) throw new Error(t('pluginGroups:errors.missing')); + assertGroupItemsEditable(items, t); + if (confirmScope && !window.confirm(t('pluginGroups:confirmScope', { + count: items.length, + group: group ?? t('pluginGroups:ungrouped'), + items: items.map(groupItemLabel).join(', '), + }))) throw new GroupActionCancelled(); + + const failures: string[] = []; + const succeeded: string[] = []; + for (const item of items) { + try { + await save(item.key, group); + succeeded.push(groupItemLabel(item)); + } catch (error) { + failures.push(`${groupItemLabel(item)}: ${extractErrorMessage(error, t('pluginGroups:errors.write'))}`); + } + } + let refreshError = ''; + try { await reload(); } catch (error) { + refreshError = `${t('pluginGroups:errors.refresh')} ${extractErrorMessage(error, '')}`; + } + if (failures.length || refreshError) { + throw new Error([ + t('pluginGroups:errors.partial', { succeeded: succeeded.length, failed: failures.length }), + succeeded.length ? `${t('pluginGroups:errors.succeededItems')} ${succeeded.join(', ')}` : '', + ...(failures.length ? [t('pluginGroups:errors.failedItems'), ...failures] : []), + refreshError, + ].filter(Boolean).join('\n')); + } +} diff --git a/webui/src/hooks/useAgents.ts b/webui/src/hooks/useAgents.ts index 1113a63ad..d9a9b81b4 100644 --- a/webui/src/hooks/useAgents.ts +++ b/webui/src/hooks/useAgents.ts @@ -13,7 +13,7 @@ const agentsResource = createSharedResource({ const response = await agentAPI.list(); return Array.isArray(response.data) ? response.data : []; }, - fallbackDataOnError: [], + fallbackDataOnError: (previous) => previous, getErrorMessage: (err) => (err instanceof Error && err.message ? err.message : 'Failed to fetch agents'), }); @@ -63,7 +63,7 @@ export function useAgents() { useRefreshOnResume(refreshAndFetch); const refetch = useCallback( - (showLoading = true) => fetchAgents({ silent: !showLoading }), + (showLoading = true, rejectOnError = false) => fetchAgents({ silent: !showLoading, rejectOnError }), [fetchAgents], ); diff --git a/webui/src/hooks/usePluginViewMode.test.tsx b/webui/src/hooks/usePluginViewMode.test.tsx new file mode 100644 index 000000000..32ab9fd93 --- /dev/null +++ b/webui/src/hooks/usePluginViewMode.test.tsx @@ -0,0 +1,52 @@ +import { act, renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { usePluginViewMode } from './usePluginViewMode'; + +beforeEach(() => { vi.restoreAllMocks(); window.localStorage.clear(); }); + +describe('usePluginViewMode', () => { + it('defaults to the page native layout and stores only a user-requested view change', () => { + const { result } = renderHook(() => usePluginViewMode('agent', 'cards')); + expect(result.current[0]).toBe('cards'); + expect(window.localStorage.length).toBe(0); + act(() => result.current[1]('list')); + expect(result.current[0]).toBe('list'); + expect(window.localStorage.getItem('flocks:plugin-view:agent')).toBe('list'); + expect(window.localStorage.length).toBe(1); + }); + + it('restores valid view preferences and ignores untrusted stored values', () => { + window.localStorage.setItem('flocks:plugin-view:skill', 'cards'); + const { result, rerender } = renderHook(({ key }) => usePluginViewMode(key, 'list'), { initialProps: { key: 'skill' } }); + expect(result.current[0]).toBe('cards'); + window.localStorage.setItem('flocks:plugin-view:tool-all', '{"groups": ["not-a-view"]}'); + rerender({ key: 'tool-all' }); + expect(result.current[0]).toBe('list'); + }); + + it('never writes an old tab mode into a new key on key changes', () => { + window.localStorage.setItem('flocks:plugin-view:tool-all', 'cards'); + window.localStorage.setItem('flocks:plugin-view:tool-mcp', 'list'); + const { result, rerender } = renderHook(({ key }) => usePluginViewMode(key, 'list'), { initialProps: { key: 'tool-all' } }); + expect(result.current[0]).toBe('cards'); + rerender({ key: 'tool-mcp' }); + expect(result.current[0]).toBe('list'); + expect(window.localStorage.getItem('flocks:plugin-view:tool-mcp')).toBe('list'); + act(() => result.current[1]('cards')); + expect(window.localStorage.getItem('flocks:plugin-view:tool-mcp')).toBe('cards'); + rerender({ key: 'tool-all' }); + expect(result.current[0]).toBe('cards'); + rerender({ key: 'tool-api' }); + expect(result.current[0]).toBe('list'); + expect(window.localStorage.getItem('flocks:plugin-view:tool-api')).toBeNull(); + }); + + it('continues in memory when reading or writing browser storage fails', () => { + vi.spyOn(window.localStorage, 'getItem').mockImplementation(() => { throw new Error('blocked'); }); + vi.spyOn(window.localStorage, 'setItem').mockImplementation(() => { throw new Error('quota'); }); + const { result } = renderHook(() => usePluginViewMode('device', 'cards')); + expect(result.current[0]).toBe('cards'); + act(() => result.current[1]('list')); + expect(result.current[0]).toBe('list'); + }); +}); diff --git a/webui/src/hooks/usePluginViewMode.ts b/webui/src/hooks/usePluginViewMode.ts new file mode 100644 index 000000000..a854726e9 --- /dev/null +++ b/webui/src/hooks/usePluginViewMode.ts @@ -0,0 +1,32 @@ +import { useCallback, useState } from 'react'; + +export type PluginViewMode = 'cards' | 'list'; +const storageKey = (key: string) => `flocks:plugin-view:${key}`; + +function readMode(key: string, fallback: PluginViewMode): PluginViewMode { + try { + const value = window.localStorage.getItem(storageKey(key)); + return value === 'cards' || value === 'list' ? value : fallback; + } catch { + return fallback; + } +} + +/** Browser-only presentation preference. Never stores group definitions or membership. */ +export function usePluginViewMode(key: string, defaultMode: PluginViewMode): readonly [PluginViewMode, (mode: PluginViewMode) => void] { + const [state, setState] = useState(() => ({ key, mode: readMode(key, defaultMode) })); + const mode = state.key === key ? state.mode : readMode(key, defaultMode); + // Reset during render, not in a persistence effect that could copy an old tab's mode. + if (state.key !== key) setState({ key, mode }); + + const setMode = useCallback((next: PluginViewMode) => { + setState({ key, mode: next }); + try { + window.localStorage.setItem(storageKey(key), next); + } catch { + // Private browsing / storage quotas must not prevent changing the view. + } + }, [key]); + + return [mode, setMode] as const; +} diff --git a/webui/src/hooks/useTools.ts b/webui/src/hooks/useTools.ts index 4d40d93c2..385b4ca42 100644 --- a/webui/src/hooks/useTools.ts +++ b/webui/src/hooks/useTools.ts @@ -50,8 +50,10 @@ const toolsResource = createSharedResource({ let toolRefreshInFlight: Promise | null = null; const toolPageResources = new Map>(); -function normalizeToolPageParams(params: ToolListPageParams): Required { +function normalizeToolPageParams(params: ToolListPageParams): Required> & Pick { return { + // Do not collapse All (undefined) and Ungrouped ('') into one cache key. + group: params.group, source: params.source ?? '', category: params.category ?? '', sourceName: params.sourceName ?? '', @@ -191,6 +193,7 @@ export function useToolPage(params: ToolListPageParams) { const resource = useMemo( () => getToolPageResource(params), [ + params.group, params.source, params.category, params.sourceName, diff --git a/webui/src/hooks/useWorkflow.test.tsx b/webui/src/hooks/useWorkflow.test.tsx index ee77fa088..3e43a2690 100644 --- a/webui/src/hooks/useWorkflow.test.tsx +++ b/webui/src/hooks/useWorkflow.test.tsx @@ -53,7 +53,7 @@ describe('useWorkflows', () => { __resetWorkflowResourcesForTesting(); }); - it('clears workflows when a silent refetch fails', async () => { + it('preserves the last native inventory and exposes silent refetch failures', async () => { listMock.mockResolvedValueOnce({ data: [makeWorkflow()], }); @@ -72,7 +72,8 @@ describe('useWorkflows', () => { window.dispatchEvent(new Event('focus')); await waitFor(() => { - expect(result.current.workflows).toEqual([]); + expect(result.current.error).toBe('Session expired'); + expect(result.current.workflows).toHaveLength(1); }); nowSpy.mockRestore(); diff --git a/webui/src/hooks/useWorkflow.ts b/webui/src/hooks/useWorkflow.ts index c9f7dfb72..5e8217b59 100644 --- a/webui/src/hooks/useWorkflow.ts +++ b/webui/src/hooks/useWorkflow.ts @@ -5,6 +5,7 @@ import { useRefreshOnResume, useSharedResource, type SharedResource, + type SharedResourceFetchOptions, } from './useSharedResource'; const WORKFLOW_LIST_STALE_TIME_MS = 5000; @@ -37,7 +38,7 @@ function getWorkflowListResource(category?: string, status?: string): SharedReso const response = await workflowAPI.listSummaries({ category, status }); return Array.isArray(response.data) ? response.data : []; }, - fallbackDataOnError: [], + fallbackDataOnError: (previous) => previous, getErrorMessage: (err) => (err instanceof Error && err.message ? err.message : 'Failed to fetch workflows'), }); @@ -80,7 +81,7 @@ export function useWorkflows(category?: string, status?: string) { workflows, loading, error, - refetch: () => fetchWorkflows(), + refetch: (options?: SharedResourceFetchOptions) => fetchWorkflows(options), }; } diff --git a/webui/src/i18nResources.ts b/webui/src/i18nResources.ts index 721c842b6..847a3a12e 100644 --- a/webui/src/i18nResources.ts +++ b/webui/src/i18nResources.ts @@ -24,6 +24,7 @@ const LAZY_NAMESPACES = [ 'monitoring', 'notification', 'permission', + 'pluginGroups', 'session', 'skill', 'task', @@ -69,6 +70,7 @@ const lazyLocaleLoaders: Record import('./locales/en-US/monitoring.json'), notification: () => import('./locales/en-US/notification.json'), permission: () => import('./locales/en-US/permission.json'), + pluginGroups: () => import('./locales/en-US/pluginGroups.json'), session: () => import('./locales/en-US/session.json'), skill: () => import('./locales/en-US/skill.json'), task: () => import('./locales/en-US/task.json'), @@ -88,6 +90,7 @@ const lazyLocaleLoaders: Record import('./locales/zh-CN/monitoring.json'), notification: () => import('./locales/zh-CN/notification.json'), permission: () => import('./locales/zh-CN/permission.json'), + pluginGroups: () => import('./locales/zh-CN/pluginGroups.json'), session: () => import('./locales/zh-CN/session.json'), skill: () => import('./locales/zh-CN/skill.json'), task: () => import('./locales/zh-CN/task.json'), diff --git a/webui/src/locales/en-US/pluginGroups.json b/webui/src/locales/en-US/pluginGroups.json new file mode 100644 index 000000000..5b753ffef --- /dev/null +++ b/webui/src/locales/en-US/pluginGroups.json @@ -0,0 +1,61 @@ +{ + "title": "Business groups", + "navigation": "Filter by business group", + "countsDescription": "Derived from visible plugins. Drag plugins here, or press Alt+M on a plugin. Group order is personal.", + "all": "All", + "ungrouped": "Ungrouped", + "create": "New group", + "rename": "Rename", + "delete": "Delete group", + "renameNamed": "Rename group {{name}}", + "deleteNamed": "Delete group {{name}}", + "save": "Save", + "saving": "Saving…", + "saved": "Group changes saved.", + "cancel": "Cancel", + "close": "Close", + "confirmScope": "Change {{count}} visible plugins to “{{group}}”?\nScope: {{items}}\nOnly these plugins are affected. Saves are individual, not atomic. No plugins will be deleted.", + "dialog": { + "createTitle": "New business group", + "renameTitle": "Rename business group", + "moveTitle": "Move {{name}}", + "sharedDescription": "This changes each plugin’s native group attribute. A new group requires its first plugin; empty groups are not saved.", + "name": "Group name", + "firstPlugin": "First editable plugin", + "choosePlugin": "Choose a visible plugin", + "destination": "Destination group" + }, + "validation": { + "length": "Use 1–32 characters, excluding leading/trailing spaces and control characters.", + "duplicate": "A group with this name already exists. Choose another name.", + "firstPlugin": "Choose the first editable plugin. Empty groups cannot be saved." + }, + "readOnly": { + "system": "System-shipped definitions have a fixed business group, even for administrators.", + "builtinSkill": "Built-in Skill definitions, including their group, are read-only even for administrators.", + "admin": "Only administrators can edit these native settings.", + "catalog": "Unconfigured catalog entries are read-only. Install/configure the service before changing its group.", + "noEditable": "No editable plugins are visible." + }, + "errors": { + "missing": "The plugin or group is no longer visible. Refresh the list before trying again.", + "readOnlyMembers": "This whole-group operation is blocked by read-only members: {{items}}", + "write": "Could not save this plugin’s group.", + "partial": "Saved {{succeeded}}; failed {{failed}}. Successful changes are not rolled back.", + "succeededItems": "Saved plugins:", + "failedItems": "Failed plugins:", + "refresh": "The native list could not be refreshed. It may be stale; refresh before another change." + }, + "drag": { + "sourceLabel": "{{name}}. Press Alt+M to move to a business group.", + "reorderLabel": "Reorder group {{name}}", + "reorderHint": "Drag, or use Up/Down to change your personal group order.", + "orderPlaced": "Personal group order updated. No plugin attributes changed.", + "cancelled": "Cancelled. No plugin attributes changed." + }, + "view": { + "label": "View layout", + "cards": "Card view", + "list": "List view" + } +} diff --git a/webui/src/locales/zh-CN/pluginGroups.json b/webui/src/locales/zh-CN/pluginGroups.json new file mode 100644 index 000000000..5ce0b1325 --- /dev/null +++ b/webui/src/locales/zh-CN/pluginGroups.json @@ -0,0 +1,61 @@ +{ + "title": "业务分组", + "navigation": "按业务分组筛选", + "countsDescription": "从可见插件属性派生。拖动插件到此处,或在插件上按 Alt+M 移组。组顺序仅为个人偏好。", + "all": "全部", + "ungrouped": "未分组", + "create": "新建分组", + "rename": "重命名", + "delete": "删除分组", + "renameNamed": "重命名分组 {{name}}", + "deleteNamed": "删除分组 {{name}}", + "save": "保存", + "saving": "正在保存…", + "saved": "分组更改已保存。", + "cancel": "取消", + "close": "关闭", + "confirmScope": "将 {{count}} 个可见插件改为“{{group}}”?\n操作范围:{{items}}\n仅修改这些插件,逐个保存而非原子操作,不会删除插件。", + "dialog": { + "createTitle": "新建业务分组", + "renameTitle": "重命名业务分组", + "moveTitle": "移动 {{name}}", + "sharedDescription": "修改各插件原生 group 属性。新分组必须关联首个插件,不保存空组。", + "name": "分组名称", + "firstPlugin": "首个可编辑插件", + "choosePlugin": "选择一个可见插件", + "destination": "目标分组" + }, + "validation": { + "length": "名称需为 1–32 个字符,不计首尾空格,不能包含控制字符。", + "duplicate": "已有同名分组,请使用其他名称。", + "firstPlugin": "请选择首个可编辑插件,不能保存空组。" + }, + "readOnly": { + "system": "系统自带定义的业务分组固定,管理员也不能修改。", + "builtinSkill": "内置 Skill 定义(包括分组)完全只读,管理员也不能修改。", + "admin": "仅管理员可修改这些原生设置。", + "catalog": "未配置的目录项只读,安装或配置服务后才能修改分组。", + "noEditable": "当前没有可编辑的可见插件。" + }, + "errors": { + "missing": "插件或分组已不可见,请刷新清单后重试。", + "readOnlyMembers": "包含只读成员,不能整体操作:{{items}}", + "write": "无法保存此插件的分组。", + "partial": "已保存 {{succeeded}} 个,失败 {{failed}} 个。已成功的更改不会回滚。", + "succeededItems": "已保存插件:", + "failedItems": "失败插件:", + "refresh": "原生清单刷新失败,当前显示可能过期,请先刷新再操作。" + }, + "drag": { + "sourceLabel": "{{name}}。按 Alt+M 移入业务分组。", + "reorderLabel": "调整分组 {{name}} 的顺序", + "reorderHint": "拖动或按上/下方向键调整个人分组顺序。", + "orderPlaced": "个人分组顺序已更新,未修改插件属性。", + "cancelled": "已取消,未修改插件属性。" + }, + "view": { + "label": "展示布局", + "cards": "卡片视图", + "list": "列表视图" + } +} diff --git a/webui/src/pages/Agent/index.test.tsx b/webui/src/pages/Agent/index.test.tsx index a394accab..783f5e9cc 100644 --- a/webui/src/pages/Agent/index.test.tsx +++ b/webui/src/pages/Agent/index.test.tsx @@ -1,12 +1,25 @@ import type { ReactNode } from 'react'; -import { describe, expect, it, vi } from 'vitest'; -import { render, screen, within } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import type { Agent } from '@/api/agent'; import AgentPage from './index'; -const { mockUseAgents } = vi.hoisted(() => ({ +const { mockUseAgents, nativeAPI, sheetSpy } = vi.hoisted(() => ({ mockUseAgents: vi.fn(), + nativeAPI: { list: vi.fn(), update: vi.fn(), delete: vi.fn(), setDelegatable: vi.fn(), refresh: vi.fn() }, + sheetSpy: vi.fn(), })); +vi.mock('@/api/agent', () => ({ agentAPI: nativeAPI })); + +beforeEach(() => { + vi.clearAllMocks(); + window.localStorage.clear(); + nativeAPI.update.mockResolvedValue({ data: {} }); + nativeAPI.delete.mockResolvedValue({ data: {} }); + nativeAPI.refresh.mockResolvedValue({ data: {} }); +}); + vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => ({ @@ -54,10 +67,13 @@ vi.mock('@/components/common/EmptyState', () => ({ })); vi.mock('./AgentSheet', () => ({ - default: () => null, + default: ({ agent, onClose }: { agent?: Agent; onClose: () => void }) => { + sheetSpy(agent); + return
; + }, })); -function makeAgent(overrides: Record) { +function makeAgent(overrides: Partial): Agent { return { name: 'agent', description: 'Agent description', @@ -71,6 +87,159 @@ function makeAgent(overrides: Record) { }; } +function useInventory(agents: Agent[]) { + nativeAPI.list.mockResolvedValue({ data: agents }); + const refetch = vi.fn().mockResolvedValue(agents); + mockUseAgents.mockReturnValue({ agents, loading: false, error: null, refetch }); + return refetch; +} + +describe('AgentPage business groups and native layouts', () => { + it('filters before 12-item sub-agent pagination, keeps primary unpaginated and source chips unchanged', async () => { + const primary = Array.from({ length: 13 }, (_, index) => makeAgent({ name: `primary-${index}`, mode: 'primary', native: true, group: 'Engineering' })); + const sub = Array.from({ length: 26 }, (_, index) => makeAgent({ name: `sub-${index}`, native: index < 13, group: index % 2 === 0 ? 'Engineering' : null })); + useInventory([...primary, ...sub, makeAgent({ name: 'system-agent', tags: ['system'], group: 'Engineering' })]); + render(); + await screen.findByTitle('Engineering'); + expect(screen.getByText('primary-12')).toBeInTheDocument(); + expect(screen.queryByText('sub-12')).not.toBeInTheDocument(); + expect(screen.queryByText('system-agent')).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '2' })); + expect(screen.getByText('sub-12')).toBeInTheDocument(); + fireEvent.click(screen.getByTitle('Engineering')); + expect(screen.getByText('sub-0')).toBeInTheDocument(); + expect(screen.getByText('sub-22')).toBeInTheDocument(); + expect(screen.queryByText('sub-24')).not.toBeInTheDocument(); + expect(screen.queryByText('sub-1')).not.toBeInTheDocument(); + expect(screen.getByRole('tab', { name: '内置 13' })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: '自定义 13' })).toBeInTheDocument(); + expect(screen.getAllByText('Engineering')).toHaveLength(1); + fireEvent.click(screen.getByRole('button', { name: '2' })); + expect(screen.getByText('sub-24')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'view.list' })); + expect(screen.getByText('sub-24')).toBeInTheDocument(); + expect(screen.queryByText('sub-0')).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('tab', { name: '自定义 13' })); + expect(screen.getByText('sub-14')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /^ungrouped/ })); + expect(screen.getByRole('tab', { name: '自定义 13' })).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByText('sub-13')).toBeInTheDocument(); + expect(screen.queryByText('sub-0')).not.toBeInTheDocument(); + }); + + it('keeps native/custom actions and all agent fields in list view without resetting an open editor', async () => { + const native = makeAgent({ name: 'builtin', nameCn: '内置智能体', mode: 'primary', native: true, group: 'Engineering' }); + const custom = makeAgent({ name: 'analyst', nameCn: '分析智能体', descriptionCn: '原生描述', delegatable: true, model: { providerID: 'provider', modelID: 'model-x' }, group: 'Engineering' }); + const refetch = useInventory([native, custom]); + nativeAPI.setDelegatable.mockResolvedValue({ data: { ...custom, delegatable: false } }); + vi.spyOn(window, 'confirm').mockReturnValue(true); + render(); + await screen.findByTitle('Engineering'); + fireEvent.click(screen.getByRole('button', { name: 'view.list' })); + const nativeRow = screen.getByText('内置智能体').closest('div.group') as HTMLElement; + const customRow = screen.getByText('分析智能体').closest('div.group') as HTMLElement; + expect(nativeRow).toHaveClass('lg:flex-row'); + expect(within(nativeRow).getByRole('button', { name: '删除' })).toBeDisabled(); + expect(within(customRow).getByText('原生描述')).toBeInTheDocument(); + expect(within(customRow).getByText('model-x')).toBeInTheDocument(); + expect(within(customRow).getByText('自定义')).toBeInTheDocument(); + expect(within(customRow).getByText('可委托')).toBeInTheDocument(); + fireEvent.click(within(customRow).getByRole('switch')); + await waitFor(() => expect(nativeAPI.setDelegatable).toHaveBeenCalledWith('analyst', false)); + fireEvent.click(within(customRow).getByRole('button', { name: '编辑' })); + expect(sheetSpy).toHaveBeenLastCalledWith(expect.objectContaining({ name: 'analyst', model: custom.model })); + expect(sheetSpy.mock.lastCall?.[0]).toHaveProperty('group', 'Engineering'); + fireEvent.click(screen.getByRole('button', { name: 'view.cards' })); + expect(screen.getByTestId('agent-sheet')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Close sheet' })); + fireEvent.click(within(screen.getByText('分析智能体').closest('div.group') as HTMLElement).getByRole('button', { name: '删除' })); + await waitFor(() => expect(nativeAPI.delete).toHaveBeenCalledWith('analyst')); + expect(refetch).toHaveBeenCalled(); + expect(nativeAPI.update).not.toHaveBeenCalled(); + }); + + it('moves a user-defined native primary with a missing group and no rescan', async () => { + const custom = makeAgent({ name: 'user-primary', mode: 'primary', native: true, group_readonly: false }); + const refetch = useInventory([custom, makeAgent({ name: 'other', group: 'Engineering' })]); + render(); + const source = screen.getByText('user-primary').closest('div.group')?.parentElement as HTMLElement; + expect(source).toHaveAttribute('draggable', 'true'); + fireEvent.keyDown(source, { key: 'm', altKey: true }); + fireEvent.change(screen.getByRole('combobox'), { target: { value: 'Engineering' } }); + fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'save' })); + await waitFor(() => expect(nativeAPI.update).toHaveBeenCalledWith('user-primary', { group: 'Engineering' })); + expect(refetch).toHaveBeenCalledWith(false, true); + expect(nativeAPI.refresh).not.toHaveBeenCalled(); + expect(nativeAPI.setDelegatable).not.toHaveBeenCalled(); + }); + + it('locks actual shipped groups without changing primary/subagent actions or custom movability', async () => { + useInventory([ + makeAgent({ name: 'rex', mode: 'primary', native: true, group: 'Mixed', group_readonly: true }), + makeAgent({ name: 'core-sub', native: false, group: 'Mixed', group_readonly: true }), + makeAgent({ name: 'user-agent', native: true, group: 'Mixed', group_readonly: false }), + ]); + render(); + const locked = screen.getByText('rex').closest('div.group')!.parentElement!; + expect(locked).toHaveAttribute('draggable', 'false'); + expect(locked).toHaveAttribute('title', 'pluginGroups:readOnly.system'); + expect(screen.getByText('core-sub').closest('div.group')!.parentElement!).toHaveAttribute('draggable', 'false'); + expect(screen.getByRole('button', { name: 'renameNamed' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'deleteNamed' })).toBeDisabled(); + fireEvent.keyDown(locked, { key: 'm', altKey: true }); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'create' })); + expect(within(screen.getByRole('combobox')).getAllByRole('option').map((option) => option.getAttribute('value'))).toEqual(['', 'user-agent']); + fireEvent.click(screen.getByRole('button', { name: 'cancel' })); + fireEvent.keyDown(screen.getByText('user-agent').closest('div.group')!.parentElement!, { key: 'm', altKey: true }); + fireEvent.change(screen.getByRole('combobox'), { target: { value: '' } }); + fireEvent.click(screen.getByRole('button', { name: 'save' })); + await waitFor(() => expect(nativeAPI.update).toHaveBeenCalledExactlyOnceWith('user-agent', { group: null })); + }); + + it.each(['move', 'create', 'rename', 'delete'])('rechecks shipped ownership from fresh Agent rows before %s writes', async (operation) => { + const agent = makeAgent({ name: 'changed', group: 'Ops', group_readonly: false }); + useInventory([agent]); + nativeAPI.list.mockResolvedValue({ data: [{ ...agent, group_readonly: true }] }); + vi.spyOn(window, 'confirm').mockReturnValue(true); + render(); + if (operation === 'move') fireEvent.keyDown(screen.getByText('changed').closest('div.group')!.parentElement!, { key: 'm', altKey: true }); + else fireEvent.click(screen.getByRole('button', { name: operation === 'create' ? 'create' : `${operation}Named` })); + if (operation === 'create' || operation === 'rename') fireEvent.change(screen.getByRole('textbox'), { target: { value: 'New' } }); + if (operation === 'create') fireEvent.change(screen.getByRole('combobox'), { target: { value: 'changed' } }); + if (operation !== 'delete') fireEvent.click(screen.getByRole('button', { name: 'save' })); + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('pluginGroups:errors.readOnlyMembers')); + expect(nativeAPI.update).not.toHaveBeenCalled(); + expect(window.confirm).not.toHaveBeenCalled(); + }); + + it.each(['create', 'rename'])('checks refreshed native names before %s instead of merging another group', async (operation) => { + const agent = makeAgent({ name: 'analyst', group: 'Ops' }); + useInventory([agent]); + nativeAPI.list.mockResolvedValue({ data: [agent, makeAgent({ name: 'new', group: 'Existing' })] }); + render(); + fireEvent.click(screen.getByRole('button', { name: operation === 'create' ? 'create' : 'renameNamed' })); + fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Existing' } }); + if (operation === 'create') fireEvent.change(screen.getByRole('combobox'), { target: { value: 'analyst' } }); + fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'save' })); + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('pluginGroups:validation.duplicate')); + expect(nativeAPI.update).not.toHaveBeenCalled(); + }); + + it('keeps original agents visible when the native inventory reload fails after a move', async () => { + const refetch = useInventory([makeAgent({ name: 'still-visible', group: 'Engineering' })]); + refetch.mockRejectedValue(new Error('offline')); + render(); + fireEvent.keyDown(screen.getByText('still-visible').closest('div.group')?.parentElement as HTMLElement, { key: 'm', altKey: true }); + fireEvent.change(screen.getByRole('combobox'), { target: { value: '' } }); + fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'save' })); + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('offline')); + expect(screen.getByText('still-visible')).toBeInTheDocument(); + expect(nativeAPI.update).toHaveBeenCalledWith('still-visible', { group: null }); + expect(nativeAPI.refresh).not.toHaveBeenCalled(); + }); +}); + describe('AgentPage cards', () => { it('使用与工作流卡片一致的纯色扁平样式', () => { mockUseAgents.mockReturnValue({ diff --git a/webui/src/pages/Agent/index.tsx b/webui/src/pages/Agent/index.tsx index 68d1dab20..9fb0d6124 100644 --- a/webui/src/pages/Agent/index.tsx +++ b/webui/src/pages/Agent/index.tsx @@ -8,6 +8,10 @@ import { useAgents } from '@/hooks/useAgents'; import { agentAPI, Agent } from '@/api/agent'; import { getAgentDisplayDescription, getAgentDisplayName } from '@/utils/agentDisplay'; import AgentSheet from './AgentSheet'; +import GroupNav, { useGroupDrag, type GroupDrag } from '@/components/plugin-groups/GroupNav'; +import { deriveGroupNav, matchesGroup, saveGroupItems, type GroupSelection } from '@/components/plugin-groups/groupView'; +import PluginViewToggle from '@/components/plugin-groups/PluginViewToggle'; +import { usePluginViewMode, type PluginViewMode } from '@/hooks/usePluginViewMode'; // ============================================================================ // Main Page Component @@ -22,13 +26,40 @@ export default function AgentPage() { const { agents, loading, error, refetch } = useAgents(); const [refreshing, setRefreshing] = useState(false); const [refreshDone, setRefreshDone] = useState(false); + const [viewMode, setViewMode] = usePluginViewMode('agent', 'cards'); + const [groupSelection, setGroupSelection] = useState(null); + const asGroupItem = (agent: Agent) => ({ + key: agent.name, name: getAgentDisplayName(agent, i18n.language), group: agent.group, + readOnlyReason: agent.group_readonly ? t('pluginGroups:readOnly.system') : undefined, + }); + const visibleAgents = (inventory: Agent[]) => inventory.filter((agent) => agent.mode === 'primary' || !(agent.tags ?? []).includes('system')); + const groupItems = visibleAgents(agents).map(asGroupItem); + const groupDrag = useGroupDrag(groupItems); + const saveGroup = (name: string, group: string | null) => agentAPI.update(name, { group }); + const reloadGroups = () => refetch(false, true); + const loadGroupItems = async () => visibleAgents((await agentAPI.list()).data).map(asGroupItem); + const moveGroup = async (key: string, group: string | null) => { + const inventory = await loadGroupItems(); + await saveGroupItems(inventory.filter((item) => item.key === key), group, saveGroup, reloadGroups, t); + }; + const createGroup = async (key: string, group: string) => { + const inventory = await loadGroupItems(); + if (inventory.some((item) => matchesGroup(item.group, group))) throw new Error(t('pluginGroups:validation.duplicate')); + await saveGroupItems(inventory.filter((item) => item.key === key), group, saveGroup, reloadGroups, t); + }; + const changeGroup = async (from: string, to: string | null) => { + const inventory = await loadGroupItems(); + if (to !== null && inventory.some((item) => matchesGroup(item.group, to))) throw new Error(t('pluginGroups:validation.duplicate')); + await saveGroupItems(inventory.filter((item) => matchesGroup(item.group, from)), to, saveGroup, reloadGroups, t, true); + }; + const selectAgent = (agent: Agent) => setEditingAgent(agent); const handleRefresh = async () => { if (refreshing) return; try { setRefreshing(true); await Promise.all([ - agentAPI.refresh().then(() => refetch()), + agentAPI.refresh().then(() => refetch(false)), new Promise((r) => setTimeout(r, 600)), ]); setRefreshDone(true); @@ -80,7 +111,7 @@ export default function AgentPage() { ); } - if (error) { + if (error && agents.length === 0) { return (
@@ -104,12 +135,14 @@ export default function AgentPage() { icon={} /> + {error &&

{error}

} {/* Toolbar — mirrors the Skill page toolbar style */}
{t('totalCount', { total: primaryAgents.length + subAgents.length })}
+
-
+

{device.name}

@@ -1698,9 +1703,10 @@ function DeviceConfigPanel({ type RoomStatus = 'ok' | 'partial' | 'empty'; -function GroupSidebar({ groups, devices, selectedGroupId, onSelect, onRename, onDelete, onCreate }: { +function GroupSidebar({ groups, devices, selectedGroupId, onSelect, onRename, onDelete, onCreate, businessNav }: { groups: DeviceGroup[]; devices: DeviceIntegration[]; + businessNav: GroupNavProps; selectedGroupId: string | null; onSelect: (id: string | null) => void; onRename: (id: string, newName: string) => Promise; @@ -1960,6 +1966,10 @@ function GroupSidebar({ groups, devices, selectedGroupId, onSelect, onRename, on
)}
+ {/* Business groups share this 208px rail; room navigation stays independent. */} +
+ +
); } @@ -1988,6 +1998,7 @@ export default function DeviceIntegrationPage() { const rexStatusPollRef = useRef(null); // null = "全部机房" aggregate view; string = specific group id const [selectedGroupId, setSelectedGroupId] = useState(null); + const [viewMode, setViewMode] = usePluginViewMode('device', 'cards'); // Group ids whose section is collapsed in the "全部机房" view. Default // (absent) = expanded, so brand-new rooms show their devices immediately. const [collapsedGroups, setCollapsedGroups] = useState>(new Set()); @@ -2026,11 +2037,46 @@ export default function DeviceIntegrationPage() { resetRexSession(); }, [resetRexSession]); - // Devices shown in the main area (filtered by selected room) - const filteredDevices = useMemo( + // Keep native room selection and shared business membership independent. + // The full devices state remains authoritative for rooms, forms and wizard counts. + const roomDevices = useMemo( () => selectedGroupId ? devices.filter((d) => d.group_id === selectedGroupId) : devices, [devices, selectedGroupId], ); + const [businessGroup, setBusinessGroup] = useState(null); + // Device UUID instances own their group independently of the selected template. + const asBusinessItem = (device: DeviceIntegration) => ({ + key: device.id, name: device.name, group: device.group, + }); + const businessItems = devices.map(asBusinessItem); + const businessDrag = useGroupDrag(businessItems); + const reloadBusinessDevices = useCallback(async () => { + // Metadata updates never provision devices, rescan templates or change rooms. + const response = await deviceAPI.list(); + setDevices(response.data || []); + }, []); + const saveBusinessGroup = (id: string, group: string | null) => deviceAPI.update(id, { group }); + const loadBusinessItems = async () => (await deviceAPI.list()).data.map(asBusinessItem); + const moveBusinessGroup = async (key: string, group: string | null) => { + const inventory = await loadBusinessItems(); + await saveGroupItems(inventory.filter((item) => item.key === key), group, saveBusinessGroup, reloadBusinessDevices, t); + }; + const createBusinessGroup = async (key: string, group: string) => { + const inventory = await loadBusinessItems(); + if (inventory.some((item) => matchesGroup(item.group, group))) throw new Error(t('pluginGroups:validation.duplicate')); + await saveGroupItems(inventory.filter((item) => item.key === key), group, saveBusinessGroup, reloadBusinessDevices, t); + }; + const changeBusinessGroup = async (from: string, to: string | null) => { + const inventory = await loadBusinessItems(); + if (to !== null && inventory.some((item) => matchesGroup(item.group, to))) throw new Error(t('pluginGroups:validation.duplicate')); + await saveGroupItems(inventory.filter((item) => matchesGroup(item.group, from)), to, saveBusinessGroup, reloadBusinessDevices, t, true); + }; + const businessNav: GroupNavProps = { + preferenceKey: 'device', inventoryComplete: !loading && !refreshing, ...deriveGroupNav(businessItems), items: businessItems, + selection: businessGroup, onSelect: setBusinessGroup, onMove: moveBusinessGroup, onCreate: createBusinessGroup, + onRename: changeBusinessGroup, onDelete: (name) => changeBusinessGroup(name, null), ...businessDrag, + }; + const filteredDevices = roomDevices.filter((device) => matchesGroup(device.group, businessGroup)); const fetchData = useCallback(async ( silent = false, @@ -2352,8 +2398,8 @@ export default function DeviceIntegrationPage() { // Groups that actually render a section in the "全部机房" view (i.e. have at // least one device) — drives the collapse-all toggle. const nonEmptyGroupIds = useMemo( - () => groups.filter((g) => devices.some((d) => d.group_id === g.id)).map((g) => g.id), - [groups, devices], + () => groups.filter((g) => filteredDevices.some((d) => d.group_id === g.id)).map((g) => g.id), + [groups, filteredDevices], ); const allCollapsed = nonEmptyGroupIds.length > 0 && nonEmptyGroupIds.every((id) => collapsedGroups.has(id)); @@ -2370,6 +2416,7 @@ export default function DeviceIntegrationPage() { icon={} action={
+ {!collapsed && ( -
+
{gDevices.map((d) => ( - setPanel({ kind: 'edit', device: d })} - /> +
+ setPanel({ kind: 'edit', device: d })} + viewMode={viewMode} + /> +
))}
)} @@ -2537,7 +2587,7 @@ export default function DeviceIntegrationPage() { reachable — the "全部机房" view should never hide a device. */} {(() => { const known = new Set(groups.map((g) => g.id)); - const orphans = devices.filter((d) => !known.has(d.group_id)); + const orphans = filteredDevices.filter((d) => !known.has(d.group_id)); if (orphans.length === 0) return null; return (
@@ -2549,15 +2599,17 @@ export default function DeviceIntegrationPage() { {t('section.ungroupedHint')}
-
+
{orphans.map((d) => ( - setPanel({ kind: 'edit', device: d })} - /> +
+ setPanel({ kind: 'edit', device: d })} + viewMode={viewMode} + /> +
))}
@@ -2595,15 +2647,17 @@ export default function DeviceIntegrationPage() { {t('header.connected', { count: connectedCount })} )}
-
+
{filteredDevices.map((d) => ( - setPanel({ kind: 'edit', device: d })} - /> +
+ setPanel({ kind: 'edit', device: d })} + viewMode={viewMode} + /> +
))}
diff --git a/webui/src/pages/Home/index.test.tsx b/webui/src/pages/Home/index.test.tsx index 2283bbbd3..f53ce6084 100644 --- a/webui/src/pages/Home/index.test.tsx +++ b/webui/src/pages/Home/index.test.tsx @@ -94,7 +94,7 @@ describe('Home create WebUI contract page entry', () => { ); }); - it('hides the create WebUI contract page entry for non-admin users', () => { + it('keeps setup guidance available while hiding custom-page creation from members', () => { useAuthMock.mockReturnValue({ user: { id: 'user-2', @@ -114,9 +114,69 @@ describe('Home create WebUI contract page entry', () => { expect( screen.queryByRole('button', { name: 'createWebUIContractPage' }), ).not.toBeInTheDocument(); - expect( - screen.queryByRole('button', { name: 'getStarted' }), - ).not.toBeInTheDocument(); + // Onboarding is available to members; custom-page management is admin-only. + expect(screen.getByRole('button', { name: 'getStarted' })).toBeEnabled(); + expect(createMock).not.toHaveBeenCalled(); + }); + + it.each(['admin', 'member'])('opens onboarding for %s without creating or navigating to a custom-page session', async (role) => { + const user = userEvent.setup(); + useAuthMock.mockReturnValue({ + user: { + id: `user-${role}`, + username: role, + role, + status: 'active', + must_reset_password: false, + }, + }); + const onOpenOnboarding = vi.fn(); + window.addEventListener('flocks:open-onboarding', onOpenOnboarding); + try { + render( + + + , + ); + + await user.click(screen.getByRole('button', { name: 'getStarted' })); + + expect(onOpenOnboarding).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ type: 'flocks:open-onboarding' }), + ); + expect(createMock).not.toHaveBeenCalled(); + expect(navigateMock).not.toHaveBeenCalled(); + expect(toastErrorMock).not.toHaveBeenCalled(); + } finally { + window.removeEventListener('flocks:open-onboarding', onOpenOnboarding); + } + }); + + it('removes only custom-page creation when the current user changes from admin to member', () => { + const { rerender } = render( + + + , + ); + expect(screen.getByRole('button', { name: 'createWebUIContractPage' })).toBeEnabled(); + + useAuthMock.mockReturnValue({ + user: { + id: 'member-user', + username: 'member', + role: 'member', + status: 'active', + must_reset_password: false, + }, + }); + rerender( + + + , + ); + + expect(screen.queryByRole('button', { name: 'createWebUIContractPage' })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'getStarted' })).toBeEnabled(); expect(createMock).not.toHaveBeenCalled(); }); diff --git a/webui/src/pages/Skill/SkillSheet.test.tsx b/webui/src/pages/Skill/SkillSheet.test.tsx index 505a9f5be..76e372c32 100644 --- a/webui/src/pages/Skill/SkillSheet.test.tsx +++ b/webui/src/pages/Skill/SkillSheet.test.tsx @@ -2,12 +2,14 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen } from '@testing-library/react'; import React from 'react'; import SkillSheet from '@/pages/Skill/SkillSheet'; +import { skillAPI } from '@/api/skill'; // --------------------------------------------------------------------------- // Mocks // --------------------------------------------------------------------------- -vi.mock('@/api/skill', () => ({ +vi.mock('@/api/skill', async (importOriginal) => ({ + ...await importOriginal(), skillAPI: { create: vi.fn().mockResolvedValue({}), update: vi.fn().mockResolvedValue({}), @@ -101,6 +103,15 @@ describe('SkillSheet', () => { vi.clearAllMocks(); }); + it.each(['project', 'flocks'])('keeps all built-in definition fields read-only for source %s', (source) => { + render(); + expect(screen.getByText('sheet.readonlyNote')).toBeInTheDocument(); + expect(screen.queryByPlaceholderText('my-skill')).not.toBeInTheDocument(); + expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'sheet.edit' })).not.toBeInTheDocument(); + expect(skillAPI.update).not.toHaveBeenCalled(); + }); + describe('Create mode', () => { it('should show "创建 技能" title', () => { render(); diff --git a/webui/src/pages/Skill/SkillSheet.tsx b/webui/src/pages/Skill/SkillSheet.tsx index c1df862c5..19cdc0145 100644 --- a/webui/src/pages/Skill/SkillSheet.tsx +++ b/webui/src/pages/Skill/SkillSheet.tsx @@ -10,7 +10,7 @@ import { lazy, Suspense, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { BookOpen, Lock, Pencil, Eye, Save, Loader2, Trash2 } from 'lucide-react'; -import { skillAPI, Skill } from '@/api/skill'; +import { isSkillDefinitionReadOnly, skillAPI, Skill } from '@/api/skill'; import { useToast } from '@/components/common/Toast'; import EntitySheet from '@/components/common/EntitySheet'; import { buildGuidedCreateGroups } from '@/components/common/GuidedCreatePanel'; @@ -95,8 +95,8 @@ export default function SkillSheet({ skill, onClose, onSaved, onDeleted }: Skill const { t } = useTranslation('skill'); const toast = useToast(); const isEdit = !!skill; - // Custom skills (source !== 'project') are editable and deletable - const isUserSkill = isEdit && skill.source !== 'project'; + // User definitions remain editable; bundled/core definitions are always read-only. + const isUserSkill = isEdit && !isSkillDefinitionReadOnly(skill); const isReadonly = isEdit && !isUserSkill; // Strip YAML front matter — name/description are already shown as separate fields @@ -138,7 +138,7 @@ export default function SkillSheet({ skill, onClose, onSaved, onDeleted }: Skill }; const handleDelete = async () => { - if (!skill || skill.source === 'project') return; + if (!skill || isSkillDefinitionReadOnly(skill)) return; if (!confirm(t('sheet.deleteConfirm', { name: skill.name }))) return; try { setDeleting(true); @@ -152,7 +152,7 @@ export default function SkillSheet({ skill, onClose, onSaved, onDeleted }: Skill }; const handleSaveContent = async () => { - if (!isEdit || !skill) return; + if (!isEdit || !skill || isReadonly) return; try { setSaving(true); await skillAPI.update(skill.name, formData); diff --git a/webui/src/pages/Skill/index.test.tsx b/webui/src/pages/Skill/index.test.tsx index 32bfeabd2..2801b7fb6 100644 --- a/webui/src/pages/Skill/index.test.tsx +++ b/webui/src/pages/Skill/index.test.tsx @@ -1,10 +1,12 @@ import type { ReactNode } from 'react'; -import { render, screen, waitFor } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import SkillPage from './index'; +import zhSkill from '@/locales/zh-CN/skill.json'; -const { statusMock, listMock, refreshMock, toastErrorMock, toastSuccessMock, tMock } = vi.hoisted(() => ({ +const { statusMock, listMock, refreshMock, updateGroupMock, getMock, toggleMock, installDepsMock, toastErrorMock, toastSuccessMock, tMock } = vi.hoisted(() => ({ + updateGroupMock: vi.fn(), getMock: vi.fn(), toggleMock: vi.fn(), installDepsMock: vi.fn(), statusMock: vi.fn(), listMock: vi.fn(), refreshMock: vi.fn(), @@ -35,8 +37,10 @@ vi.mock('@/api/skill', async () => { status: statusMock, list: listMock, refresh: refreshMock, - get: vi.fn(), - installDeps: vi.fn(), + get: getMock, + updateGroup: updateGroupMock, + toggle: toggleMock, + installDeps: installDepsMock, delete: vi.fn(), }, }; @@ -82,9 +86,169 @@ function makeUiHiddenSkill(name: string) { }; } +vi.mock('./SkillSheet', () => ({ default: () =>
})); + describe('SkillPage', () => { beforeEach(() => { vi.clearAllMocks(); + localStorage.clear(); + const labels: Record = { 'table.type': zhSkill.table.type, 'table.name': zhSkill.table.name, + 'table.source': zhSkill.table.source, 'table.enabled': zhSkill.table.enabled, 'table.actions': zhSkill.table.actions }; + tMock.mockImplementation((key: string) => labels[key] ?? key); + vi.spyOn(window, 'confirm').mockReturnValue(true); + updateGroupMock.mockResolvedValue({ data: {} }); + refreshMock.mockResolvedValue({ data: {} }); + }); + + it.each(['project', 'flocks'])('blocks builtin %s group writes and mixed-group operations, preserving the default table', async (source) => { + let inventory = [ + { ...makeSkill('builtin'), source, group: 'Mixed', group_readonly: false }, + { ...makeSkill('custom'), group: 'Mixed' }, + { ...makeUiHiddenSkill('invisible'), group: 'Hidden' }, + ]; + statusMock.mockImplementation(async () => ({ data: inventory })); + listMock.mockImplementation(async () => ({ data: inventory })); + updateGroupMock.mockImplementation(async (name, group) => { + inventory = inventory.map((skill) => skill.name === name ? { ...skill, group } : skill); + return { data: {} }; + }); + render(); + await screen.findByText('builtin'); + expect(screen.getAllByRole('columnheader').map((cell) => cell.textContent)).toEqual(['类型', '名称', '来源', '启用', '操作']); + expect(screen.queryByText('Hidden')).not.toBeInTheDocument(); + const builtin = screen.getByText('builtin').closest('tr')!; + const custom = screen.getByText('custom').closest('tr')!; + expect(builtin).toHaveAttribute('draggable', 'false'); + expect(builtin).toHaveAttribute('title', 'pluginGroups:readOnly.builtinSkill'); + expect(within(builtin).getByText('table.builtin')).toBeInTheDocument(); + expect(within(custom).getByText('table.custom')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'renameNamed' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'deleteNamed' })).toBeDisabled(); + fireEvent.keyDown(builtin, { key: 'm', altKey: true }); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + fireEvent.keyDown(custom, { key: 'm', altKey: true }); + fireEvent.change(screen.getByRole('combobox'), { target: { value: '' } }); + fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'save' })); + await waitFor(() => expect(updateGroupMock).toHaveBeenCalledExactlyOnceWith('custom', null)); + expect(refreshMock).not.toHaveBeenCalled(); + expect(screen.getAllByRole('switch')).toHaveLength(2); + fireEvent.click(screen.getByRole('button', { name: 'view.cards' })); + expect(screen.getByText('builtin').closest('article')).toHaveAttribute('draggable', 'false'); + expect(screen.getByText('custom description')).toBeInTheDocument(); + expect(screen.getAllByRole('switch')).toHaveLength(2); + }); + + it('honors the server shipped flag without relying on a source label', async () => { + statusMock.mockResolvedValue({ data: [{ ...makeSkill('shipped'), group: 'System', group_readonly: true }] }); + render(); + await screen.findByText('shipped'); + const row = screen.getByText('shipped').closest('tr')!; + expect(row).toHaveAttribute('draggable', 'false'); + expect(row).toHaveAttribute('title', 'pluginGroups:readOnly.system'); + fireEvent.keyDown(row, { key: 'm', altKey: true }); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'create' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'renameNamed' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'deleteNamed' })).toBeDisabled(); + }); + + it.each(['move', 'create'])('rechecks selected Skill ownership before %s', async (operation) => { + const custom = { ...makeSkill('custom'), group: 'Ops', group_readonly: false }; + statusMock.mockResolvedValue({ data: [custom] }); + listMock.mockResolvedValue({ data: [{ ...custom, group_readonly: true }] }); + render(); + await screen.findByText('custom'); + if (operation === 'move') fireEvent.keyDown(screen.getByText('custom').closest('tr')!, { key: 'm', altKey: true }); + else { + fireEvent.click(screen.getByRole('button', { name: 'create' })); + fireEvent.change(screen.getByRole('textbox', { name: 'dialog.name' }), { target: { value: 'New' } }); + fireEvent.change(screen.getByRole('combobox'), { target: { value: 'custom' } }); + } + fireEvent.click(screen.getByRole('button', { name: 'save' })); + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('pluginGroups:errors.readOnlyMembers')); + expect(updateGroupMock).not.toHaveBeenCalled(); + }); + + it('keeps the original list columns readable and horizontally scrollable on narrow screens', async () => { + statusMock.mockResolvedValue({ data: [makeSkill('long-readable-skill-name')] }); + render(); + await screen.findByText('long-readable-skill-name'); + const table = screen.getByRole('table'); + expect(table).toHaveClass('min-w-[max(100%,40rem)]'); + expect(table.parentElement).toHaveClass('overflow-x-auto'); + expect(table.parentElement).not.toHaveClass('overflow-hidden'); + expect(within(table).getAllByRole('columnheader').map((cell) => cell.textContent)).toEqual(['类型', '名称', '来源', '启用', '操作']); + expect(within(table).getByRole('button', { name: 'table.edit' })).toBeEnabled(); + }); + + it.each(['create', 'rename'])('checks fresh skill group names before %s writes', async (operation) => { + const skill = { ...makeSkill('custom'), group: 'Ops' }; + statusMock.mockResolvedValue({ data: [skill] }); + listMock.mockResolvedValue({ data: [skill, { ...makeSkill('new'), group: 'Existing' }] }); + render(); + await screen.findByText('custom'); + fireEvent.click(screen.getByRole('button', { name: operation === 'create' ? 'create' : 'renameNamed' })); + fireEvent.change(screen.getByRole('textbox', { name: 'dialog.name' }), { target: { value: 'Existing' } }); + if (operation === 'create') fireEvent.change(screen.getByRole('combobox'), { target: { value: 'custom' } }); + fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'save' })); + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('pluginGroups:validation.duplicate')); + expect(updateGroupMock).not.toHaveBeenCalled(); + }); + + it.each(['rename', 'delete'])('prechecks newly discovered readonly group members before %s writes', async (operation) => { + const custom = { ...makeSkill('custom'), group: 'Ops' }; + statusMock.mockResolvedValue({ data: [custom] }); + listMock.mockResolvedValue({ data: [custom, { ...makeSkill('builtin'), source: 'flocks', group: 'Ops' }] }); + render(); + await screen.findByText('custom'); + fireEvent.click(screen.getByRole('button', { name: `${operation}Named` })); + if (operation === 'rename') { + fireEvent.change(screen.getByRole('textbox', { name: 'dialog.name' }), { target: { value: 'Renamed' } }); + fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'save' })); + } + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('pluginGroups:errors.readOnlyMembers')); + expect(updateGroupMock).not.toHaveBeenCalled(); + expect(window.confirm).not.toHaveBeenCalled(); + }); + + it('retains click, enable and dependency-install actions in the alternate view', async () => { + const skill = { ...makeSkill('needs-deps'), eligible: false, missing: ['binary'], install_specs: [{ kind: 'pip', package: 'demo' }] }; + statusMock.mockResolvedValue({ data: [skill] }); + getMock.mockResolvedValue({ data: skill }); + toggleMock.mockResolvedValue({ data: { disabled: true } }); + installDepsMock.mockResolvedValue({ data: { results: [{ success: true }] } }); + render(); + await screen.findByText('needs-deps'); + fireEvent.click(screen.getByRole('button', { name: 'view.cards' })); + const card = screen.getByText('needs-deps').closest('article')!; + fireEvent.click(within(card).getByRole('switch')); + await waitFor(() => expect(toggleMock).toHaveBeenCalledWith('needs-deps')); + fireEvent.click(within(card).getByRole('button', { name: 'eligibility.installDeps' })); + await waitFor(() => expect(installDepsMock).toHaveBeenCalledWith('needs-deps')); + fireEvent.click(within(card).getByRole('button', { name: 'table.edit' })); + await waitFor(() => expect(getMock).toHaveBeenCalledWith('needs-deps')); + expect(await screen.findByTestId('skill-sheet')).toBeInTheDocument(); + }); + + it('loads the original full list before whole-group edits and reports exact partial failures', async () => { + let inventory = [{ ...makeSkill('first'), group: 'Ops' as string | null }, { ...makeSkill('failed'), group: 'Ops' as string | null }]; + statusMock.mockImplementation(async () => ({ data: inventory })); + listMock.mockImplementation(async () => ({ data: inventory })); + updateGroupMock.mockImplementation(async (name, group) => { + if (name === 'failed') throw new Error('write denied'); + inventory = inventory.map((skill) => skill.name === name ? { ...skill, group } : skill); + return { data: {} }; + }); + render(); + await screen.findByText('first'); + fireEvent.click(screen.getByRole('button', { name: 'deleteNamed' })); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('failed: write denied')); + expect(listMock).toHaveBeenCalledOnce(); + expect(updateGroupMock).toHaveBeenCalledTimes(2); + expect(screen.getByRole('button', { name: 'Ops 1' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'ungrouped 1' })).toBeInTheDocument(); + expect(refreshMock).not.toHaveBeenCalled(); }); it('refreshes the list when the window regains focus', async () => { diff --git a/webui/src/pages/Skill/index.tsx b/webui/src/pages/Skill/index.tsx index 50e2bab9a..611c13c3c 100644 --- a/webui/src/pages/Skill/index.tsx +++ b/webui/src/pages/Skill/index.tsx @@ -19,10 +19,14 @@ import PageHeader from '@/components/common/PageHeader'; import LoadingSpinner from '@/components/common/LoadingSpinner'; import EmptyState from '@/components/common/EmptyState'; import { useToast } from '@/components/common/Toast'; -import { skillAPI, Skill } from '@/api/skill'; +import { isSkillDefinitionReadOnly, skillAPI, Skill } from '@/api/skill'; import { EnabledBadge } from '@/pages/Tool/components/badges'; import SkillSheet from './SkillSheet'; import SkillInstallDialog from './SkillInstallDialog'; +import GroupNav, { useGroupDrag, type GroupDrag } from '@/components/plugin-groups/GroupNav'; +import { deriveGroupNav, matchesGroup, saveGroupItems, type GroupSelection } from '@/components/plugin-groups/groupView'; +import { usePluginViewMode } from '@/hooks/usePluginViewMode'; +import PluginViewToggle from '@/components/plugin-groups/PluginViewToggle'; const PAGE_SIZE = 25; @@ -49,6 +53,7 @@ export default function SkillPage() { // the Tool list page's column-filter behavior. const [enabledFilter, setEnabledFilter] = useState>(new Set()); const [sourceColFilter, setSourceColFilter] = useState>(new Set()); + const [viewMode, setViewMode] = usePluginViewMode('skill', 'list'); // Throttle anchor for `refreshSkillsAndFetch` — visibility/focus listeners // can fire several times in a single second; without this guard the page // would hammer the backend every time the tab gets focus. @@ -103,6 +108,37 @@ export default function SkillPage() { [skills], ); + const [groupSelection, setGroupSelection] = useState(null); + const asGroupItem = (skill: Skill) => ({ + key: skill.name, name: skill.name, group: skill.group, + readOnlyReason: skill.group_readonly ? t('pluginGroups:readOnly.system') + : isSkillDefinitionReadOnly(skill) ? t('pluginGroups:readOnly.builtinSkill') : undefined, + }); + const groupItems = visibleSkills.map(asGroupItem); + const groupDrag = useGroupDrag(groupItems); + const refreshGroupingInventory = useCallback(async () => { + if (!await fetchSkills({ silent: true })) throw new Error('Skill inventory refresh failed'); + }, [fetchSkills]); + const saveGroup = (name: string, group: string | null) => skillAPI.updateGroup(name, group); + const loadGroupItems = async () => { + const { data } = await skillAPI.list(); + return data.filter((skill) => !skill.ui_hidden && skill.category !== 'system').map(asGroupItem); + }; + const moveGroup = async (key: string, group: string | null) => { + const inventory = await loadGroupItems(); + await saveGroupItems(inventory.filter((item) => item.key === key), group, saveGroup, refreshGroupingInventory, t); + }; + const createGroup = async (key: string, group: string) => { + const inventory = await loadGroupItems(); + if (inventory.some((item) => matchesGroup(item.group, group))) throw new Error(t('pluginGroups:validation.duplicate')); + await saveGroupItems(inventory.filter((item) => item.key === key), group, saveGroup, refreshGroupingInventory, t); + }; + const changeGroup = async (from: string, to: string | null) => { + const inventory = await loadGroupItems(); + if (to !== null && inventory.some((item) => matchesGroup(item.group, to))) throw new Error(t('pluginGroups:validation.duplicate')); + await saveGroupItems(inventory.filter((item) => matchesGroup(item.group, from)), to, saveGroup, refreshGroupingInventory, t, true); + }; + const enabledCount = useMemo( () => visibleSkills.filter(s => !s.disabled).length, [visibleSkills], @@ -118,6 +154,7 @@ export default function SkillPage() { const filteredSkills = useMemo(() => { const q = searchQuery.toLowerCase(); return visibleSkills.filter(skill => { + if (!matchesGroup(skill.group, groupSelection)) return false; const isUser = isUserManaged(skill); if (sourceFilter === 'builtin' && isUser) return false; if (sourceFilter === 'custom' && !isUser) return false; @@ -132,12 +169,12 @@ export default function SkillPage() { (skill.description || '').toLowerCase().includes(q) ); }); - }, [visibleSkills, searchQuery, sourceFilter, enabledFilter, sourceColFilter]); + }, [visibleSkills, searchQuery, sourceFilter, enabledFilter, sourceColFilter, groupSelection]); const hasColumnFilter = enabledFilter.size > 0 || sourceColFilter.size > 0; // Reset to first page whenever any filter changes - useEffect(() => { setPage(1); }, [searchQuery, sourceFilter, enabledFilter, sourceColFilter]); + useEffect(() => { setPage(1); }, [searchQuery, sourceFilter, enabledFilter, sourceColFilter, groupSelection]); const totalPages = Math.max(1, Math.ceil(filteredSkills.length / PAGE_SIZE)); @@ -326,6 +363,7 @@ export default function SkillPage() { {/* 右侧操作:刷新 + 安装 + 创建 */}
+
+
+ changeGroup(name, null)} {...groupDrag} /> +
{visibleSkills.length === 0 ? ( // Truly empty inventory — show big EmptyState with CTAs @@ -387,6 +428,8 @@ export default function SkillPage() { // as a single empty row inside the table so the column headers // and filter funnels stay visible and operable. setSourceColFilter(new Set())} - hasActiveFilter={sourceFilter !== 'all' || hasColumnFilter || !!searchQuery} + hasActiveFilter={sourceFilter !== 'all' || hasColumnFilter || !!searchQuery || groupSelection !== null} onClearAllFilters={() => { setSourceFilter('all'); setEnabledFilter(new Set()); setSourceColFilter(new Set()); setSearchQuery(''); + setGroupSelection(null); }} onSelect={handleSelectSkill} onInstallDeps={handleInstallDeps} @@ -432,6 +476,8 @@ export default function SkillPage() { onPageChange={setPage} /> )} +
+
{sheetSkill && ( ; @@ -499,6 +547,7 @@ interface SkillTableProps { } function SkillTable({ + viewMode = 'list', grouping, skills, selectedSkill, installingDeps, togglingSkills, enabledFilter, onToggleEnabledFilter, onClearEnabledFilter, allSources, sourceColFilter, onToggleSourceColFilter, onClearSourceColFilter, @@ -507,9 +556,45 @@ function SkillTable({ }: SkillTableProps) { const { t } = useTranslation('skill'); + if (viewMode === 'cards') { + return ( + <> +
+ + value === 'enabled' ? t('filter.enabled') : t('filter.disabled')} + /> +
+ {skills.length > 0 ? ( +
+ {skills.map((skill) => ( + + ))} +
+ ) : ( +
+ +
{t('emptyState.noResults')}
+ {hasActiveFilter && } +
+ )} + + ); + } + return ( -
- +
+
@@ -554,6 +639,7 @@ function SkillTable({ onSelect={onSelect} onInstallDeps={onInstallDeps} onToggle={onToggle} + grouping={grouping} /> ))} {skills.length === 0 && ( @@ -584,6 +670,7 @@ function SkillTable({ // ─── SkillRow ───────────────────────────────────────────────────────────────── interface SkillRowProps { + grouping?: GroupDrag; skill: Skill; isSelected: boolean; installingDeps: boolean; @@ -593,7 +680,7 @@ interface SkillRowProps { onToggle: (skill: Skill, e: React.MouseEvent) => void; } -function SkillRow({ skill, isSelected, installingDeps, toggling, onSelect, onInstallDeps, onToggle }: SkillRowProps) { +function SkillRow({ skill, isSelected, installingDeps, toggling, onSelect, onInstallDeps, onToggle, grouping }: SkillRowProps) { const { t } = useTranslation('skill'); const isUser = isUserManaged(skill); const hasMissingDeps = skill.eligible === false && (skill.install_specs?.length ?? 0) > 0; @@ -605,6 +692,7 @@ function SkillRow({ skill, isSelected, installingDeps, toggling, onSelect, onIns // buttons — stays independently clickable without nested-button warnings. return ( 0; + + return ( +
+
+ + onToggle(skill, event)} /> +
+ + {hasMissingDeps &&
+ + {t('eligibility.missingDepsInline', { list: (skill.missing ?? []).join(', ') })} +
} +
+ {t('table.source')} + {skill.source ?? '-'} +
+
+ {hasMissingDeps && } + +
+
+ ); +} + // ─── Sub-components ─────────────────────────────────────────────────────────── function SkillEnabledControl({ enabled, loading, title, onChange }: { diff --git a/webui/src/pages/Tool/ToolDetailDrawer.test.tsx b/webui/src/pages/Tool/ToolDetailDrawer.test.tsx index 7da6bdc61..3ea4c505c 100644 --- a/webui/src/pages/Tool/ToolDetailDrawer.test.tsx +++ b/webui/src/pages/Tool/ToolDetailDrawer.test.tsx @@ -3,6 +3,8 @@ import { describe, it, expect, vi } from 'vitest'; import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { ToolDetailDrawer } from './index'; +vi.mock('@/contexts/AuthContext', () => ({ useAuth: () => ({ user: { role: 'admin' } }) })); + vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string, options?: Record) => { diff --git a/webui/src/pages/Tool/ToolGroups.test.tsx b/webui/src/pages/Tool/ToolGroups.test.tsx new file mode 100644 index 000000000..e59d5e945 --- /dev/null +++ b/webui/src/pages/Tool/ToolGroups.test.tsx @@ -0,0 +1,432 @@ +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createInstance } from 'i18next'; +import { I18nextProvider, initReactI18next } from 'react-i18next'; +import ToolPage from './index'; +import { __resetToolsResourceForTesting } from '@/hooks/useTools'; +import type { Tool } from '@/api/tool'; +import zhTool from '@/locales/zh-CN/tool.json'; +import enTool from '@/locales/en-US/tool.json'; +import zhGroups from '@/locales/zh-CN/pluginGroups.json'; +import enGroups from '@/locales/en-US/pluginGroups.json'; + +const mocks = vi.hoisted(() => ({ + listPage: vi.fn(), get: vi.fn(), patch: vi.fn(), refresh: vi.fn(), listServices: vi.fn(), + auth: { role: 'admin' }, +})); +vi.mock('@/api/client', () => ({ default: { + get: (url: string, config?: { params?: Record }) => { + if (url === '/api/tools/page') return mocks.listPage(config?.params); + if (url.endsWith('/fixtures')) return Promise.resolve({ data: [] }); + return mocks.get(url); + }, + patch: (...args: unknown[]) => mocks.patch(...args), + post: (...args: unknown[]) => mocks.refresh(...args), +} })); +vi.mock('@/contexts/AuthContext', () => ({ useAuth: () => ({ user: mocks.auth }) })); +vi.mock('@/api/provider', () => ({ providerAPI: { listApiServices: mocks.listServices } })); +vi.mock('@/components/common/Toast', () => ({ + useToast: () => ({ error: vi.fn(), warning: vi.fn(), success: vi.fn() }), +})); + +let inventory: Tool[]; +const nativeRow = (name: string) => screen.getByRole('button', { name: new RegExp(`^${name}(?: |$)`) }).closest('[draggable]') as HTMLElement; + +async function mount(language = 'zh-CN') { + const i18n = createInstance(); + await i18n.use(initReactI18next).init({ + lng: language, + resources: { + 'zh-CN': { tool: zhTool, pluginGroups: zhGroups }, + 'en-US': { tool: enTool, pluginGroups: enGroups }, + }, + interpolation: { escapeValue: false }, + }); + const result = render(); + await screen.findByRole('button', { name: 'tool_00' }); + return result; +} + +beforeEach(() => { + vi.clearAllMocks(); + window.localStorage.clear(); + __resetToolsResourceForTesting(); + mocks.auth.role = 'admin'; + vi.spyOn(window, 'confirm').mockReturnValue(true); + inventory = Array.from({ length: 61 }, (_, index) => ({ + name: `tool_${String(index).padStart(2, '0')}`, + description: `Complete description ${index}`, + category: index % 2 ? 'file' : 'custom', + source: 'plugin_py', source_name: 'Native provider', enabled: true, + parameters: [], requires_confirmation: false, + group: index < 30 ? 'Alpha' : index < 60 ? 'Beta' : null, + })); + mocks.listServices.mockResolvedValue({ data: [] }); + mocks.get.mockImplementation(async (url: string) => ({ data: inventory.find((tool) => url === `/api/tools/${tool.name}`) })); + mocks.listPage.mockImplementation(async (params: Record) => { + const eligible = inventory.filter((tool) => (!params.q || tool.name.includes(params.q)) + && (!params.source || params.source.split(',').includes(tool.source)) + && (!params.category || params.category.split(',').includes(tool.category)) + && (!params.enabled || params.enabled.split(',').includes(String(tool.enabled)))); + const group: Record = {}; + eligible.forEach((tool) => { const name = tool.group?.trim() || ''; group[name] = (group[name] ?? 0) + 1; }); + const selected = eligible.filter((tool) => params.group === undefined || (tool.group?.trim() || '') === params.group); + return { data: { + items: selected.slice(params.offset ?? 0, (params.offset ?? 0) + (params.limit ?? 25)), + total: selected.length, offset: params.offset ?? 0, limit: params.limit ?? 25, + facets: { group, category: { custom: 31, file: 30 }, source: { plugin_py: selected.length }, source_groups: {}, source_name: { 'Native provider': selected.length }, enabled: { true: selected.length } }, + } }; + }); + mocks.patch.mockImplementation(async (url: string, request: { group: string | null }) => { + const item = inventory.find((tool) => url === `/api/tools/${encodeURIComponent(tool.name)}`)!; + item.group = request.group; + return { data: item }; + }); +}); + +describe('Tools native group attributes and original view preservation', () => { + it.each([ + ['zh-CN', ['工具名称', '来源', '供应商', '状态', '操作'], '管理'], + ['en-US', ['Tool Name', 'Source', 'Provider', 'Status', 'Actions'], 'Manage'], + ] as const)('keeps all native list columns, grids and actions in %s', async (language, labels, manage) => { + await mount(language); + const header = screen.getByText(labels[0]).parentElement!; + expect(Array.from(header.children).map((cell) => cell.textContent)).toEqual(['', ...labels]); + expect(header.style.gridTemplateColumns).toBe('32px minmax(220px, 3fr) minmax(80px, 1fr) minmax(140px, 1.6fr) minmax(80px, 1fr) minmax(90px, 1fr)'); + const row = nativeRow('tool_00'); + expect(row.children).toHaveLength(6); + expect(row.style.gridTemplateColumns).toBe(header.style.gridTemplateColumns); + expect(within(row).getByText('Native provider')).toBeInTheDocument(); + expect(screen.queryAllByRole('checkbox')).toHaveLength(0); + expect(screen.getAllByText('Alpha')).toHaveLength(1); + fireEvent.click(within(row).getByRole('button', { name: manage })); + await waitFor(() => expect(mocks.get).toHaveBeenCalledWith('/api/tools/tool_00')); + expect(await screen.findByText('Complete description 0')).toBeInTheDocument(); + }); + + it('uses full facets, scalar group filters, distinct All/Ungrouped cache keys and offset zero', async () => { + await mount(); + const nav = screen.getByRole('complementary', { name: '业务分组' }); + expect(within(nav).getByRole('button', { name: '全部 61' })).toBeInTheDocument(); + expect(within(nav).getByRole('button', { name: '未分组 1' })).toBeInTheDocument(); + fireEvent.change(screen.getByPlaceholderText(zhTool.search.placeholder), { target: { value: 'tool_' } }); + await waitFor(() => expect(mocks.listPage).toHaveBeenLastCalledWith(expect.objectContaining({ q: 'tool_' }))); + fireEvent.click(screen.getByRole('button', { name: '2' })); + await screen.findByRole('button', { name: 'tool_25' }); + fireEvent.click(screen.getByRole('button', { name: 'Beta 30' })); + await screen.findByRole('button', { name: 'tool_30' }); + const betaCalls = mocks.listPage.mock.calls.filter(([params]) => params.group === 'Beta'); + expect(betaCalls).toHaveLength(1); + expect(betaCalls[0][0]).toEqual(expect.objectContaining({ group: 'Beta', offset: 0, q: 'tool_', sort_by: 'source', sort_dir: 'asc' })); + fireEvent.click(screen.getByRole('button', { name: '2' })); + await screen.findByRole('button', { name: 'tool_55' }); + const originalText = nativeRow('tool_55').textContent; + const requests = mocks.listPage.mock.calls.length; + fireEvent.click(screen.getByRole('button', { name: '卡片视图' })); + expect(nativeRow('tool_55').textContent).toBe(originalText); + expect(mocks.listPage).toHaveBeenCalledTimes(requests); + fireEvent.click(screen.getByRole('button', { name: '全部 61' })); + await screen.findByRole('button', { name: 'tool_00' }); + expect(mocks.listPage).toHaveBeenCalledWith(expect.objectContaining({ group: undefined, offset: 0, q: 'tool_' })); + fireEvent.click(screen.getByRole('button', { name: '未分组 1' })); + await screen.findByRole('button', { name: 'tool_60' }); + expect(mocks.listPage).toHaveBeenLastCalledWith(expect.objectContaining({ group: '', offset: 0, q: 'tool_' })); + }); + + it.each(['tool_30', 'no-matching-tools'])('retains Alpha when search facets only show Beta or none (%s)', async (query) => { + await mount(); + fireEvent.click(screen.getByRole('button', { name: 'Alpha 30' })); + await waitFor(() => expect(screen.getByRole('button', { name: 'Alpha 30' })).toHaveAttribute('aria-pressed', 'true')); + fireEvent.change(screen.getByPlaceholderText(zhTool.search.placeholder), { target: { value: query } }); + const selected = await screen.findByRole('button', { name: 'Alpha 0' }); + expect(selected).toHaveAttribute('aria-pressed', 'true'); + expect(screen.queryByRole('button', { name: 'tool_30' })).not.toBeInTheDocument(); + expect(mocks.listPage).toHaveBeenLastCalledWith(expect.objectContaining({ group: 'Alpha', q: query })); + fireEvent.change(screen.getByPlaceholderText(zhTool.search.placeholder), { target: { value: '' } }); + await waitFor(() => expect(screen.getByRole('button', { name: 'Alpha 30' })).toHaveAttribute('aria-pressed', 'true')); + expect(await screen.findByRole('button', { name: 'tool_00' })).toBeInTheDocument(); + }); + + it('retains Alpha when a column filter only matches Beta', async () => { + inventory.forEach((tool) => { tool.enabled = tool.group !== 'Alpha'; }); + await mount(); + fireEvent.click(screen.getByRole('button', { name: 'Alpha 30' })); + await waitFor(() => expect(screen.getByRole('button', { name: 'Alpha 30' })).toHaveAttribute('aria-pressed', 'true')); + const header = screen.getByRole('button', { name: '状态' }).parentElement!; + fireEvent.click(within(header).getAllByRole('button')[1]); + fireEvent.click(screen.getByRole('checkbox', { name: zhTool.table.enabledLabel })); + await waitFor(() => expect(screen.getByRole('button', { name: 'Alpha 0' })).toHaveAttribute('aria-pressed', 'true')); + expect(mocks.listPage).toHaveBeenLastCalledWith(expect.objectContaining({ group: 'Alpha', enabled: 'true' })); + }); + + it('retains the selected group across Local category and zero-match query filters', async () => { + await mount(); + fireEvent.click(screen.getByRole('button', { name: /^本地工具/ })); + await screen.findByRole('button', { name: /^tool_00 / }); + fireEvent.click(screen.getByRole('button', { name: 'Alpha 30' })); + await waitFor(() => expect(screen.getByRole('button', { name: 'Alpha 30' })).toHaveAttribute('aria-pressed', 'true')); + fireEvent.click(screen.getByRole('button', { name: `${zhTool.category.file} (12)` })); + expect(screen.getByRole('button', { name: 'Alpha 30' })).toHaveAttribute('aria-pressed', 'true'); + expect(screen.queryByRole('button', { name: /^tool_00 / })).not.toBeInTheDocument(); + fireEvent.change(screen.getByPlaceholderText(zhTool.search.placeholder), { target: { value: 'tool_30' } }); + await waitFor(() => expect(screen.getByRole('button', { name: 'Alpha 0' })).toHaveAttribute('aria-pressed', 'true')); + expect(mocks.listPage).toHaveBeenLastCalledWith(expect.objectContaining({ group: 'Alpha', q: 'tool_30' })); + }); + + it('preserves a selected group after its first page request fails and retries that group', async () => { + await mount(); + const originalPage = mocks.listPage.getMockImplementation()!; + mocks.listPage.mockImplementation((params) => params.group === 'Beta' ? Promise.reject(new Error('group page offline')) : originalPage(params)); + fireEvent.click(screen.getByRole('button', { name: 'Beta 30' })); + await screen.findByText('group page offline'); + expect(screen.queryByRole('button', { name: 'tool_00' })).not.toBeInTheDocument(); + expect(mocks.listPage).toHaveBeenLastCalledWith(expect.objectContaining({ group: 'Beta' })); + mocks.listPage.mockImplementation(originalPage); + mocks.refresh.mockResolvedValue({ data: { status: 'success', tool_count: inventory.length, message: '', stages: {}, errors: [] } }); + fireEvent.click(screen.getByTitle(zhTool.button.refreshList)); + await screen.findByRole('button', { name: 'tool_30' }); + expect(screen.getByRole('button', { name: 'Beta 30' })).toHaveAttribute('aria-pressed', 'true'); + expect(mocks.listPage).toHaveBeenLastCalledWith(expect.objectContaining({ group: 'Beta', offset: 0 })); + }); + + it('creates a group only after query-independent name validation with a single-field native save', async () => { + await mount(); + fireEvent.click(screen.getByRole('button', { name: '新建分组' })); + fireEvent.change(screen.getByRole('textbox', { name: '分组名称' }), { target: { value: 'New native group' } }); + fireEvent.change(screen.getByRole('combobox'), { target: { value: 'tool_00' } }); + fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: '保存' })); + await waitFor(() => expect(mocks.patch).toHaveBeenCalledExactlyOnceWith('/api/tools/tool_00', { group: 'New native group' })); + await screen.findByRole('button', { name: 'New native group 1' }); + expect(mocks.listPage).toHaveBeenCalledWith(expect.objectContaining({ limit: 1, offset: 0, q: undefined })); + expect(mocks.refresh).not.toHaveBeenCalled(); + }); + + it.each(['create', 'rename'])('rejects %s collisions outside filtered facets before any native writes', async (operation) => { + await mount(); + fireEvent.change(screen.getByPlaceholderText(zhTool.search.placeholder), { target: { value: 'tool_00' } }); + await waitFor(() => expect(mocks.listPage).toHaveBeenLastCalledWith(expect.objectContaining({ q: 'tool_00' }))); + await screen.findByRole('button', { name: 'Alpha 1' }); + expect(screen.queryByRole('button', { name: 'Beta 30' })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: operation === 'create' ? '新建分组' : '重命名分组 Alpha' })); + fireEvent.change(screen.getByRole('textbox', { name: '分组名称' }), { target: { value: 'Beta' } }); + if (operation === 'create') fireEvent.change(screen.getByRole('combobox'), { target: { value: 'tool_00' } }); + fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: '保存' })); + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent(zhGroups.validation.duplicate)); + expect(mocks.listPage).toHaveBeenLastCalledWith(expect.objectContaining({ limit: 1, offset: 0, q: undefined, source: undefined, group: undefined })); + expect(mocks.patch).not.toHaveBeenCalled(); + expect(window.confirm).not.toHaveBeenCalled(); + }); + + it.each(['api', 'mcp'])('keeps the native %s group selected while a child-tool search loads', async (tab) => { + mocks.listServices.mockResolvedValue({ data: [ + { id: 'native-a', name: 'Native service A', group: 'Services', enabled: true, status: 'connected', tool_count: 0 }, + { id: 'native-b', name: 'Native service B', group: null, enabled: true, status: 'connected', tool_count: 0 }, + ] }); + const originalGet = mocks.get.getMockImplementation()!; + mocks.get.mockImplementation((url: string) => { + if (url === '/api/mcp') return Promise.resolve({ data: { + 'native-a': { status: 'connected', group: 'Services', tools: [], resources: [] }, + 'native-b': { status: 'connected', group: null, tools: [], resources: [] }, + } }); + if (url.startsWith('/api/mcp/catalog/')) return Promise.resolve({ data: url.endsWith('/categories') ? {} : [] }); + return originalGet(url); + }); + await mount(); + fireEvent.click(screen.getByRole('button', { name: tab === 'api' ? /^API 集成/ : /^MCP 服务/ })); + const groupButton = await screen.findByRole('button', { name: 'Services 1' }); + fireEvent.click(groupButton); + await waitFor(() => expect(screen.getByRole('button', { name: 'Services 1' })).toHaveAttribute('aria-pressed', 'true')); + const originalPage = mocks.listPage.getMockImplementation()!; + let resolvePage!: (result: unknown) => void; + mocks.listPage.mockImplementation((params) => params.q ? new Promise((resolve) => { resolvePage = resolve; }) : originalPage(params)); + fireEvent.change(screen.getByPlaceholderText(zhTool.search.placeholder), { target: { value: 'native' } }); + await waitFor(() => expect(resolvePage).toBeDefined()); + expect(screen.getByRole('button', { name: 'Services 1' })).toBe(groupButton); + expect(groupButton).toHaveAttribute('aria-pressed', 'true'); + resolvePage(await originalPage({ q: 'native', source: tab, offset: 0, limit: 25 })); + await waitFor(() => expect(screen.getByRole('button', { name: 'Services 1' })).toHaveAttribute('aria-pressed', 'true')); + expect(screen.queryByText(tab === 'api' ? 'Native service B' : 'native-b')).not.toBeInTheDocument(); + }); + + it('moves one native row with the keyboard equivalent and only reloads metadata', async () => { + await mount(); + fireEvent.keyDown(nativeRow('tool_00'), { key: 'm', altKey: true }); + fireEvent.change(screen.getByRole('combobox'), { target: { value: 'Beta' } }); + fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: '保存' })); + await waitFor(() => expect(mocks.patch).toHaveBeenCalledExactlyOnceWith('/api/tools/tool_00', { group: 'Beta' })); + await screen.findByRole('button', { name: 'Beta 31' }); + expect(mocks.refresh).not.toHaveBeenCalled(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('loads every matching native page before confirming and renaming, not just 25 rows', async () => { + inventory = Array.from({ length: 205 }, (_, index) => ({ ...inventory[0], name: `tool_${String(index).padStart(2, '0')}`, group: 'Alpha' })); + await mount(); + fireEvent.click(screen.getByRole('button', { name: '重命名分组 Alpha' })); + fireEvent.change(screen.getByRole('textbox', { name: '分组名称' }), { target: { value: 'Renamed' } }); + fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: '保存' })); + await waitFor(() => expect(mocks.patch).toHaveBeenCalledTimes(205)); + expect(mocks.listPage).toHaveBeenCalledWith(expect.objectContaining({ group: 'Alpha', offset: 0, limit: 200 })); + expect(mocks.listPage).toHaveBeenCalledWith(expect.objectContaining({ group: 'Alpha', offset: 200, limit: 200 })); + expect(window.confirm).toHaveBeenCalledWith(expect.stringContaining('205')); + expect(window.confirm).toHaveBeenCalledWith(expect.stringContaining('tool_204')); + expect(mocks.patch).toHaveBeenLastCalledWith('/api/tools/tool_204', { group: 'Renamed' }); + expect(mocks.refresh).not.toHaveBeenCalled(); + }); + + it('locks shipped rows even when their source is not builtin, while custom members remain movable', async () => { + inventory[0].group_readonly = true; + inventory[1].group_readonly = false; + await mount(); + expect(nativeRow('tool_00')).toHaveAttribute('draggable', 'false'); + expect(nativeRow('tool_00')).toHaveAttribute('title', zhGroups.readOnly.system); + expect(screen.getByRole('button', { name: '重命名分组 Alpha' })).toBeDisabled(); + expect(screen.getByRole('button', { name: '删除分组 Alpha' })).toBeDisabled(); + fireEvent.keyDown(nativeRow('tool_00'), { key: 'm', altKey: true }); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '新建分组' })); + expect(within(screen.getByRole('combobox')).queryByRole('option', { name: 'tool_00' })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '取消' })); + fireEvent.keyDown(nativeRow('tool_01'), { key: 'm', altKey: true }); + fireEvent.change(screen.getByRole('combobox'), { target: { value: '' } }); + fireEvent.click(screen.getByRole('button', { name: '保存' })); + await waitFor(() => expect(mocks.patch).toHaveBeenCalledExactlyOnceWith('/api/tools/tool_01', { group: null })); + }); + + it.each(['move', 'create'])('checks fresh tool ownership before a selected-row %s', async (operation) => { + await mount(); + mocks.get.mockResolvedValue({ data: { ...inventory[0], group_readonly: true } }); + if (operation === 'move') fireEvent.keyDown(nativeRow('tool_00'), { key: 'm', altKey: true }); + else { + fireEvent.click(screen.getByRole('button', { name: '新建分组' })); + fireEvent.change(screen.getByRole('textbox', { name: '分组名称' }), { target: { value: 'New' } }); + fireEvent.change(screen.getByRole('combobox'), { target: { value: 'tool_00' } }); + } + fireEvent.click(screen.getByRole('button', { name: '保存' })); + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent(zhGroups.readOnly.system)); + expect(mocks.patch).not.toHaveBeenCalled(); + }); + + it.each(['rename', 'delete'])('finds a locked tool beyond page 200 before whole-group %s', async (operation) => { + inventory = Array.from({ length: 205 }, (_, index) => ({ ...inventory[0], name: `tool_${String(index).padStart(2, '0')}`, group: 'Alpha', group_readonly: index === 204 })); + await mount(); + fireEvent.click(screen.getByRole('button', { name: operation === 'rename' ? '重命名分组 Alpha' : '删除分组 Alpha' })); + if (operation === 'rename') { + fireEvent.change(screen.getByRole('textbox', { name: '分组名称' }), { target: { value: 'New' } }); + fireEvent.click(screen.getByRole('button', { name: '保存' })); + } + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('tool_204')); + expect(mocks.listPage).toHaveBeenCalledWith(expect.objectContaining({ group: 'Alpha', offset: 200, limit: 200 })); + expect(mocks.patch).not.toHaveBeenCalled(); + expect(window.confirm).not.toHaveBeenCalled(); + }); + + it.each([ + ['rename', 'search'], ['delete', 'search'], ['rename', 'local'], ['delete', 'local'], + ])('audits hidden locked members before %s under the %s filter', async (operation, filter) => { + inventory[29].group_readonly = true; + inventory[29].source = 'api'; + await mount(); + if (filter === 'search') { + fireEvent.change(screen.getByPlaceholderText(zhTool.search.placeholder), { target: { value: 'tool_00' } }); + await screen.findByRole('button', { name: 'Alpha 1' }); + } else { + fireEvent.click(screen.getByRole('button', { name: /^本地工具/ })); + await screen.findByRole('button', { name: 'Alpha 29' }); + } + fireEvent.click(screen.getByRole('button', { name: operation === 'rename' ? '重命名分组 Alpha' : '删除分组 Alpha' })); + if (operation === 'rename') { + fireEvent.change(screen.getByRole('textbox', { name: '分组名称' }), { target: { value: 'New' } }); + fireEvent.click(screen.getByRole('button', { name: '保存' })); + } + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('tool_29')); + expect(mocks.listPage).toHaveBeenCalledWith(expect.objectContaining({ group: 'Alpha', q: undefined, source: undefined, limit: 200 })); + expect(mocks.patch).not.toHaveBeenCalled(); + expect(window.confirm).not.toHaveBeenCalled(); + }); + + it('deletes with one explicit filtered-scope confirmation and preserves a still-existing selection', async () => { + await mount(); + fireEvent.click(screen.getByRole('button', { name: 'Alpha 30' })); + await waitFor(() => expect(screen.getByRole('button', { name: 'Alpha 30' })).toHaveAttribute('aria-pressed', 'true')); + fireEvent.change(screen.getByPlaceholderText(zhTool.search.placeholder), { target: { value: 'tool_00' } }); + await screen.findByRole('button', { name: 'Alpha 1' }); + fireEvent.click(screen.getByRole('button', { name: '删除分组 Alpha' })); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + await waitFor(() => expect(mocks.patch).toHaveBeenCalledExactlyOnceWith('/api/tools/tool_00', { group: null })); + await waitFor(() => expect(screen.getByRole('button', { name: 'Alpha 0' })).toHaveAttribute('aria-pressed', 'true')); + expect(window.confirm).toHaveBeenCalledTimes(1); + const confirmation = vi.mocked(window.confirm).mock.calls[0][0]!; + expect(confirmation).toContain('tool_00'); + expect(confirmation).not.toContain('tool_01'); + expect(mocks.listPage).toHaveBeenCalledWith(expect.objectContaining({ limit: 1, group: undefined, q: undefined })); + expect(inventory[1].group).toBe('Alpha'); + }); + + it('clears a filtered selection only after the unfiltered summary confirms the last member left', async () => { + inventory.forEach((tool, index) => { if (index > 0 && tool.group === 'Alpha') tool.group = 'Beta'; }); + await mount(); + fireEvent.click(screen.getByRole('button', { name: 'Alpha 1' })); + fireEvent.change(screen.getByPlaceholderText(zhTool.search.placeholder), { target: { value: 'tool_00' } }); + await waitFor(() => expect(mocks.listPage).toHaveBeenLastCalledWith(expect.objectContaining({ group: 'Alpha', q: 'tool_00' }))); + await screen.findByRole('button', { name: 'Alpha 1' }); + fireEvent.click(screen.getByRole('button', { name: '删除分组 Alpha' })); + await waitFor(() => expect(mocks.patch).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(screen.getByRole('button', { name: '全部 1' })).toHaveAttribute('aria-pressed', 'true')); + expect(mocks.listPage).toHaveBeenCalledWith(expect.objectContaining({ limit: 1, group: undefined, q: undefined })); + }); + + it('cancels the single native delete confirmation without a preview dialog or writes', async () => { + vi.mocked(window.confirm).mockReturnValue(false); + await mount(); + fireEvent.click(screen.getByRole('button', { name: '删除分组 Alpha' })); + await waitFor(() => expect(window.confirm).toHaveBeenCalledTimes(1)); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(mocks.patch).not.toHaveBeenCalled(); + expect(screen.getByRole('button', { name: 'Alpha 30' })).toBeInTheDocument(); + }); + + it('reports actual partial failures and recomputes both group counts', async () => { + const original = mocks.patch.getMockImplementation()!; + mocks.patch.mockImplementation((url, body) => url === '/api/tools/tool_02' ? Promise.reject(new Error('read-only filesystem')) : original(url, body)); + await mount(); + fireEvent.click(screen.getByRole('button', { name: '删除分组 Alpha' })); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('tool_02: read-only filesystem')); + expect(mocks.patch).toHaveBeenCalledTimes(30); + expect(screen.getByRole('button', { name: 'Alpha 1' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '未分组 30' })).toBeInTheDocument(); + expect(inventory[0].group).toBeNull(); + expect(inventory[2].group).toBe('Alpha'); + }); + + it('permits filtering but gates native settings writes for non-admins', async () => { + mocks.auth.role = 'member'; + await mount(); + expect(nativeRow('tool_00')).toHaveAttribute('draggable', 'false'); + expect(screen.getByRole('button', { name: '新建分组' })).toBeDisabled(); + expect(screen.getByRole('button', { name: '重命名分组 Alpha' })).toBeDisabled(); + fireEvent.click(screen.getByRole('button', { name: 'Beta 30' })); + await screen.findByRole('button', { name: 'tool_30' }); + expect(mocks.patch).not.toHaveBeenCalled(); + }); + + it('keeps Local category filtering and tab-specific view preferences', async () => { + await mount(); + fireEvent.click(screen.getByRole('button', { name: '卡片视图' })); + fireEvent.click(screen.getByRole('button', { name: /^本地工具/ })); + await screen.findByRole('button', { name: /^tool_00 / }); + expect(screen.getByRole('button', { name: '列表视图' })).toHaveAttribute('aria-pressed', 'true'); + const requests = mocks.listPage.mock.calls.length; + fireEvent.click(screen.getByRole('button', { name: `${zhTool.category.file} (12)` })); + expect(screen.queryByRole('button', { name: /^tool_00 / })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: /^tool_01 / })).toBeInTheDocument(); + expect(mocks.listPage).toHaveBeenCalledTimes(requests); + const text = nativeRow('tool_01').textContent; + fireEvent.click(screen.getByRole('button', { name: '卡片视图' })); + expect(nativeRow('tool_01').textContent).toBe(text); + fireEvent.click(screen.getByRole('button', { name: /^全量工具/ })); + await screen.findByRole('button', { name: 'tool_00' }); + expect(screen.getByRole('button', { name: '卡片视图' })).toHaveAttribute('aria-pressed', 'true'); + }); +}); diff --git a/webui/src/pages/Tool/ToolPageAutoDisable.test.tsx b/webui/src/pages/Tool/ToolPageAutoDisable.test.tsx index aad856925..5afe71053 100644 --- a/webui/src/pages/Tool/ToolPageAutoDisable.test.tsx +++ b/webui/src/pages/Tool/ToolPageAutoDisable.test.tsx @@ -1,3 +1,4 @@ +vi.mock('@/contexts/AuthContext', () => ({ useAuth: () => ({ user: { role: 'admin' } }) })); import React from 'react'; import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; diff --git a/webui/src/pages/Tool/components/APITabContent.test.tsx b/webui/src/pages/Tool/components/APITabContent.test.tsx index b6329a742..cb0d8de28 100644 --- a/webui/src/pages/Tool/components/APITabContent.test.tsx +++ b/webui/src/pages/Tool/components/APITabContent.test.tsx @@ -1,9 +1,11 @@ -import { render, screen, waitFor } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import APITabContent from './APITabContent'; +vi.mock('@/contexts/AuthContext', () => ({ useAuth: () => ({ user: { role: 'admin' } }) })); + const { apiDetailProps, listAllToolPages, mcpAPI, providerAPI } = vi.hoisted(() => ({ apiDetailProps: vi.fn(), listAllToolPages: vi.fn(), @@ -56,6 +58,145 @@ describe('APITabContent', () => { listAllToolPages.mockResolvedValue([]); }); + it('keeps versioned service identities, full group counts, card fields/actions and independent drawer tools', async () => { + providerAPI.listApiServices.mockResolvedValue({ data: [ + { id: 'service-a__v9_2', name: 'Service A', version: '9.2', description: 'Service A description', enabled: true, status: 'connected', tool_count: 40, latency_ms: 12, verify_ssl: false, group: 'Alpha' }, + { id: 'service-b', name: 'Service B', enabled: false, status: 'disabled', tool_count: 0, verify_ssl: true, group: null, builtin: true, group_readonly: false }, + { id: 'device-hidden', name: 'Not an API row', integration_type: 'device', enabled: true, tool_count: 10, verify_ssl: false }, + ] }); + listAllToolPages.mockResolvedValue([{ name: 'ungrouped-child-tool', source: 'api', group: null }]); + const props = { + tools: [], onSelectTool: vi.fn(), onRefreshTools: vi.fn().mockResolvedValue(undefined), + catalogEntries: [], catalogCategories: {}, catalogLoading: false, configuredIds: new Set(), onConfiguredChange: vi.fn(), + }; + const { rerender } = render(); + await screen.findByText('Service A'); + await waitFor(() => expect(screen.getByRole('button', { name: 'all 2' })).toBeInTheDocument()); + expect(screen.queryByText('Not an API row')).not.toBeInTheDocument(); + const nativeRow = screen.getByText('Service A').closest('[draggable]') as HTMLElement; + expect(nativeRow.children).toHaveLength(6); + const originalText = nativeRow.textContent; + expect(within(nativeRow).getByText('v9.2')).toBeInTheDocument(); + expect(within(nativeRow).getByText('40')).toBeInTheDocument(); + expect(within(nativeRow).getByText('12ms')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Alpha 1' })); + expect(screen.queryByText('Service B')).not.toBeInTheDocument(); + rerender(); + const card = screen.getByText('Service A').closest('[draggable]') as HTMLElement; + expect(card.textContent).toBe(originalText); + fireEvent.click(within(card).getByRole('button', { name: 'mcp.manage' })); + expect(await screen.findByText('ungrouped-child-tool')).toBeInTheDocument(); + expect(listAllToolPages).toHaveBeenCalledWith({ source: 'api', sourceName: 'service-a__v9_2', sortBy: 'name', sortDir: 'asc' }); + fireEvent.click(screen.getByRole('button', { name: 'ungrouped 1' })); + expect(screen.getByText('Service B')).toBeInTheDocument(); + expect(screen.getByText('ungrouped-child-tool')).toBeInTheDocument(); + + const ungroupedRow = screen.getByText('Service B').closest('[draggable]') as HTMLElement; + fireEvent.keyDown(ungroupedRow, { key: 'm', altKey: true }); + fireEvent.change(screen.getByRole('combobox'), { target: { value: 'Alpha' } }); + fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'save' })); + await waitFor(() => expect(providerAPI.updateApiService).toHaveBeenCalledWith('service-b', { group: 'Alpha' })); + await waitFor(() => expect(providerAPI.listApiServices).toHaveBeenCalledWith({ force: true })); + expect(props.onRefreshTools).not.toHaveBeenCalled(); + expect(screen.queryAllByRole('checkbox')).toHaveLength(0); + }); + + it('locks definition-only API rows but leaves builtin configured instances editable', async () => { + providerAPI.listApiServices.mockResolvedValue({ data: [ + { id: 'definition', name: 'System API definition', builtin: true, group_readonly: true, group: 'Mixed', enabled: false, tool_count: 0 }, + { id: 'configured', name: 'Configured API', builtin: true, group_readonly: false, group: 'Mixed', enabled: false, tool_count: 0 }, + ] }); + render(); + await screen.findByText('System API definition'); + const definition = screen.getByText('System API definition').closest('[draggable]')!; + expect(definition).toHaveAttribute('draggable', 'false'); + expect(definition).toHaveAttribute('title', 'pluginGroups:readOnly.system'); + expect(screen.getByRole('button', { name: 'renameNamed' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'deleteNamed' })).toBeDisabled(); + fireEvent.keyDown(definition, { key: 'm', altKey: true }); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'create' })); + expect(within(screen.getByRole('combobox')).queryByRole('option', { name: /System API definition/ })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'cancel' })); + fireEvent.keyDown(screen.getByText('Configured API').closest('[draggable]')!, { key: 'm', altKey: true }); + fireEvent.change(screen.getByRole('combobox'), { target: { value: '' } }); + fireEvent.click(screen.getByRole('button', { name: 'save' })); + await waitFor(() => expect(providerAPI.updateApiService).toHaveBeenCalledExactlyOnceWith('configured', { group: null })); + }); + + it.each(['move', 'create', 'rename', 'delete'])('rechecks fresh definition-only API rows before %s', async (operation) => { + const service = { id: 'service', name: 'Native API', group: 'Ops', group_readonly: false, enabled: false, tool_count: 0 }; + providerAPI.listApiServices.mockResolvedValueOnce({ data: [service] }).mockResolvedValue({ data: [{ ...service, group_readonly: true }] }); + vi.spyOn(window, 'confirm').mockReturnValue(true); + render(); + await screen.findByText('Native API'); + if (operation === 'move') fireEvent.keyDown(screen.getByText('Native API').closest('[draggable]')!, { key: 'm', altKey: true }); + else fireEvent.click(screen.getByRole('button', { name: operation === 'create' ? 'create' : `${operation}Named` })); + if (operation === 'create' || operation === 'rename') fireEvent.change(screen.getByRole('textbox'), { target: { value: 'New' } }); + if (operation === 'create') fireEvent.change(screen.getByRole('combobox'), { target: { value: 'service' } }); + if (operation !== 'delete') fireEvent.click(screen.getByRole('button', { name: 'save' })); + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('pluginGroups:errors.readOnlyMembers')); + expect(providerAPI.updateApiService).not.toHaveBeenCalled(); + expect(window.confirm).not.toHaveBeenCalled(); + }); + + it('keeps unconfigured catalog attributes read-only, with no artificial service creation', async () => { + render(); + await screen.findByText('Catalog API'); + const row = screen.getByText('Catalog API').closest('[draggable]') as HTMLElement; + expect(row).toHaveAttribute('draggable', 'false'); + expect(row).toHaveAttribute('title', 'pluginGroups:readOnly.system'); + fireEvent.keyDown(row, { key: 'm', altKey: true }); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'renameNamed' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'deleteNamed' })).toBeDisabled(); + expect(providerAPI.updateApiService).not.toHaveBeenCalled(); + expect(mcpAPI.catalogInstall).not.toHaveBeenCalled(); + }); + + it('keeps the native rows and selected group when reloading after an action fails', async () => { + const response = { data: [{ id: 'service', name: 'Native API', group: 'Ops', enabled: true, tool_count: 0 }] }; + providerAPI.listApiServices.mockResolvedValueOnce(response).mockRejectedValueOnce(new Error('API list offline')).mockResolvedValue(response); + providerAPI.updateApiService.mockResolvedValue({ data: {} }); + render(); + await screen.findByText('Native API'); + fireEvent.click(screen.getByRole('button', { name: 'Ops 1' })); + fireEvent.click(screen.getByTitle('detail.disableServer')); + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('API list offline')); + expect(screen.getByText('Native API')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Ops 1' })).toHaveAttribute('aria-pressed', 'true'); + fireEvent.click(screen.getByRole('button', { name: 'button.retry' })); + await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument()); + expect(providerAPI.updateApiService).toHaveBeenCalledExactlyOnceWith('service', { enabled: false }); + }); + + it.each(['create', 'rename'])('rejects fresh native API name collisions before %s writes', async (operation) => { + const service = { id: 'service__v1', name: 'Native API', group: 'Ops', enabled: true, tool_count: 0 }; + providerAPI.listApiServices.mockResolvedValueOnce({ data: [service] }).mockResolvedValue({ data: [service, { ...service, id: 'other__v2', group: 'Existing' }] }); + render(); + await screen.findByText('Native API'); + fireEvent.click(screen.getByRole('button', { name: operation === 'create' ? 'create' : 'renameNamed' })); + fireEvent.change(screen.getByRole('textbox', { name: 'dialog.name' }), { target: { value: 'Existing' } }); + if (operation === 'create') fireEvent.change(screen.getByRole('combobox'), { target: { value: 'service__v1' } }); + fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'save' })); + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('pluginGroups:validation.duplicate')); + expect(providerAPI.listApiServices).toHaveBeenLastCalledWith({ force: true }); + expect(providerAPI.updateApiService).not.toHaveBeenCalled(); + }); + it('loads the complete tool list when a service detail drawer opens', async () => { const user = userEvent.setup(); providerAPI.listApiServices.mockResolvedValue({ diff --git a/webui/src/pages/Tool/components/APITabContent.tsx b/webui/src/pages/Tool/components/APITabContent.tsx index 13f54e3c7..0709839ab 100644 --- a/webui/src/pages/Tool/components/APITabContent.tsx +++ b/webui/src/pages/Tool/components/APITabContent.tsx @@ -12,6 +12,10 @@ import LoadingSpinner from '@/components/common/LoadingSpinner'; import { getCatalogDescription } from '@/utils/mcpCatalog'; import { APIServiceDetailPanel } from './ServiceDetailPanel'; import { SERVICE_TAB_GRID_COLS } from './gridLayout'; +import GroupNav, { useGroupDrag } from '@/components/plugin-groups/GroupNav'; +import { deriveGroupNav, matchesGroup, saveGroupItems, type GroupSelection } from '@/components/plugin-groups/groupView'; +import { useAuth } from '@/contexts/AuthContext'; +import { extractErrorMessage } from '@/utils/error'; const DETAIL_DRAWER_WIDTH = 560; const LANG_COLORS: Record = { @@ -27,8 +31,10 @@ const INSTALL_CONFIRM_BUTTON_CLASS = 'px-4 py-2 text-sm text-white bg-green-600 interface APITabContentProps { tools: Tool[]; + searchQuery?: string; onSelectTool: (tool: Tool) => void; onRefreshTools: () => Promise; + viewMode?: 'list' | 'cards'; catalogEntries: MCPCatalogEntry[]; catalogCategories: Record; catalogLoading: boolean; @@ -38,8 +44,10 @@ interface APITabContentProps { export default function APITabContent({ tools, + searchQuery = '', onSelectTool, onRefreshTools, + viewMode = 'list', catalogEntries, catalogCategories, catalogLoading, @@ -59,6 +67,7 @@ export default function APITabContent({ const [services, setServices] = useState([]); const [servicesLoading, setServicesLoading] = useState(true); + const [servicesError, setServicesError] = useState(null); const [selectedServiceId, setSelectedServiceId] = useState(null); const [testingServices, setTestingServices] = useState>(new Set()); const [selectedCategory, setSelectedCategory] = useState('all'); @@ -67,14 +76,17 @@ export default function APITabContent({ const [credValues, setCredValues] = useState>({}); const [serviceToolCache, setServiceToolCache] = useState>({}); - const fetchServices = useCallback(async () => { + const fetchServices = useCallback(async (force = false) => { try { setServicesLoading(true); - const res = await providerAPI.listApiServices(); + const res = await providerAPI.listApiServices(force ? { force: true } : undefined); // Exclude security device APIs — they live on the Device Integration page setServices((res.data || []).filter((s) => s.integration_type !== 'device')); - } catch { - setServices([]); + setServicesError(null); + } catch (error) { + if (force) throw error; + // Keep the last native rows and group selection when a reload fails. + setServicesError(error); } finally { setServicesLoading(false); } @@ -307,8 +319,60 @@ export default function APITabContent({ : (englishDescription || chineseDescription); }, [i18n.language]); + const { user } = useAuth(); + const [groupSelection, setGroupSelection] = useState(null); + // The server distinguishes definition-only rows from configured instances; + // builtin, credentials and enabled state do not establish group ownership. + const asServiceGroupItem = (service: APIServiceSummary) => ({ + key: service.id, name: service.name, group: service.group, + readOnlyReason: service.group_readonly ? t('pluginGroups:readOnly.system') + : user?.role === 'admin' ? undefined : t('pluginGroups:readOnly.admin'), + }); + const asCatalogGroupItem = (entry: MCPCatalogEntry) => ({ + key: `catalog:${entry.id}`, name: entry.name, group: entry.group, + readOnlyReason: t(entry.group_readonly ? 'pluginGroups:readOnly.system' : 'pluginGroups:readOnly.catalog'), + }); + const groupInventory = (nativeServices: APIServiceSummary[]) => [ + ...nativeServices.map(asServiceGroupItem), + ...catalogEntries.filter((entry) => !nativeServices.some((service) => service.id === entry.id)).map(asCatalogGroupItem), + ]; + const groupItems = groupInventory(services); + const groupDrag = useGroupDrag(groupItems); + const reloadGroupData = () => fetchServices(true); + const saveGroup = (id: string, group: string | null) => providerAPI.updateApiService(id, { group }); + const loadGroupItems = async () => { + const { data } = await providerAPI.listApiServices({ force: true }); + return groupInventory(data.filter((service) => service.integration_type !== 'device')); + }; + const moveGroup = async (key: string, group: string | null) => { + const inventory = await loadGroupItems(); + await saveGroupItems(inventory.filter((item) => item.key === key), group, saveGroup, reloadGroupData, t); + }; + const createGroup = async (key: string, group: string) => { + const inventory = await loadGroupItems(); + if (inventory.some((item) => matchesGroup(item.group, group))) throw new Error(t('pluginGroups:validation.duplicate')); + await saveGroupItems(inventory.filter((item) => item.key === key), group, saveGroup, reloadGroupData, t); + }; + const changeGroup = async (from: string, to: string | null) => { + const inventory = await loadGroupItems(); + if (to !== null && inventory.some((item) => matchesGroup(item.group, to))) throw new Error(t('pluginGroups:validation.duplicate')); + const members = inventory.filter((item) => matchesGroup(item.group, from)); + await saveGroupItems(members, to, saveGroup, reloadGroupData, t, true); + }; + const query = searchQuery.trim().toLowerCase(); + const matchesQuery = (...values: (string | undefined)[]) => !query || values.some((value) => value?.toLowerCase().includes(query)); + const visibleServices = services.filter((service) => matchesGroup(service.group, groupSelection)); + const visibleCatalog = filteredCatalog.filter((entry) => matchesGroup(entry.group, groupSelection) + && matchesQuery(entry.id, entry.name, entry.description, entry.description_cn, ...entry.tags)); + return ( -
+
+ changeGroup(name, null)} {...groupDrag} /> +
+ {servicesError !== null &&
+ {extractErrorMessage(servicesError, t('alert.refreshFailedTitle'))} + +
}
+
}
); @@ -265,19 +308,28 @@ function WorkflowSection({ title, icon, workflows, + groupSelection, + groupDrag, + viewMode, }: { title: string; icon: React.ReactNode; workflows: WorkflowSummary[]; + groupSelection: GroupSelection; + groupDrag: GroupDrag; + viewMode: PluginViewMode; }) { + const { t, i18n } = useTranslation('workflow'); const [page, setPage] = useState(1); - const totalPages = Math.max(1, Math.ceil(workflows.length / PAGE_SIZE)); + const filtered = workflows.filter((workflow) => matchesGroup(workflow.group, groupSelection)); + const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE)); + useEffect(() => { setPage(1); }, [groupSelection]); useEffect(() => { if (page > totalPages) setPage(totalPages); }, [totalPages, page]); - const displayed = workflows.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); + const displayed = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); return ( // Use a labelled
so the grouping is exposed as a landmark @@ -299,9 +351,12 @@ function WorkflowSection({ {/* Grid — min-height anchors layout to avoid jump when pagination hides rows */}
1 ? 540 : undefined }}> -
+ {displayed.length === 0 &&

{t('emptyState.title')}

} +
{displayed.map(workflow => ( - +
+ +
))}
@@ -309,7 +364,7 @@ function WorkflowSection({ {totalPages > 1 && (
- {(page - 1) * PAGE_SIZE + 1}–{Math.min(workflows.length, page * PAGE_SIZE)} / {workflows.length} + {(page - 1) * PAGE_SIZE + 1}–{Math.min(filtered.length, page * PAGE_SIZE)} / {filtered.length}
)} + {showItems && items.map((item) =>
+ {item.name} + +
)} ; } async function mount(items: GroupNavItem[], onCreate?: (key: string, group: string) => Promise) { @@ -46,6 +53,56 @@ beforeEach(() => { }); describe('pure native GroupNav', () => { + it('only shows a short drag hint, without permanent readonly or implementation explanations', async () => { + await mount([{ key: 'builtin', name: 'Built-in', group: 'Fixed', readOnlyReason: messages.readOnly.system }]); + const sidebar = screen.getByRole('complementary'); + expect(within(sidebar).getByText(messages.countsDescription)).toBeInTheDocument(); + expect(sidebar).not.toHaveTextContent(messages.readOnly.system); + expect(sidebar).not.toHaveTextContent(/derived|administrators|personal|native group/i); + expect(notify).not.toHaveBeenCalled(); + }); + + it('opens the existing group picker from the item button without triggering the original card action', async () => { + await mount([{ key: 'a', name: 'Alpha', group: 'First' }, { key: 'b', name: 'Beta', group: 'Second' }]); + fireEvent.click(within(screen.getByTestId('a')).getByRole('button', { name: 'Edit group' })); + expect(screen.getByRole('dialog', { name: 'Edit group for Alpha' })).toBeInTheDocument(); + expect(screen.getByRole('combobox', { name: 'Destination group' })).toHaveValue('First'); + fireEvent.change(screen.getByRole('combobox'), { target: { value: 'Second' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + await waitFor(() => expect(move).toHaveBeenCalledExactlyOnceWith('a', 'Second')); + expect(originalClick).not.toHaveBeenCalled(); + expect(notify).not.toHaveBeenCalled(); + }); + + it('warns on the readonly item button without opening a picker or saving', async () => { + await mount([{ key: 'builtin', name: 'Built-in', group: 'Fixed', readOnlyReason: messages.readOnly.system }]); + const button = within(screen.getByTestId('builtin')).getByRole('button', { name: 'Edit group' }); + expect(button).toBeEnabled(); + fireEvent.click(button); + expect(notify).toHaveBeenCalledExactlyOnceWith(messages.readOnly.system); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(move).not.toHaveBeenCalled(); + expect(originalClick).not.toHaveBeenCalled(); + }); + + it('uses the existing warning toast for an attempted builtin drag and never creates a drag payload', async () => { + const i18n = createInstance(); + await i18n.use(initReactI18next).init({ lng: 'en-US', resources: { 'en-US': { pluginGroups: messages } } }); + function ToastExample() { + const { warning } = useToast(); + return ; + } + render(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + const dataTransfer = transfer(); + expect(fireEvent.dragStart(screen.getByTestId('builtin'), { dataTransfer })).toBe(false); + expect(dataTransfer.types).toEqual([]); + expect(screen.getByRole('alert')).toHaveTextContent(messages.readOnly.system); + fireEvent.drop(screen.getByRole('button', { name: 'Ungrouped 0' }).parentElement!, { dataTransfer }); + expect(move).not.toHaveBeenCalled(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + it('only clears a missing selection for an authoritative unfiltered inventory', async () => { const i18n = createInstance(); await i18n.use(initReactI18next).init({ lng: 'en-US', resources: { 'en-US': { pluginGroups: messages } } }); @@ -242,8 +299,12 @@ describe('pure native GroupNav', () => { await mount([{ key: 'builtin', name: 'Built-in', group: 'Mixed', readOnlyReason: 'Built-in even for admin' }, { key: 'custom', name: 'Custom', group: 'Mixed' }]); expect(screen.getByRole('button', { name: 'Rename group Mixed' })).toBeDisabled(); expect(screen.getByRole('button', { name: 'Delete group Mixed' })).toBeDisabled(); - expect(screen.getByTestId('builtin')).toHaveAttribute('draggable', 'false'); + const dataTransfer = transfer(); + expect(fireEvent.dragStart(screen.getByTestId('builtin'), { dataTransfer })).toBe(false); + expect(dataTransfer.types).toEqual([]); + expect(notify).toHaveBeenCalledWith('Built-in even for admin'); fireEvent.keyDown(screen.getByTestId('builtin'), { key: 'm', altKey: true }); + expect(notify).toHaveBeenCalledTimes(2); expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); fireEvent.keyDown(screen.getByTestId('custom'), { key: 'm', altKey: true }); fireEvent.change(screen.getByRole('combobox'), { target: { value: '' } }); diff --git a/webui/src/components/plugin-groups/GroupNav.tsx b/webui/src/components/plugin-groups/GroupNav.tsx index 930f653f1..09f18d964 100644 --- a/webui/src/components/plugin-groups/GroupNav.tsx +++ b/webui/src/components/plugin-groups/GroupNav.tsx @@ -11,7 +11,7 @@ function readOrder(key: string): unknown { } /** Only transient drag/keyboard state; no plugin membership or backend state. */ -export function useGroupDrag(items: readonly GroupNavItem[]) { +export function useGroupDrag(items: readonly GroupNavItem[], onReadOnly: (message: string) => void) { const { t } = useTranslation('pluginGroups'); const [movingKey, setMovingKey] = useState(null); const gestureScope = useId(); @@ -26,13 +26,26 @@ export function useGroupDrag(items: readonly GroupNavItem[]) { const item = items.find((row) => row.key === gesture.key); return item?.readOnlyReason ? undefined : item; }; + const requestMove = (key: string) => { + const item = items.find((row) => row.key === key); + if (!item) return; + activeDrag.current = null; + if (item.readOnlyReason) { + setMovingKey(null); + onReadOnly(item.readOnlyReason); + return; + } + setMovingKey(key); + }; const dragProps = (key: string, options?: { allowPrimaryButton?: boolean }): HTMLAttributes => { const item = items.find((row) => row.key === key); if (!item) return {}; - if (item.readOnlyReason) return { draggable: false, title: item.readOnlyReason, 'aria-description': item.readOnlyReason }; return { + // Receive the attempted gesture so readonly items can explain why it is blocked. draggable: true, tabIndex: 0, + title: item.readOnlyReason, + 'aria-description': item.readOnlyReason, 'aria-label': t('drag.sourceLabel', { name: item.name }), 'aria-keyshortcuts': 'Alt+m', onDragStart: (event) => { @@ -42,6 +55,12 @@ export function useGroupDrag(items: readonly GroupNavItem[]) { event.preventDefault(); return; } + if (item.readOnlyReason) { + event.preventDefault(); + event.stopPropagation(); + requestMove(key); + return; + } const token = `${gestureScope}-${++gestureSequence.current}`; activeDrag.current = { token, key, element: event.currentTarget }; event.dataTransfer.setData(RESOURCE_MIME, token); @@ -54,12 +73,12 @@ export function useGroupDrag(items: readonly GroupNavItem[]) { if ((event.target as HTMLElement).closest('input, textarea, select, [contenteditable="true"]')) return; event.preventDefault(); event.stopPropagation(); - setMovingKey(key); + requestMove(key); } }, }; }; - return { dragProps, movingItem, clearMovingItem: () => setMovingKey(null), consumeResourceDrag }; + return { dragProps, requestMove, movingItem, clearMovingItem: () => setMovingKey(null), consumeResourceDrag }; } export type GroupDrag = ReturnType; @@ -282,7 +301,6 @@ export default function GroupNav({
; })} - {items.some((item) => item.readOnlyReason) &&

{[...new Set(items.map((item) => item.readOnlyReason).filter(Boolean))].join(' ')}

} {message &&

{message}

} {error && !modalOpen &&

{error}

} {modalOpen && @@ -302,7 +320,6 @@ export default function GroupNav({ {editableItems.map((item) => )} } -

{t('dialog.sharedDescription')}

:
+
+ {grouping && } {hasMissingDeps && (
-
+
+ {grouping && } {hasMissingDeps &&
{/* Actions column */} -
+
+ {service.enabled ? ( <>
{/* Actions column */} -
+
+ {groupDrag && } {tool.enabled ? ( <>
{/* Actions column */} -
+
+ {isActive ? ( <>
- {/* Actions column — manage button */} -
+ {/* Actions column */} +
+
- +
+ + +
{/* Description */} From 553ab43eaf38fdc794cfa59e8a6088e17d5b1ed9 Mon Sep 17 00:00:00 2001 From: chenjie Date: Mon, 21 Sep 2026 16:50:39 +0800 Subject: [PATCH 3/5] fix(i18n): localize default plugin group labels Translate known default group names in navigation, selection controls, and confirmations while preserving native values, saved ordering keys, and custom names. Cover language switching and canonical values in grouping regression tests. Co-Authored-By: Claude Code --- .../plugin-groups/GroupNav.test.tsx | 54 +++++++++++++++++-- .../src/components/plugin-groups/GroupNav.tsx | 13 ++--- .../plugin-groups/groupView.test.ts | 20 ++++++- .../src/components/plugin-groups/groupView.ts | 12 ++++- webui/src/locales/en-US/pluginGroups.json | 21 ++++++++ webui/src/locales/zh-CN/pluginGroups.json | 21 ++++++++ 6 files changed, 130 insertions(+), 11 deletions(-) diff --git a/webui/src/components/plugin-groups/GroupNav.test.tsx b/webui/src/components/plugin-groups/GroupNav.test.tsx index ecdba5dd7..5f0409778 100644 --- a/webui/src/components/plugin-groups/GroupNav.test.tsx +++ b/webui/src/components/plugin-groups/GroupNav.test.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { createInstance } from 'i18next'; import { I18nextProvider, initReactI18next } from 'react-i18next'; @@ -8,6 +8,7 @@ import PluginGroupButton from './PluginGroupButton'; import { ToastProvider, useToast } from '@/components/common/Toast'; import { deriveGroupNav, type GroupNavItem, type GroupSelection } from './groupView'; import messages from '@/locales/en-US/pluginGroups.json'; +import zhMessages from '@/locales/zh-CN/pluginGroups.json'; const move = vi.fn(); const rename = vi.fn(); @@ -31,9 +32,9 @@ function Example({ inventory, showItems = true, onCreate, onReadOnly = notify }: } async function mount(items: GroupNavItem[], onCreate?: (key: string, group: string) => Promise) { const i18n = createInstance(); - await i18n.use(initReactI18next).init({ lng: 'en-US', resources: { 'en-US': { pluginGroups: messages } }, interpolation: { escapeValue: false } }); + await i18n.use(initReactI18next).init({ lng: 'en-US', resources: { 'en-US': { pluginGroups: messages }, 'zh-CN': { pluginGroups: zhMessages } }, interpolation: { escapeValue: false } }); const view = render(); - return { ...view, showItems: (showItems: boolean) => view.rerender() }; + return { ...view, i18n, showItems: (showItems: boolean) => view.rerender() }; } function transfer() { const values = new Map(); @@ -53,6 +54,53 @@ beforeEach(() => { }); describe('pure native GroupNav', () => { + it('localizes default names while keeping custom names and the selected native value', async () => { + const { i18n } = await mount([ + { key: 'a', name: 'A', group: '平台集成' }, + { key: 'b', name: 'B', group: '安全研判' }, + { key: 'c', name: 'C', group: '系统辅助' }, + { key: 'custom', name: 'Custom', group: '客户业务组' }, + ]); + expect(screen.getByRole('button', { name: 'Platform Integrations 1' })).toHaveAttribute('title', 'Platform Integrations'); + expect(screen.getByRole('button', { name: 'Security Analysis 1' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'System Utilities 1' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '客户业务组 1' })).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Platform Integrations 1' })); + expect(screen.getByTestId('selection')).toHaveTextContent('"平台集成"'); + await act(async () => { await i18n.changeLanguage('zh-CN'); }); + expect(screen.getByRole('button', { name: '平台集成 1' })).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getByRole('button', { name: '安全研判 1' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '系统辅助 1' })).toBeInTheDocument(); + expect(move).not.toHaveBeenCalled(); + expect(localStorage.length).toBe(0); + }); + + it('shows translated picker labels but sends native values for picker saves and drops', async () => { + await mount([ + { key: 'a', name: 'Alpha' }, { key: 'b', name: 'Beta' }, + { key: 'builtin', name: 'Built-in', group: '平台集成', readOnlyReason: messages.readOnly.system }, + ]); + fireEvent.click(within(screen.getByTestId('a')).getByRole('button', { name: 'Edit group' })); + expect(screen.getByRole('option', { name: 'Platform Integrations' })).toHaveValue('平台集成'); + fireEvent.change(screen.getByRole('combobox'), { target: { value: '平台集成' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + await waitFor(() => expect(move).toHaveBeenCalledExactlyOnceWith('a', '平台集成')); + await screen.findByRole('button', { name: 'Platform Integrations 2' }); + const dataTransfer = transfer(); + fireEvent.dragStart(screen.getByTestId('b'), { dataTransfer }); + fireEvent.drop(screen.getByRole('button', { name: 'Platform Integrations 2' }).parentElement!, { dataTransfer }); + await waitFor(() => expect(move).toHaveBeenLastCalledWith('b', '平台集成')); + }); + + it('uses translated action labels without storing translated ordering keys', async () => { + await mount([{ key: 'a', name: 'A', group: '平台集成' }, { key: 'b', name: 'B', group: '安全研判' }]); + expect(screen.getByRole('button', { name: 'Rename group Platform Integrations' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Delete group Platform Integrations' })).toBeInTheDocument(); + fireEvent.keyDown(screen.getByRole('button', { name: 'Reorder group Security Analysis' }), { key: 'ArrowUp' }); + expect(JSON.parse(localStorage.getItem('flocks:plugin-group-order:test')!)).toEqual(['安全研判', '平台集成']); + expect(move).not.toHaveBeenCalled(); + }); + it('only shows a short drag hint, without permanent readonly or implementation explanations', async () => { await mount([{ key: 'builtin', name: 'Built-in', group: 'Fixed', readOnlyReason: messages.readOnly.system }]); const sidebar = screen.getByRole('complementary'); diff --git a/webui/src/components/plugin-groups/GroupNav.tsx b/webui/src/components/plugin-groups/GroupNav.tsx index 09f18d964..3b275a742 100644 --- a/webui/src/components/plugin-groups/GroupNav.tsx +++ b/webui/src/components/plugin-groups/GroupNav.tsx @@ -1,7 +1,7 @@ import { useEffect, useId, useRef, useState, type HTMLAttributes, type ReactNode } from 'react'; import { GripVertical, Pencil, Plus, Trash2, X } from 'lucide-react'; import { useTranslation } from 'react-i18next'; -import { GroupActionCancelled, groupItemLabel, groupName, orderGroupNames, type GroupNavEntry, type GroupNavItem, type GroupSelection } from './groupView'; +import { GroupActionCancelled, groupItemLabel, groupLabel, groupName, orderGroupNames, type GroupNavEntry, type GroupNavItem, type GroupSelection } from './groupView'; const RESOURCE_MIME = 'application/x-flocks-visible-plugin'; const ORDER_MIME = 'application/x-flocks-group-order'; @@ -274,8 +274,9 @@ export default function GroupNav({
{navigationButton('', t('ungrouped'), ungroupedCount)}
{ordered.map((group) => { const entry = groups.find((row) => row.name === group) ?? { name: group, count: 0 }; + const label = groupLabel(group, t); return
- - {navigationButton(group, group, entry.count)} + {navigationButton(group, label, entry.count)}
; })} @@ -324,7 +325,7 @@ export default function GroupNav({ } {error &&

{error}

} diff --git a/webui/src/components/plugin-groups/groupView.test.ts b/webui/src/components/plugin-groups/groupView.test.ts index 2739cb115..5f58c46c9 100644 --- a/webui/src/components/plugin-groups/groupView.test.ts +++ b/webui/src/components/plugin-groups/groupView.test.ts @@ -3,7 +3,7 @@ import { createInstance } from 'i18next'; import enGroups from '@/locales/en-US/pluginGroups.json'; const i18n = createInstance(); const t = i18n.t.bind(i18n); -import { deriveGroupNav, GroupActionCancelled, matchesGroup, orderGroupNames, saveGroupItems } from './groupView'; +import { deriveGroupNav, GroupActionCancelled, groupLabel, matchesGroup, orderGroupNames, saveGroupItems } from './groupView'; beforeEach(async () => { vi.restoreAllMocks(); @@ -11,6 +11,24 @@ beforeEach(async () => { }); describe('native group view helpers', () => { + it('only translates exact known defaults, not custom names or translation-key-like names', () => { + for (const [nativeName, label] of Object.entries(enGroups.defaultNames)) { + expect(groupLabel(nativeName, t)).toBe(label); + } + for (const name of ['客户自定义', 'NDR', 'defaultNames', 'toString', '__proto__', 'custom.group', 'custom:group']) { + expect(groupLabel(name, t)).toBe(name); + } + }); + + it('localizes the confirmation destination without changing the saved group', async () => { + const confirm = vi.spyOn(window, 'confirm').mockReturnValue(true); + const save = vi.fn().mockResolvedValue(undefined); + await saveGroupItems([{ key: 'custom', name: 'Custom' }], '平台集成', save, vi.fn(), t, true); + expect(confirm.mock.lastCall?.[0]).toContain('Platform Integrations'); + expect(confirm.mock.lastCall?.[0]).not.toContain('平台集成'); + expect(save).toHaveBeenCalledExactlyOnceWith('custom', '平台集成'); + }); + it('derives trimmed nonempty names and keeps All distinct from Ungrouped and literal all', () => { const result = deriveGroupNav([ { key: 'a', name: 'A', group: ' Operations ' }, diff --git a/webui/src/components/plugin-groups/groupView.ts b/webui/src/components/plugin-groups/groupView.ts index 4f97c7437..a72ec8879 100644 --- a/webui/src/components/plugin-groups/groupView.ts +++ b/webui/src/components/plugin-groups/groupView.ts @@ -19,6 +19,16 @@ export interface GroupNavEntry { export type GroupSelection = string | null; export const groupItemLabel = (item: GroupNavItem): string => item.name === item.key ? item.name : `${item.name} (${item.key})`; export const groupName = (value?: string | null): string => value?.trim() ?? ''; + +/** Localize known default names for display only; native values stay unchanged. */ +export function groupLabel(name: string, t: TFunction): string { + const labels = t('pluginGroups:defaultNames', { returnObjects: true }); + if (labels && typeof labels === 'object' && Object.prototype.hasOwnProperty.call(labels, name)) { + const label = (labels as Record)[name]; + if (typeof label === 'string') return label; + } + return name; +} export const matchesGroup = (value: string | null | undefined, selection: GroupSelection): boolean => ( selection === null || groupName(value) === selection ); @@ -71,7 +81,7 @@ export async function saveGroupItems( assertGroupItemsEditable(items, t); if (confirmScope && !window.confirm(t('pluginGroups:confirmScope', { count: items.length, - group: group ?? t('pluginGroups:ungrouped'), + group: group === null ? t('pluginGroups:ungrouped') : groupLabel(group, t), items: items.map(groupItemLabel).join(', '), }))) throw new GroupActionCancelled(); diff --git a/webui/src/locales/en-US/pluginGroups.json b/webui/src/locales/en-US/pluginGroups.json index 79aca7bdb..8794c0ce8 100644 --- a/webui/src/locales/en-US/pluginGroups.json +++ b/webui/src/locales/en-US/pluginGroups.json @@ -2,6 +2,27 @@ "title": "Business groups", "navigation": "Filter by business group", "countsDescription": "Drag a plugin onto a group to move it.", + "defaultNames": { + "平台集成": "Platform Integrations", + "安全研判": "Security Analysis", + "系统辅助": "System Utilities", + "威胁情报": "Threat Intelligence", + "文件操作": "File Operations", + "终端执行": "Terminal Commands", + "代码分析": "Code Analysis", + "检索": "Search", + "任务与工作流": "Tasks & Workflows", + "代理协作": "Agent Collaboration", + "系统管理": "System Management", + "企业协作": "Enterprise Collaboration", + "网络测绘": "Network Mapping", + "网络防护": "Network Protection", + "SIEM 与日志分析": "SIEM & Log Analysis", + "安全运营": "Security Operations", + "数据处理": "Data Processing", + "合规": "Compliance", + "代码安全": "Code Security" + }, "all": "All", "ungrouped": "Ungrouped", "create": "New group", diff --git a/webui/src/locales/zh-CN/pluginGroups.json b/webui/src/locales/zh-CN/pluginGroups.json index 4d1b24c7f..beaa614a3 100644 --- a/webui/src/locales/zh-CN/pluginGroups.json +++ b/webui/src/locales/zh-CN/pluginGroups.json @@ -2,6 +2,27 @@ "title": "业务分组", "navigation": "按业务分组筛选", "countsDescription": "拖动插件到分组即可归组。", + "defaultNames": { + "平台集成": "平台集成", + "安全研判": "安全研判", + "系统辅助": "系统辅助", + "威胁情报": "威胁情报", + "文件操作": "文件操作", + "终端执行": "终端执行", + "代码分析": "代码分析", + "检索": "检索", + "任务与工作流": "任务与工作流", + "代理协作": "代理协作", + "系统管理": "系统管理", + "企业协作": "企业协作", + "网络测绘": "网络测绘", + "网络防护": "网络防护", + "SIEM 与日志分析": "SIEM 与日志分析", + "安全运营": "安全运营", + "数据处理": "数据处理", + "合规": "合规", + "代码安全": "代码安全" + }, "all": "全部", "ungrouped": "未分组", "create": "新建分组", From dd7a469b900ebeca26a895b793718c0e35b3c75c Mon Sep 17 00:00:00 2001 From: chenjie Date: Mon, 21 Sep 2026 18:59:12 +0800 Subject: [PATCH 4/5] fix(plugins): resolve PR765 grouping and service search bugs Validate service group values before config writes and tolerate malformed legacy instance groups when reading service lists. Invalidate Agent caches after general configuration updates and filter API services using service metadata and complete pre-pagination tool facets. Add regression coverage for invalid groups, immediate query freshness, and service search beyond the first tool page. Co-Authored-By: Claude Code --- flocks/server/routes/config.py | 18 ++++ flocks/server/routes/mcp.py | 13 ++- flocks/tool/schema/api_service_schema.py | 8 ++ tests/provider/test_api_service_management.py | 48 ++++++++++ tests/server/routes/test_agent_routes.py | 23 +++++ tests/server/routes/test_mcp_routes.py | 57 ++++++++++++ tests/server/routes/test_remaining_routes.py | 44 ++++++++++ webui/src/pages/Tool/ToolGroups.test.tsx | 74 ++++++++++++++++ .../Tool/components/APITabContent.test.tsx | 88 +++++++++++++++++++ .../pages/Tool/components/APITabContent.tsx | 14 ++- webui/src/pages/Tool/index.tsx | 5 ++ 11 files changed, 387 insertions(+), 5 deletions(-) diff --git a/flocks/server/routes/config.py b/flocks/server/routes/config.py index 076ddac21..a901b781d 100644 --- a/flocks/server/routes/config.py +++ b/flocks/server/routes/config.py @@ -70,6 +70,20 @@ def _normalize_slack_dm_policy(config_data: Dict[str, Any]) -> None: async def _validate_plugin_group_updates(config_data: Dict[str, Any]) -> None: """Check native definition locks before any config or secret side effects.""" try: + for section in ("mcp", "api_services"): + entries = config_data.get(section) + if not isinstance(entries, dict): + continue + groups = [entry["group"] for entry in entries.values() if isinstance(entry, dict) and "group" in entry] + if not groups: + continue + if section == "mcp": + from flocks.mcp.types import normalize_mcp_group as normalize_group + else: + from flocks.tool.schema.api_service_schema import normalize_api_service_group as normalize_group + for group in groups: + normalize_group(group) + for section in ("agent", "mode"): entries = config_data.get(section) if not isinstance(entries, dict): @@ -713,6 +727,10 @@ async def update_config(config_data: Dict[str, Any]) -> Dict[str, Any]: # Clear cache to reload Config.clear_cache() + if any(isinstance(config_data.get(section), dict) and config_data[section] for section in ("agent", "mode")): + from flocks.agent.registry import Agent + + Agent.invalidate_cache() # Refresh only OSS-owned channel routing and Agent visibility config. from flocks.channel.inbound.dispatcher import ( InboundDispatcher, diff --git a/flocks/server/routes/mcp.py b/flocks/server/routes/mcp.py index fe72cc9c8..fc161c693 100644 --- a/flocks/server/routes/mcp.py +++ b/flocks/server/routes/mcp.py @@ -15,6 +15,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request, Response from fastapi.responses import JSONResponse from pydantic import BaseModel, Field, field_validator +from pydantic_core import PydanticCustomError from flocks.audit import emit_audit_event from flocks.auth.context import AuthUser @@ -63,6 +64,14 @@ log = Log.create(service="routes.mcp") +def _read_stored_mcp_group(value: Any) -> Optional[str]: + """Ignore malformed legacy instance metadata in responses, never on writes.""" + try: + return normalize_mcp_group(value) + except PydanticCustomError: + return None + + def _to_frontend_mcp_config(server_config: Dict[str, Any]) -> Dict[str, Any]: """Normalize backend transport names for the frontend form.""" server_config = mask_sensitive_mcp_config_for_frontend(server_config) @@ -97,7 +106,7 @@ def _to_frontend_mcp_config(server_config: Dict[str, Any]) -> Dict[str, Any]: args = args_value return { "type": transport, - "group": normalize_mcp_group(server_config.get("group")), + "group": _read_stored_mcp_group(server_config.get("group")), "url": server_config.get("url"), "command": command, "args": args, @@ -155,7 +164,7 @@ def _load_raw_mcp_server_config(name: str) -> Optional[Dict[str, Any]]: def _effective_mcp_group(name: str, config: Dict[str, Any]) -> Optional[str]: if "group" in config: - return normalize_mcp_group(config["group"]) + return _read_stored_mcp_group(config["group"]) entry = McpCatalog.get().get_entry(name) return normalize_mcp_group(getattr(entry, "group", None)) diff --git a/flocks/tool/schema/api_service_schema.py b/flocks/tool/schema/api_service_schema.py index a7f95a734..416b97b02 100644 --- a/flocks/tool/schema/api_service_schema.py +++ b/flocks/tool/schema/api_service_schema.py @@ -193,6 +193,14 @@ def project_api_service_group( merged["group_readonly"] = readonly if readonly: merged["group"] = definition.get("group") + elif isinstance(raw_service, dict) and "group" in raw_service: + # Legacy config may predate strict group validation. Keep one malformed + # instance from breaking every service response; never rewrite its raw + # record or substitute a fixed definition group for its explicit value. + try: + merged["group"] = normalize_api_service_group(raw_service["group"]) + except PydanticCustomError: + merged["group"] = None def _load_api_service_metadata_data(provider_id: str) -> Optional[Dict[str, Any]]: diff --git a/tests/provider/test_api_service_management.py b/tests/provider/test_api_service_management.py index f487248f6..5f4cbb27a 100644 --- a/tests/provider/test_api_service_management.py +++ b/tests/provider/test_api_service_management.py @@ -38,6 +38,54 @@ def native_services(self, tmp_path, monkeypatch): routes._clear_api_service_summary_metadata_cache() return routes, ConfigWriter, no_toggle, no_status_write + @pytest.mark.asyncio + @pytest.mark.parametrize("stored_group,expected", [ + (["legacy"], None), ("x" * 33, None), ("a\x00b", None), + ("a\nb", None), ("a\x7fb", None), (None, None), ("", ""), (" Valid ", "Valid"), + ]) + async def test_bad_stored_group_cannot_break_mixed_service_reads( + self, native_services, monkeypatch, stored_group, expected, + ): + import json + from flocks.tool.schema import api_service_schema + + routes, writer, no_toggle, no_status_write = native_services + # Simulate historical data without going through today's strict writer. + path = writer._get_config_path() + raw = writer._read_raw() + raw["api_services"]["native_api_v1"]["group"] = stored_group + raw["api_services"].pop("native_api_v2") + raw["api_services"]["healthy"] = {"name": "Healthy", "enabled": True, "group": " Operations "} + path.write_text(json.dumps(raw), encoding="utf-8") + before = path.read_bytes() + monkeypatch.setattr(api_service_schema, "is_shipped_tool_path", lambda path: True) + routes._clear_api_service_summary_metadata_cache() + + for _ in range(2): # Cached list metadata must also retain the safe projection. + summaries = {row.id: row for row in await routes.list_api_services()} + assert set(summaries) == {"native_api_v1", "native_api_v2", "healthy"} + assert summaries["healthy"].group == "Operations" + assert summaries["native_api_v1"].group == expected + assert summaries["native_api_v1"].enabled is True + assert summaries["native_api_v1"].group_readonly is False + assert summaries["native_api_v2"].group == "Package" + assert summaries["native_api_v2"].group_readonly is True + + metadata = await routes.get_api_service_metadata("native_api_v1") + assert metadata.group == expected and metadata.group_readonly is False + assert metadata.name == "Native API" and metadata.version == "1" + assert api_service_schema._load_api_service_metadata_data("native_api_v1")["group"] == expected + assert (await routes.get_api_service_metadata("native_api_v2")).group == "Package" + assert path.read_bytes() == before + assert writer.get_api_service_raw("native_api_v1")["group"] == stored_group + no_toggle.assert_not_called() + no_status_write.assert_not_called() + if stored_group is not None and expected is None: + with pytest.raises(ValueError): + api_service_schema.normalize_api_service_group(stored_group) + with pytest.raises(ValueError): + routes.APIServiceUpdateRequest(group=stored_group) + @pytest.mark.asyncio @pytest.mark.parametrize("record", [None, {}, {"enabled": False}, {"group": ""}]) async def test_shipped_definition_vs_empty_or_disabled_instance(self, native_services, monkeypatch, tmp_path, record): diff --git a/tests/server/routes/test_agent_routes.py b/tests/server/routes/test_agent_routes.py index 16225690c..e47c0d3a9 100644 --- a/tests/server/routes/test_agent_routes.py +++ b/tests/server/routes/test_agent_routes.py @@ -119,6 +119,29 @@ async def test_native_group_storage_persist_clear_and_ordinary_updates(self, cli listed = (await client.get("/api/agent")).json() assert next(item for item in listed if item["name"] == "test-agent")["group"] == "" + @pytest.mark.asyncio + @pytest.mark.parametrize("section", ["agent", "mode"]) + @pytest.mark.parametrize("source", ["yaml", "storage"]) + async def test_generic_config_group_is_visible_without_manual_refresh(self, client, native_group_agents, section, source): + name = "yaml-demo" + if source == "storage": + name = f"config-cache-{section}" + created = await client.post("/api/agent", json={**_AGENT_PAYLOAD, "name": name, "group": "Package"}) + assert created.status_code == 200, created.text + before = await client.get(f"/api/agent/{name}") + assert before.status_code == 200, before.text + assert before.json()["group"] == "Package" + + for value, expected in (("修改后", "修改后"), (None, ""), ("New group", "New group"), ("", "")): + saved = await client.patch("/api/config/", json={section: {name: {"group": value}}}) + assert saved.status_code == 200, saved.text + detail = await client.get(f"/api/agent/{name}") + assert detail.status_code == 200, detail.text + assert detail.json()["group"] == expected + listed = await client.get("/api/agent") + assert next(item for item in listed.json() if item["name"] == name)["group"] == expected + assert detail.json()["prompt"] == before.json()["prompt"] + @pytest.mark.asyncio async def test_native_group_yaml_preserves_prompt_and_unknown_fields(self, client, native_group_agents): import yaml diff --git a/tests/server/routes/test_mcp_routes.py b/tests/server/routes/test_mcp_routes.py index e40c2ec52..14740bf98 100644 --- a/tests/server/routes/test_mcp_routes.py +++ b/tests/server/routes/test_mcp_routes.py @@ -12,6 +12,63 @@ class TestNativeMcpGroup: + @pytest.mark.asyncio + @pytest.mark.parametrize("stored_group,expected", [ + (["legacy"], None), ("x" * 33, None), ("a\x00b", None), + ("a\nb", None), ("a\x7fb", None), (None, None), ("", ""), (" Valid ", "Valid"), + ]) + async def test_bad_stored_group_cannot_break_mixed_status_and_info_reads( + self, tmp_path, monkeypatch, stored_group, expected, + ): + import json + from types import SimpleNamespace + from flocks.config.config_writer import ConfigWriter + from flocks.mcp.types import normalize_mcp_group + + monkeypatch.setenv("FLOCKS_CONFIG_DIR", str(tmp_path / "config")) + configs = { + "legacy": { + "type": "remote", "url": "https://example.invalid/mcp", "enabled": False, + "headers": {"Authorization": "{secret:unchanged}"}, "group": stored_group, + }, + "healthy": {"type": "remote", "url": "https://example.invalid/good", "group": " Operations "}, + "inherited": {"type": "remote", "url": "https://example.invalid/default"}, + "explicit-null": {"type": "remote", "url": "https://example.invalid/clear", "group": None}, + } + path = ConfigWriter._get_config_path() + path.parent.mkdir(parents=True, exist_ok=True) + # Bypass current validation only to reproduce legacy stored data. + path.write_text(json.dumps({"mcp": configs}), encoding="utf-8") + before = path.read_bytes() + entry = SimpleNamespace(group="Canonical", group_readonly=True) + monkeypatch.setattr(mcp_routes.McpCatalog, "get", lambda: SimpleNamespace(get_entry=lambda name: entry)) + monkeypatch.setattr(mcp_routes.MCP, "status", AsyncMock(return_value={ + "healthy": McpStatusInfo(status=McpStatus.CONNECTED, tools_count=3), + })) + monkeypatch.setattr(mcp_routes.MCP, "get_server_info", AsyncMock(return_value=None)) + + statuses = await mcp_routes.get_mcp_status() + assert set(statuses) == set(configs) + assert statuses["legacy"]["group"] == expected + assert statuses["legacy"]["status"] == McpStatus.DISABLED + assert statuses["legacy"]["group_readonly"] is False + assert statuses["healthy"]["group"] == "Operations" + assert statuses["healthy"]["tools_count"] == 3 + assert statuses["inherited"]["group"] == "Canonical" + assert statuses["explicit-null"]["group"] is None + info = await mcp_routes.get_mcp_server_info("legacy") + assert info["group"] == info["config"]["group"] == info["status"]["group"] == expected + assert info["config"]["url"] == configs["legacy"]["url"] + assert mcp_routes._to_frontend_mcp_config(configs["legacy"])["group"] == expected + assert entry.group == "Canonical" and entry.group_readonly is True + assert path.read_bytes() == before + assert ConfigWriter.get_mcp_server("legacy")["group"] == stored_group + if stored_group is not None and expected is None: + with pytest.raises(ValueError): + normalize_mcp_group(stored_group) + with pytest.raises(ValueError): + mcp_routes.McpUpdateRequest(config={"group": stored_group}) + @pytest.mark.parametrize("group", ["Instance", "", None]) def test_native_writers_preserve_group_yml_metadata_and_replace_connection(self, tmp_path, monkeypatch, group): import yaml diff --git a/tests/server/routes/test_remaining_routes.py b/tests/server/routes/test_remaining_routes.py index c5a8d51aa..e931aa01f 100644 --- a/tests/server/routes/test_remaining_routes.py +++ b/tests/server/routes/test_remaining_routes.py @@ -656,6 +656,50 @@ async def test_invalid_group_fails_before_secret_extraction(self, monkeypatch): update.assert_not_awaited() extract.assert_not_called() + @pytest.mark.asyncio + @pytest.mark.parametrize("section", ["mcp", "api_services"]) + @pytest.mark.parametrize("group", [["invalid"], {"bad": True}, 1, True, "x" * 33, "line\nbreak", "nul\x00value"]) + async def test_invalid_service_group_is_rejected_before_any_side_effect(self, monkeypatch, section, group): + from flocks.server.routes import config as config_routes + from flocks.security import channel_secrets + + update = AsyncMock() + extract = Mock(side_effect=AssertionError("Group must be validated before secrets are extracted")) + monkeypatch.setattr(config_routes.Config, "update", update) + monkeypatch.setattr(channel_secrets, "extract_channel_secrets", extract) + with pytest.raises(HTTPException) as exc: + await config_routes.update_config({ + section: {"configured-instance": {"group": group}}, + "channels": {"slack": {"botToken": "synthetic-not-a-real-token"}}, + }) + assert exc.value.status_code == status.HTTP_400_BAD_REQUEST + update.assert_not_awaited() + extract.assert_not_called() + + @pytest.mark.asyncio + @pytest.mark.parametrize("section", ["mcp", "api_services"]) + @pytest.mark.parametrize("group", [None, "", " Operations ", "安" * 32]) + async def test_service_group_preflight_accepts_valid_instance_values(self, section, group): + from flocks.server.routes import config as config_routes + + payload = {section: {"configured-instance": {"group": group}}} + await config_routes._validate_plugin_group_updates(payload) + assert payload[section]["configured-instance"]["group"] == group + + @pytest.mark.asyncio + async def test_failed_agent_config_write_keeps_agent_cache(self, monkeypatch): + from flocks.agent.registry import Agent + from flocks.server.routes import config as config_routes + + invalidate = Mock() + monkeypatch.setattr(Agent, "validate_group_settings", AsyncMock()) + monkeypatch.setattr(Agent, "invalidate_cache", invalidate) + monkeypatch.setattr(config_routes.Config, "update", AsyncMock(side_effect=OSError("write failed"))) + with pytest.raises(HTTPException) as exc: + await config_routes.update_config({"agent": {"custom": {"group": "After"}}}) + assert exc.value.status_code == status.HTTP_400_BAD_REQUEST + invalidate.assert_not_called() + @pytest.mark.asyncio async def test_config_has_expected_top_level_keys(self, client: AsyncClient): """Config response contains expected top-level keys.""" diff --git a/webui/src/pages/Tool/ToolGroups.test.tsx b/webui/src/pages/Tool/ToolGroups.test.tsx index bcac378f4..389dea714 100644 --- a/webui/src/pages/Tool/ToolGroups.test.tsx +++ b/webui/src/pages/Tool/ToolGroups.test.tsx @@ -248,6 +248,80 @@ describe('Tools native group attributes and original view preservation', () => { expect(screen.queryByText(tab === 'api' ? 'Native service B' : 'native-b')).not.toBeInTheDocument(); }); + it('filters API service names with no child tools and keeps the business group when search is cleared', async () => { + mocks.listServices.mockResolvedValue({ data: [ + { id: 'qa-alpha', name: 'QA Alpha', description: 'PR765 Alpha fixture', group: 'E2E', enabled: false, tool_count: 0 }, + { id: 'qa-beta', name: 'QA Beta', description: 'PR765 Beta fixture', group: 'E2E', enabled: false, tool_count: 0 }, + { id: 'other-alpha', name: 'Other Alpha', group: 'Other', enabled: false, tool_count: 0 }, + ] }); + await mount(); + fireEvent.click(screen.getByRole('button', { name: /^API 集成/ })); + await screen.findByText('QA Beta'); + fireEvent.click(screen.getByRole('button', { name: 'E2E 2' })); + const search = screen.getByPlaceholderText(zhTool.search.placeholder); + fireEvent.change(search, { target: { value: 'Alpha' } }); + expect(screen.getByText('QA Alpha')).toBeInTheDocument(); + expect(screen.queryByText('QA Beta')).not.toBeInTheDocument(); + expect(screen.queryByText('Other Alpha')).not.toBeInTheDocument(); + await waitFor(() => expect(mocks.listPage).toHaveBeenLastCalledWith(expect.objectContaining({ source: 'api', q: 'Alpha' }))); + fireEvent.change(search, { target: { value: 'zz-no-match-765' } }); + expect(await screen.findByText(zhTool.api.noTools)).toBeInTheDocument(); + expect(screen.queryByText('QA Alpha')).not.toBeInTheDocument(); + expect(screen.queryByText('QA Beta')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'E2E 2' })).toHaveAttribute('aria-pressed', 'true'); + fireEvent.change(search, { target: { value: '' } }); + expect(screen.getByText('QA Alpha')).toBeInTheDocument(); + expect(screen.getByText('QA Beta')).toBeInTheDocument(); + expect(screen.queryByText('Other Alpha')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'E2E 2' })).toHaveAttribute('aria-pressed', 'true'); + }); + + it('matches API child keywords through complete service facets beyond the first 25 tools', async () => { + mocks.listServices.mockResolvedValue({ data: [ + { id: 'alpha_v1', name: 'QA Alpha', group: 'Services', enabled: true, tool_count: 25 }, + { id: 'beta_v2', name: 'QA Beta', group: 'Services', enabled: true, tool_count: 1 }, + { id: 'gamma_v1', name: 'QA Gamma', group: 'Services', enabled: true, tool_count: 0 }, + ] }); + const toolRows = Array.from({ length: 26 }, (_, index) => ({ ...inventory[0], + name: `child_${index}`, description: 'needle keyword', source: 'api' as const, + source_name: index < 25 ? 'alpha_v1' : 'beta_v2' })); + const apiPage = (params: Record) => { + const matches = toolRows.filter((tool) => !params.q || `${tool.name} ${tool.description} ${tool.source_name}`.toLowerCase().includes(params.q.toLowerCase())); + const sourceNames: Record = {}; + matches.forEach((tool) => { sourceNames[tool.source_name] = (sourceNames[tool.source_name] ?? 0) + 1; }); + return { data: { items: matches.slice(0, 25), total: matches.length, offset: 0, limit: 25, + facets: { group: {}, category: {}, source: { api: matches.length }, source_groups: { api: Object.keys(sourceNames).length }, source_name: sourceNames, enabled: {} } } }; + }; + const originalPage = mocks.listPage.getMockImplementation()!; + let resolveSearch!: (result: ReturnType) => void; + mocks.listPage.mockImplementation((params) => { + if (params.source !== 'api') return originalPage(params); + if (params.q === 'needle') return new Promise((resolve) => { resolveSearch = resolve; }); + return Promise.resolve(apiPage(params)); + }); + await mount(); + fireEvent.click(screen.getByRole('button', { name: /^API 集成/ })); + await screen.findByText('QA Beta'); + const search = screen.getByPlaceholderText(zhTool.search.placeholder); + fireEvent.change(search, { target: { value: 'needle' } }); + // The old unfiltered facets must not leak into the debounce/loading window. + expect(screen.queryByText('QA Alpha')).not.toBeInTheDocument(); + expect(screen.queryByText('QA Beta')).not.toBeInTheDocument(); + expect(screen.queryByText(zhTool.api.noTools)).not.toBeInTheDocument(); + await waitFor(() => expect(resolveSearch).toBeDefined()); + expect(screen.getByRole('complementary').parentElement).toHaveAttribute('aria-busy', 'true'); + resolveSearch(apiPage({ q: 'needle' })); + expect(await screen.findByText('QA Alpha')).toBeInTheDocument(); + expect(screen.getByText('QA Beta')).toBeInTheDocument(); + expect(screen.queryByText('QA Gamma')).not.toBeInTheDocument(); + expect(mocks.listPage).toHaveBeenLastCalledWith(expect.objectContaining({ source: 'api', q: 'needle', offset: 0, limit: 25 })); + expect(mocks.get).not.toHaveBeenCalled(); + fireEvent.change(search, { target: { value: 'zz-no-match-765' } }); + expect(screen.queryByText('QA Alpha')).not.toBeInTheDocument(); + expect(screen.queryByText('QA Beta')).not.toBeInTheDocument(); + expect(await screen.findByText(zhTool.api.noTools)).toBeInTheDocument(); + }); + it.each([ ['all', 'list'], ['all', 'cards'], ['local', 'list'], ['local', 'cards'], ])('offers an explicit group action in the %s tab %s view without opening tool details', async (tab, viewMode) => { diff --git a/webui/src/pages/Tool/components/APITabContent.test.tsx b/webui/src/pages/Tool/components/APITabContent.test.tsx index b889655bb..b47a99ef6 100644 --- a/webui/src/pages/Tool/components/APITabContent.test.tsx +++ b/webui/src/pages/Tool/components/APITabContent.test.tsx @@ -60,6 +60,94 @@ describe('APITabContent', () => { listAllToolPages.mockResolvedValue([]); }); + it.each([' Alpha ', 'QA-ALPHA_V2', 'unique English', '独特描述'])('searches service metadata independently of child tools (%s)', async (searchQuery) => { + providerAPI.listApiServices.mockResolvedValue({ data: [ + { id: 'qa-alpha_v2', name: 'QA Alpha', description: 'unique English description', description_cn: '独特描述', group: 'Ops', enabled: false, tool_count: 0 }, + { id: 'qa-beta_v1', name: 'QA Beta', description: 'Other description', group: 'Ops', enabled: false, tool_count: 0 }, + ] }); + render(); + expect(await screen.findByText('QA Alpha')).toBeInTheDocument(); + expect(screen.queryByText('QA Beta')).not.toBeInTheDocument(); + expect(listAllToolPages).not.toHaveBeenCalled(); + }); + + it('composes search with group selection, shows no matches, and restores rows without closing details', async () => { + providerAPI.listApiServices.mockResolvedValue({ data: [ + { id: 'qa-alpha', name: 'QA Alpha', group: 'Ops', enabled: false, tool_count: 0 }, + { id: 'qa-beta', name: 'QA Beta', group: 'Ops', enabled: false, tool_count: 0 }, + { id: 'other-alpha', name: 'Outside Alpha', group: 'Other', enabled: false, tool_count: 0 }, + ] }); + listAllToolPages.mockResolvedValue([{ name: 'complete-drawer-tool' }]); + const props = { tools: [], matchingToolServices: {}, onSelectTool: vi.fn(), onRefreshTools: vi.fn(), + catalogEntries: [], catalogCategories: {}, catalogLoading: false, configuredIds: new Set(), onConfiguredChange: vi.fn() }; + const { rerender } = render(); + await screen.findByText('QA Alpha'); + fireEvent.click(screen.getByRole('button', { name: 'Ops 2' })); + fireEvent.click(screen.getByText('QA Alpha', { selector: 'span' }).closest('button')!); + expect(await screen.findByText('complete-drawer-tool')).toBeInTheDocument(); + rerender(); + expect(screen.getByText('QA Alpha', { selector: 'span' })).toBeInTheDocument(); + expect(screen.queryByText('QA Beta')).not.toBeInTheDocument(); + expect(screen.queryByText('Outside Alpha')).not.toBeInTheDocument(); + rerender(); + expect(screen.getByText('api.noTools')).toBeInTheDocument(); + expect(screen.queryByText('QA Alpha', { selector: 'span' })).not.toBeInTheDocument(); + expect(screen.queryByText('QA Beta')).not.toBeInTheDocument(); + expect(screen.getByText('complete-drawer-tool')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Ops 2' })).toHaveAttribute('aria-pressed', 'true'); + rerender(); + expect(screen.getByText('QA Alpha', { selector: 'span' })).toBeInTheDocument(); + expect(screen.getByText('QA Beta')).toBeInTheDocument(); + expect(screen.queryByText('Outside Alpha')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Ops 2' })).toHaveAttribute('aria-pressed', 'true'); + expect(listAllToolPages).toHaveBeenCalledExactlyOnceWith({ source: 'api', sourceName: 'qa-alpha', sortBy: 'name', sortDir: 'asc' }); + }); + + it('uses complete tool facets, not the current page, and waits for pending tool matches', async () => { + providerAPI.listApiServices.mockResolvedValue({ data: [ + { id: 'alpha_v1', name: 'QA Alpha', enabled: true, tool_count: 25 }, + { id: 'beta_v2', name: 'QA Beta', enabled: true, tool_count: 1 }, + { id: 'beta_v1', name: 'Other Beta version', enabled: true, tool_count: 0 }, + ] }); + const tools = Array.from({ length: 25 }, (_, index) => ({ name: `needle_${index}`, description: 'needle', + source: 'api' as const, source_name: 'alpha_v1', category: 'custom', parameters: [], enabled: true, requires_confirmation: false })); + const props = { tools, searchQuery: 'needle', onSelectTool: vi.fn(), onRefreshTools: vi.fn(), + catalogEntries: [], catalogCategories: {}, catalogLoading: false, configuredIds: new Set(), onConfiguredChange: vi.fn() }; + const { rerender } = render(); + await waitFor(() => expect(providerAPI.listApiServices).toHaveBeenCalledOnce()); + expect(screen.queryByText('QA Alpha', { selector: 'span' })).not.toBeInTheDocument(); + expect(screen.queryByText('QA Beta')).not.toBeInTheDocument(); + expect(screen.queryByText('api.noTools')).not.toBeInTheDocument(); + expect(screen.getByText('loading')).toBeInTheDocument(); + rerender(); + expect(screen.getByText('QA Alpha', { selector: 'span' })).toBeInTheDocument(); + expect(screen.getByText('QA Beta')).toBeInTheDocument(); + expect(screen.queryByText('Other Beta version')).not.toBeInTheDocument(); + // A failed/missing summary must not fall back to matching the 25-row sample. + rerender(); + expect(screen.queryByText('QA Alpha', { selector: 'span' })).not.toBeInTheDocument(); + expect(screen.getByText('api.noTools')).toBeInTheDocument(); + expect(listAllToolPages).not.toHaveBeenCalled(); + }); + + it('keeps catalog keyword search independent of service and child-tool matches', async () => { + providerAPI.listApiServices.mockResolvedValue({ data: [{ id: 'service', name: 'Native service', enabled: false, tool_count: 0 }] }); + const props = { tools: [], onSelectTool: vi.fn(), onRefreshTools: vi.fn(), + catalogEntries: [{ id: 'catalog', name: 'Catalog API', description: 'Catalog description', category: 'intel', tool_type: 'api' as const, + github: '', language: 'python', license: 'MIT', stars: 1, transport: 'stdio', install: {}, env_vars: {}, + system_deps: [], tags: ['catalog-keyword'], official: false, requires_auth: false }], + catalogCategories: {}, catalogLoading: false, configuredIds: new Set(), onConfiguredChange: vi.fn() }; + const { rerender } = render(); + expect(await screen.findByText('Catalog API')).toBeInTheDocument(); + expect(screen.queryByText('Native service')).not.toBeInTheDocument(); + rerender(); + expect(screen.queryByText('Catalog API')).not.toBeInTheDocument(); + expect(screen.getByText('api.noTools')).toBeInTheDocument(); + expect(mcpAPI.catalogInstall).not.toHaveBeenCalled(); + }); + it('keeps versioned service identities, full group counts, card fields/actions and independent drawer tools', async () => { providerAPI.listApiServices.mockResolvedValue({ data: [ { id: 'service-a__v9_2', name: 'Service A', version: '9.2', description: 'Service A description', enabled: true, status: 'connected', tool_count: 40, latency_ms: 12, verify_ssl: false, group: 'Alpha' }, diff --git a/webui/src/pages/Tool/components/APITabContent.tsx b/webui/src/pages/Tool/components/APITabContent.tsx index 94db5d93d..5ab560331 100644 --- a/webui/src/pages/Tool/components/APITabContent.tsx +++ b/webui/src/pages/Tool/components/APITabContent.tsx @@ -34,6 +34,9 @@ const INSTALL_CONFIRM_BUTTON_CLASS = 'px-4 py-2 text-sm text-white bg-green-600 interface APITabContentProps { tools: Tool[]; searchQuery?: string; + /** Complete source_name facet for the current query, not the loaded tool page. */ + matchingToolServices?: Readonly>; + toolSearchPending?: boolean; onSelectTool: (tool: Tool) => void; onRefreshTools: () => Promise; viewMode?: 'list' | 'cards'; @@ -47,6 +50,8 @@ interface APITabContentProps { export default function APITabContent({ tools, searchQuery = '', + matchingToolServices, + toolSearchPending = false, onSelectTool, onRefreshTools, viewMode = 'list', @@ -364,12 +369,14 @@ export default function APITabContent({ }; const query = searchQuery.trim().toLowerCase(); const matchesQuery = (...values: (string | undefined)[]) => !query || values.some((value) => value?.toLowerCase().includes(query)); - const visibleServices = services.filter((service) => matchesGroup(service.group, groupSelection)); + const visibleServices = services.filter((service) => matchesGroup(service.group, groupSelection) + && (matchesQuery(service.id, service.name, service.description, service.description_cn) + || (!toolSearchPending && (matchingToolServices?.[service.id] ?? 0) > 0))); const visibleCatalog = filteredCatalog.filter((entry) => matchesGroup(entry.group, groupSelection) && matchesQuery(entry.id, entry.name, entry.description, entry.description_cn, ...entry.tags)); return ( -
+
changeGroup(name, null)} {...groupDrag} />
{servicesError !== null &&
@@ -396,7 +403,8 @@ export default function APITabContent({ )}
- {servicesLoading && services.length === 0 && filteredCatalog.length === 0 ? ( + {(servicesLoading && services.length === 0 && filteredCatalog.length === 0) + || (query && toolSearchPending && visibleServices.length === 0 && visibleCatalog.length === 0) ? (
diff --git a/webui/src/pages/Tool/index.tsx b/webui/src/pages/Tool/index.tsx index 75fcddae0..7921e127e 100644 --- a/webui/src/pages/Tool/index.tsx +++ b/webui/src/pages/Tool/index.tsx @@ -242,6 +242,9 @@ export default function ToolPage() { reload: reloadToolPage, } = useToolPage(toolPageParams); const [hasLoadedToolPage, setHasLoadedToolPage] = useState(false); + // Do not apply a previous query's service facets during the input debounce or load. + const apiToolSearchPending = searchQuery.trim().toLowerCase() !== debouncedSearchQuery.trim().toLowerCase() + || loading || !toolPageInitialized; // Catalog data (fetched once at top level, shared with MCP & API tabs) const [catalogEntries, setCatalogEntries] = useState([]); @@ -724,6 +727,8 @@ export default function ToolPage() { Date: Tue, 22 Sep 2026 14:26:39 +0800 Subject: [PATCH 5/5] fix(ui): count API and MCP service inventories consistently Exclude device services from API totals while including disabled API services. Count MCP service identities from native server and catalog inventories instead of tool facets, including zero-tool services. Synchronize tab totals after service updates and refreshes while preserving tool-level activation statistics and previous counts on failed reloads. Co-Authored-By: Claude Code --- webui/src/pages/Tool/ToolGroups.test.tsx | 109 ++++++++++++++++-- .../pages/Tool/ToolPageAutoDisable.test.tsx | 2 + .../Tool/components/APITabContent.test.tsx | 18 +++ .../pages/Tool/components/APITabContent.tsx | 9 +- .../pages/Tool/components/MCPTabContent.tsx | 13 +-- webui/src/pages/Tool/index.tsx | 50 +++++--- webui/src/pages/Tool/tabCounts.test.ts | 45 ++++++-- webui/src/pages/Tool/tabCounts.ts | 20 +++- 8 files changed, 220 insertions(+), 46 deletions(-) diff --git a/webui/src/pages/Tool/ToolGroups.test.tsx b/webui/src/pages/Tool/ToolGroups.test.tsx index 389dea714..94c06f433 100644 --- a/webui/src/pages/Tool/ToolGroups.test.tsx +++ b/webui/src/pages/Tool/ToolGroups.test.tsx @@ -11,12 +11,13 @@ import zhGroups from '@/locales/zh-CN/pluginGroups.json'; import enGroups from '@/locales/en-US/pluginGroups.json'; const mocks = vi.hoisted(() => ({ - listPage: vi.fn(), get: vi.fn(), patch: vi.fn(), refresh: vi.fn(), listServices: vi.fn(), - auth: { role: 'admin' }, toastWarning: vi.fn(), + listPage: vi.fn(), get: vi.fn(), patch: vi.fn(), refresh: vi.fn(), listServices: vi.fn(), listMcp: vi.fn(), + auth: { role: 'admin' }, toastWarning: vi.fn(), updateService: vi.fn(), deleteService: vi.fn(), })); vi.mock('@/api/client', () => ({ default: { get: (url: string, config?: { params?: Record }) => { if (url === '/api/tools/page') return mocks.listPage(config?.params); + if (url === '/api/mcp') return mocks.listMcp(); if (url.endsWith('/fixtures')) return Promise.resolve({ data: [] }); return mocks.get(url); }, @@ -24,7 +25,7 @@ vi.mock('@/api/client', () => ({ default: { post: (...args: unknown[]) => mocks.refresh(...args), } })); vi.mock('@/contexts/AuthContext', () => ({ useAuth: () => ({ user: mocks.auth }) })); -vi.mock('@/api/provider', () => ({ providerAPI: { listApiServices: mocks.listServices } })); +vi.mock('@/api/provider', () => ({ providerAPI: { listApiServices: mocks.listServices, updateApiService: mocks.updateService, deleteApiService: mocks.deleteService } })); vi.mock('@/components/common/Toast', () => ({ useToast: () => ({ error: vi.fn(), warning: mocks.toastWarning, success: vi.fn() }), })); @@ -62,6 +63,7 @@ beforeEach(() => { group: index < 30 ? 'Alpha' : index < 60 ? 'Beta' : null, })); mocks.listServices.mockResolvedValue({ data: [] }); + mocks.listMcp.mockResolvedValue({ data: {} }); mocks.get.mockImplementation(async (url: string) => ({ data: inventory.find((tool) => url === `/api/tools/${tool.name}`) })); mocks.listPage.mockImplementation(async (params: Record) => { const eligible = inventory.filter((tool) => (!params.q || tool.name.includes(params.q)) @@ -222,12 +224,12 @@ describe('Tools native group attributes and original view preservation', () => { { id: 'native-a', name: 'Native service A', group: 'Services', enabled: true, status: 'connected', tool_count: 0 }, { id: 'native-b', name: 'Native service B', group: null, enabled: true, status: 'connected', tool_count: 0 }, ] }); + mocks.listMcp.mockResolvedValue({ data: { + 'native-a': { status: 'connected', group: 'Services', tools: [], resources: [] }, + 'native-b': { status: 'connected', group: null, tools: [], resources: [] }, + } }); const originalGet = mocks.get.getMockImplementation()!; mocks.get.mockImplementation((url: string) => { - if (url === '/api/mcp') return Promise.resolve({ data: { - 'native-a': { status: 'connected', group: 'Services', tools: [], resources: [] }, - 'native-b': { status: 'connected', group: null, tools: [], resources: [] }, - } }); if (url.startsWith('/api/mcp/catalog/')) return Promise.resolve({ data: url.endsWith('/categories') ? {} : [] }); return originalGet(url); }); @@ -248,6 +250,99 @@ describe('Tools native group attributes and original view preservation', () => { expect(screen.queryByText(tab === 'api' ? 'Native service B' : 'native-b')).not.toBeInTheDocument(); }); + it('counts all ten API services, excludes devices, and stays synchronized after native changes', async () => { + let services = Array.from({ length: 26 }, (_, index) => ({ + id: `api-${index}`, name: `API service ${index}`, enabled: index < 7 || index >= 10, + integration_type: index >= 10 ? 'device' : undefined, group: index < 5 ? 'Ops' : null, + status: 'unknown', tool_count: 0, builtin: false, verify_ssl: false, + })); + mocks.listServices.mockImplementation(async () => ({ data: services })); + mocks.updateService.mockImplementation(async (id, body) => { + services = services.map((service) => service.id === id ? { ...service, ...body } : service); + return { data: services.find((service) => service.id === id) }; + }); + mocks.deleteService.mockImplementation(async (id) => { + services = services.filter((service) => service.id !== id); + return { data: {} }; + }); + mocks.refresh.mockResolvedValue({ data: { status: 'success', tool_count: 61, message: '', stages: {}, errors: [] } }); + await mount(); + fireEvent.click(await screen.findByRole('button', { name: /^API 集成\s+10$/ })); + await screen.findByText('API service 9'); + const nav = screen.getByRole('complementary'); + expect(within(nav).getByRole('button', { name: '全部 10' })).toBeInTheDocument(); + expect(screen.queryByText('API service 10')).not.toBeInTheDocument(); + const row = (index: number) => screen.getByText(`API service ${index}`).closest('[draggable]') as HTMLElement; + fireEvent.click(within(row(0)).getByTitle(zhTool.detail.disableServer)); + await waitFor(() => expect(mocks.updateService).toHaveBeenCalledWith('api-0', { enabled: false })); + await waitFor(() => expect(within(row(0)).getByRole('button', { name: zhTool.detail.enableServer })).toBeEnabled()); + expect(screen.getByRole('button', { name: /^API 集成\s+10$/ })).toBeInTheDocument(); + fireEvent.click(within(row(7)).getByRole('button', { name: zhTool.detail.enableServer })); + await waitFor(() => expect(mocks.updateService).toHaveBeenCalledWith('api-7', { enabled: true })); + await waitFor(() => expect(within(row(7)).getByTitle(zhTool.detail.disableServer)).toBeEnabled()); + expect(screen.getByRole('button', { name: /^API 集成\s+10$/ })).toBeInTheDocument(); + fireEvent.click(within(row(9)).getByTitle(zhTool.button.delete)); + await waitFor(() => expect(screen.getByRole('button', { name: /^API 集成\s+9$/ })).toBeInTheDocument()); + await waitFor(() => expect(within(nav).getByRole('button', { name: '全部 9' })).toBeInTheDocument()); + services.push({ ...services[0], id: 'added', name: 'Added API' }); + fireEvent.click(screen.getByTitle(zhTool.button.refreshList)); + await screen.findByText('Added API'); + expect(screen.getByRole('button', { name: /^API 集成\s+10$/ })).toBeInTheDocument(); + expect(within(nav).getByRole('button', { name: '全部 10' })).toBeInTheDocument(); + fireEvent.click(within(nav).getByRole('button', { name: 'Ops 6' })); + fireEvent.change(screen.getByPlaceholderText(zhTool.search.placeholder), { target: { value: 'zz-no-match-counts' } }); + await screen.findByText(zhTool.api.noTools); + expect(screen.getByRole('button', { name: /^API 集成\s+10$/ })).toBeInTheDocument(); + expect(within(nav).getByRole('button', { name: '全部 10' })).toBeInTheDocument(); + }); + + it('counts eleven MCP services including zero-tool and inactive rows, without changing tool totals', async () => { + const statuses = ['connected', 'connected', 'connected', 'connected', 'connected', 'connected', 'connected', 'error', 'disabled', 'disabled', 'disconnected']; + let servers = Object.fromEntries(statuses.map((status, index) => [index === 6 ? 'gridinsoft' : `mcp-${index}`, { + status, tools_count: index < 6 ? [6, 6, 6, 1, 15, 15][index] : 0, tools: [], resources: [], group: 'MCP group', + }])); + const catalog = Object.keys(servers).filter((id) => id !== 'mcp-10').map((id) => ({ + id, name: id === 'gridinsoft' ? 'GridinSoft' : id, description: 'Native catalog', category: 'intel', tool_type: 'mcp', + github: '', language: 'python', license: 'MIT', stars: 0, transport: 'stdio', install: {}, env_vars: {}, + system_deps: [], tags: [], official: false, requires_auth: false, group: 'MCP group', + })); + mocks.listMcp.mockImplementation(async () => ({ data: servers })); + mocks.get.mockImplementation(async (url) => ({ data: url.endsWith('/entries') ? catalog : url.endsWith('/categories') ? {} : [] })); + const originalPage = mocks.listPage.getMockImplementation()!; + mocks.listPage.mockImplementation((params) => params.source === 'mcp' ? Promise.resolve({ data: { + items: [], total: params.q ? 0 : 49, offset: 0, limit: 25, + facets: { group: {}, category: {}, source: { mcp: params.q ? 0 : 49 }, source_groups: { mcp: params.q ? 0 : 6 }, source_name: {}, enabled: { true: 46, false: 3 } }, + } }) : originalPage(params)); + mocks.refresh.mockResolvedValue({ data: { status: 'success', tool_count: 49, message: '', stages: {}, errors: [] } }); + await mount(); + fireEvent.click(await screen.findByRole('button', { name: /^MCP 服务\s+11$/ })); + await screen.findByText('GridinSoft'); + const nav = screen.getByRole('complementary'); + expect(within(nav).getByRole('button', { name: '全部 11' })).toBeInTheDocument(); + const header = screen.getByRole('heading', { name: zhTool.pageTitle }).parentElement!; + expect(header).toHaveTextContent(`46 ${zhTool.statusBadge.active}`); + expect(header).toHaveTextContent(`3 ${zhTool.statusBadge.inactive}`); + fireEvent.click(within(nav).getByRole('button', { name: 'MCP group 11' })); + fireEvent.change(screen.getByPlaceholderText(zhTool.search.placeholder), { target: { value: 'GridinSoft' } }); + await waitFor(() => expect(mocks.listPage).toHaveBeenLastCalledWith(expect.objectContaining({ q: 'GridinSoft' }))); + expect(screen.getByRole('button', { name: /^MCP 服务\s+11$/ })).toBeInTheDocument(); + // A removed catalog-backed configuration still has its original catalog row. + delete servers['mcp-9']; + fireEvent.click(screen.getByTitle(zhTool.button.refreshList)); + await waitFor(() => expect(screen.getByTitle(zhTool.button.refreshDone)).toBeEnabled()); + expect(screen.getByRole('button', { name: /^MCP 服务\s+11$/ })).toBeInTheDocument(); + delete servers['mcp-10']; + fireEvent.click(screen.getByTitle(zhTool.button.refreshDone)); + await waitFor(() => expect(screen.getByRole('button', { name: /^MCP 服务\s+10$/ })).toBeInTheDocument()); + await waitFor(() => expect(within(nav).getByRole('button', { name: '全部 10' })).toBeInTheDocument()); + mocks.listMcp.mockRejectedValue(new Error('native inventory offline')); + await waitFor(() => expect(screen.getByTitle(zhTool.button.refreshDone)).toBeEnabled()); + fireEvent.click(screen.getByTitle(zhTool.button.refreshDone)); + await screen.findByText('native inventory offline'); + expect(screen.getByRole('button', { name: /^MCP 服务\s+10$/ })).toBeInTheDocument(); + expect(within(nav).getByRole('button', { name: '全部 10' })).toBeInTheDocument(); + }); + it('filters API service names with no child tools and keeps the business group when search is cleared', async () => { mocks.listServices.mockResolvedValue({ data: [ { id: 'qa-alpha', name: 'QA Alpha', description: 'PR765 Alpha fixture', group: 'E2E', enabled: false, tool_count: 0 }, diff --git a/webui/src/pages/Tool/ToolPageAutoDisable.test.tsx b/webui/src/pages/Tool/ToolPageAutoDisable.test.tsx index 5afe71053..1433192fa 100644 --- a/webui/src/pages/Tool/ToolPageAutoDisable.test.tsx +++ b/webui/src/pages/Tool/ToolPageAutoDisable.test.tsx @@ -73,6 +73,8 @@ vi.mock('@/api/tool', () => ({ }, })); +vi.mock('@/api/mcp', () => ({ mcpAPI: { list: vi.fn(() => Promise.resolve({ data: {} })) } })); + vi.mock('@/api/provider', () => ({ providerAPI: { listApiServices: vi.fn(() => Promise.resolve({ data: [] })), diff --git a/webui/src/pages/Tool/components/APITabContent.test.tsx b/webui/src/pages/Tool/components/APITabContent.test.tsx index b47a99ef6..254e484f8 100644 --- a/webui/src/pages/Tool/components/APITabContent.test.tsx +++ b/webui/src/pages/Tool/components/APITabContent.test.tsx @@ -148,6 +148,24 @@ describe('APITabContent', () => { expect(mcpAPI.catalogInstall).not.toHaveBeenCalled(); }); + it('reports the full API inventory count across filters and native reloads', async () => { + const service = { id: 'api', name: 'API service', enabled: false, tool_count: 0, group: 'Ops' }; + providerAPI.listApiServices.mockResolvedValue({ data: [service, { ...service, id: 'device', integration_type: 'device' }] }); + const onServiceCountChange = vi.fn(); + const props = { tools: [], onSelectTool: vi.fn(), onRefreshTools: vi.fn(), onServiceCountChange, + catalogEntries: [], catalogCategories: {}, catalogLoading: false, configuredIds: new Set(), onConfiguredChange: vi.fn() }; + const { rerender } = render(); + await waitFor(() => expect(onServiceCountChange).toHaveBeenLastCalledWith(1)); + fireEvent.click(screen.getByRole('button', { name: 'Ops 1' })); + rerender(); + expect(screen.getByText('api.noTools')).toBeInTheDocument(); + expect(onServiceCountChange).toHaveBeenLastCalledWith(1); + providerAPI.listApiServices.mockResolvedValue({ data: [service, { ...service, id: 'added' }] }); + rerender(); + await waitFor(() => expect(onServiceCountChange).toHaveBeenLastCalledWith(2)); + expect(screen.getByRole('button', { name: 'Ops 2' })).toHaveAttribute('aria-pressed', 'true'); + }); + it('keeps versioned service identities, full group counts, card fields/actions and independent drawer tools', async () => { providerAPI.listApiServices.mockResolvedValue({ data: [ { id: 'service-a__v9_2', name: 'Service A', version: '9.2', description: 'Service A description', enabled: true, status: 'connected', tool_count: 40, latency_ms: 12, verify_ssl: false, group: 'Alpha' }, diff --git a/webui/src/pages/Tool/components/APITabContent.tsx b/webui/src/pages/Tool/components/APITabContent.tsx index 5ab560331..cd5b9fe57 100644 --- a/webui/src/pages/Tool/components/APITabContent.tsx +++ b/webui/src/pages/Tool/components/APITabContent.tsx @@ -39,6 +39,8 @@ interface APITabContentProps { toolSearchPending?: boolean; onSelectTool: (tool: Tool) => void; onRefreshTools: () => Promise; + onServiceCountChange?: (count: number) => void; + refreshKey?: number; viewMode?: 'list' | 'cards'; catalogEntries: MCPCatalogEntry[]; catalogCategories: Record; @@ -54,6 +56,8 @@ export default function APITabContent({ toolSearchPending = false, onSelectTool, onRefreshTools, + onServiceCountChange, + refreshKey, viewMode = 'list', catalogEntries, catalogCategories, @@ -101,7 +105,7 @@ export default function APITabContent({ useEffect(() => { fetchServices(); - }, [fetchServices]); + }, [fetchServices, refreshKey]); const selectedService = useMemo( () => (selectedServiceId ? services.find((service) => service.id === selectedServiceId) ?? null : null), @@ -344,6 +348,9 @@ export default function APITabContent({ ...catalogEntries.filter((entry) => !nativeServices.some((service) => service.id === entry.id)).map(asCatalogGroupItem), ]; const groupItems = groupInventory(services); + useEffect(() => { + if (!servicesLoading && servicesError === null) onServiceCountChange?.(groupItems.length); + }, [groupItems.length, servicesLoading, servicesError, onServiceCountChange]); const { warning } = useToast(); const groupDrag = useGroupDrag(groupItems, warning); const reloadGroupData = () => fetchServices(true); diff --git a/webui/src/pages/Tool/components/MCPTabContent.tsx b/webui/src/pages/Tool/components/MCPTabContent.tsx index 3c46e3b1e..6359cdf0d 100644 --- a/webui/src/pages/Tool/components/MCPTabContent.tsx +++ b/webui/src/pages/Tool/components/MCPTabContent.tsx @@ -46,6 +46,7 @@ interface MCPTabContentProps { searchQuery: string; onSelectTool: (tool: Tool) => void; onRefreshTools: () => Promise; + onServersChange?: (servers: MCPServer[]) => void; viewMode?: 'list' | 'cards'; catalogEntries: MCPCatalogEntry[]; catalogCategories: Record; @@ -73,6 +74,7 @@ export default function MCPTabContent({ searchQuery, onSelectTool, onRefreshTools, + onServersChange, viewMode = 'list', catalogEntries, catalogCategories, @@ -102,6 +104,7 @@ export default function MCPTabContent({ const response = await mcpAPI.list(); const newServers = serverRows(response.data); setServers(newServers); + onServersChange?.(newServers); setServersError(null); setSelectedServerData((prev) => { if (!prev) return prev; @@ -115,17 +118,11 @@ export default function MCPTabContent({ } finally { setServersLoading(false); } - }, []); + }, [onServersChange]); useEffect(() => { fetchServers(); - }, [fetchServers]); - - useEffect(() => { - if (refreshKey && refreshKey > 0) { - fetchServers(); - } - }, [refreshKey, fetchServers]); + }, [fetchServers, refreshKey]); const handleConnect = async (name: string, e?: React.MouseEvent) => { e?.stopPropagation(); diff --git a/webui/src/pages/Tool/index.tsx b/webui/src/pages/Tool/index.tsx index 7921e127e..76f5be45d 100644 --- a/webui/src/pages/Tool/index.tsx +++ b/webui/src/pages/Tool/index.tsx @@ -73,7 +73,7 @@ import { shouldLoadMcpCatalog, type TabKey, } from './tabLoading'; -import { getToolTabCounts } from './tabCounts'; +import { getApiServiceCount, getMcpServiceCount, getToolTabCounts } from './tabCounts'; // ============================================================================ // Constants & Config @@ -189,14 +189,15 @@ export default function ToolPage() { // Sheet state const [showMCPSheet, setShowMCPSheet] = useState(false); - const [mcpRefreshKey, setMcpRefreshKey] = useState(0); + const [serviceRefreshKey, setServiceRefreshKey] = useState(0); const [showAPISheet, setShowAPISheet] = useState(false); const [showGenerateSheet, setShowGenerateSheet] = useState(false); // Sort: default by 类别 (source) MCP -> API -> 内置 const [sort, setSort] = useState({ field: 'source', dir: 'asc' }); const [filters, setFilters] = useState(EMPTY_FILTERS); - const [apiEnabledServicesCount, setApiEnabledServicesCount] = useState(0); + const [apiServicesCount, setApiServicesCount] = useState(0); + const [mcpInventory, setMcpInventory] = useState | MCPServer[]>({}); const debouncedSearchQuery = useDebouncedValue(searchQuery, 250); const tabSourceFilter = getTabSourceFilter(activeTab); const sourceFilterParam = useMemo(() => { @@ -310,27 +311,37 @@ export default function ToolPage() { setConfiguredIds(prev => { const next = new Set(prev); next.delete(id); return next; }); }, []); - const fetchApiServicesCount = useCallback(async () => { + const fetchApiServicesCount = useCallback(async (force = false) => { try { - const res = await providerAPI.listApiServices(); - const services = Array.isArray(res.data) ? res.data : []; - setApiEnabledServicesCount(services.filter((service) => service.enabled).length); + const res = await providerAPI.listApiServices(force ? { force: true } : undefined); + setApiServicesCount(getApiServiceCount(Array.isArray(res.data) ? res.data : [])); } catch { - // Keep the previous count when the status request fails. + // Keep the previous native inventory count on a failed reload. + } + }, []); + + const fetchMcpInventory = useCallback(async () => { + try { + const res = await mcpAPI.list(); + setMcpInventory(res.data); + } catch { + // A tool facet cannot stand in for missing/zero-tool native services. } }, []); useEffect(() => { - fetchApiServicesCount(); - }, [fetchApiServicesCount]); + void fetchApiServicesCount(); + void fetchMcpInventory(); + }, [fetchApiServicesCount, fetchMcpInventory]); const refreshToolData = useCallback(async () => { const [refreshResult] = await Promise.all([ refetch(), - fetchApiServicesCount(), + fetchApiServicesCount(true), + fetchMcpInventory(), ]); return refreshResult; - }, [refetch, fetchApiServicesCount]); + }, [refetch, fetchApiServicesCount, fetchMcpInventory]); const { user } = useAuth(); const readOnlyReason = user?.role === 'admin' ? undefined : t('pluginGroups:readOnly.admin'); @@ -424,8 +435,11 @@ export default function ToolPage() { const apiCatalogEntries = useMemo(() => [] as MCPCatalogEntry[], []); const tabCounts = useMemo( - () => getToolTabCounts(totalTools, toolFacets, apiEnabledServicesCount), - [totalTools, toolFacets, apiEnabledServicesCount], + () => getToolTabCounts(totalTools, toolFacets, { + api: apiServicesCount, + mcp: getMcpServiceCount(mcpInventory, mcpCatalogEntries), + }), + [totalTools, toolFacets, apiServicesCount, mcpInventory, mcpCatalogEntries], ); const enabledSummary = useMemo(() => ({ active: toolFacets.enabled['true'] ?? tools.filter((tool) => tool.enabled).length, @@ -492,6 +506,7 @@ export default function ToolPage() { const message = extractErrorMessage(err, t('alert.unknownError')); toast.error(t('alert.refreshFailedTitle'), message); } finally { + setServiceRefreshKey((key) => key + 1); setRefreshing(false); } }; @@ -712,6 +727,7 @@ export default function ToolPage() { ) : activeTab === 'api' ? ( @@ -729,6 +745,8 @@ export default function ToolPage() { searchQuery={searchQuery} matchingToolServices={apiToolSearchPending || error ? undefined : toolFacets.source_name} toolSearchPending={apiToolSearchPending} + onServiceCountChange={setApiServicesCount} + refreshKey={serviceRefreshKey} onSelectTool={openDetail} onRefreshTools={refreshToolDataAfterMutation} catalogEntries={apiCatalogEntries} @@ -870,7 +888,7 @@ export default function ToolPage() { setShowMCPSheet(false); void handleRefresh(); }} - onSaved={() => { setShowMCPSheet(false); handleRefresh(); setMcpRefreshKey(k => k + 1); }} + onSaved={() => { setShowMCPSheet(false); void handleRefresh(); }} onRefresh={handleRefresh} /> )} diff --git a/webui/src/pages/Tool/tabCounts.test.ts b/webui/src/pages/Tool/tabCounts.test.ts index 2ca2cec9d..5171f30b9 100644 --- a/webui/src/pages/Tool/tabCounts.test.ts +++ b/webui/src/pages/Tool/tabCounts.test.ts @@ -1,22 +1,45 @@ import { describe, expect, it } from 'vitest'; import type { ToolListFacets } from '@/api/tool'; -import { getToolTabCounts } from './tabCounts'; +import { getApiServiceCount, getMcpServiceCount, getToolTabCounts } from './tabCounts'; describe('getToolTabCounts', () => { - it('uses enabled API service count for the API tab', () => { + it('uses total native services instead of enabled services or tool-bearing source groups', () => { const facets: ToolListFacets = { category: {}, - source: { api: 3, mcp: 2, plugin_py: 2 }, - source_groups: { api: 3, mcp: 1, plugin_py: 1 }, + source: { api: 64, mcp: 49, plugin_py: 5 }, + source_groups: { api: 7, mcp: 6, plugin_py: 1 }, source_name: {}, - enabled: {}, + enabled: { true: 46, false: 3 }, }; - - expect(getToolTabCounts(2, facets, 3)).toEqual({ - all: 7, - mcp: 1, - api: 3, - local: 2, + expect(getToolTabCounts(49, facets, { api: 10, mcp: 11 })).toEqual({ + all: 118, + mcp: 11, + api: 10, + local: 5, + }); + expect(getToolTabCounts(0, { ...facets, source: {}, source_groups: {} }, { api: 10, mcp: 11 })).toEqual({ + all: 0, mcp: 11, api: 10, local: 0, }); }); + + it('counts disabled API services and excludes every device integration', () => { + const services = Array.from({ length: 26 }, (_, index) => ({ + id: `service-${index}`, enabled: index < 7 || index >= 10, + integration_type: index >= 10 ? 'device' : undefined, + })); + expect(getApiServiceCount(services)).toBe(10); + }); + + it('counts the MCP native/catalog union once, regardless of state or tool count', () => { + const servers = [ + { name: 'connected', status: 'connected', tools_count: 49 }, + { name: 'gridinsoft', status: 'connected', tools_count: 0 }, + { name: 'disabled', status: 'disabled', tools_count: 0 }, + { name: 'disconnected', status: 'disconnected', tools_count: 0 }, + { name: 'error', status: 'error', tools_count: 0 }, + ]; + const catalog = [{ id: 'gridinsoft' }, { id: 'disabled' }, { id: 'catalog-only' }]; + expect(getMcpServiceCount(servers, catalog)).toBe(6); + expect(getMcpServiceCount(Object.fromEntries(servers.map((server) => [server.name, server])), catalog)).toBe(6); + }); }); diff --git a/webui/src/pages/Tool/tabCounts.ts b/webui/src/pages/Tool/tabCounts.ts index a81edc7b4..07bbfe230 100644 --- a/webui/src/pages/Tool/tabCounts.ts +++ b/webui/src/pages/Tool/tabCounts.ts @@ -1,4 +1,18 @@ import type { ToolListFacets } from '@/api/tool'; +import type { APIServiceSummary, MCPCatalogEntry, MCPServer } from '@/types'; + +export function getApiServiceCount(services: readonly Pick[]): number { + return services.filter((service) => service.integration_type !== 'device').length; +} + +/** Match the MCP page's unfiltered native-server/catalog union, once per identity. */ +export function getMcpServiceCount( + servers: Record | readonly Pick[], + catalog: readonly Pick[], +): number { + const names = Array.isArray(servers) ? servers.map((server) => server.name) : Object.keys(servers); + return new Set([...names, ...catalog.map((entry) => entry.id)]).size; +} export interface ToolTabCounts { all: number; @@ -10,13 +24,13 @@ export interface ToolTabCounts { export function getToolTabCounts( totalTools: number, facets: ToolListFacets, - apiEnabledServicesCount: number, + serviceCounts: Pick, ): ToolTabCounts { const allTools = Object.values(facets.source).reduce((sum, count) => sum + count, 0); return { all: allTools || totalTools, - mcp: facets.source_groups.mcp ?? 0, - api: apiEnabledServicesCount, + mcp: serviceCounts.mcp, + api: serviceCounts.api, local: facets.source.plugin_py ?? 0, }; }