diff --git a/.flocks/flockshub/index.json b/.flocks/flockshub/index.json index 1316944dd..226009aef 100644 --- a/.flocks/flockshub/index.json +++ b/.flocks/flockshub/index.json @@ -14544,6 +14544,208 @@ "trust": "community", "riskLevel": "medium", "manifestPath": "plugins/skills/Anthropic-Cybersecurity-Skills/validating-backup-integrity-for-recovery/manifest.json" + }, + { + "id": "chaitin_muyun_api_v3", + "type": "device", + "name": "Chaitin Muyun", + "description": "Chaitin Muyun host security platform JSON-RPC API 3.0 integration.", + "version": "3.0", + "category": "integration", + "tags": [ + "edr", + "hids", + "vulnerability", + "integration" + ], + "useCases": [ + "integration", + "incident-response", + "vulnerability-management" + ], + "trust": "official", + "riskLevel": "low", + "manifestPath": "plugins/tools/device/chaitin_muyun_api_v3/manifest.json", + "descriptionCn": "长亭牧云主机安全平台 API 3.0 接入。" + }, + { + "id": "chaitin_safeline_waf_v1_0_0", + "type": "device", + "name": "Chaitin SafeLine WAF", + "description": "Chaitin SafeLine WAF OpenAPI integration.", + "version": "1.0.0", + "category": "integration", + "tags": [ + "waf", + "web-security", + "integration" + ], + "useCases": [ + "integration", + "threat-detection", + "log-analysis" + ], + "trust": "official", + "riskLevel": "low", + "manifestPath": "plugins/tools/device/chaitin_safeline_waf_v1_0_0/manifest.json", + "descriptionCn": "长亭雷池 WAF OpenAPI 接入。" + }, + { + "id": "chaitin_dongjian_v2_8", + "type": "device", + "name": "Chaitin Dongjian", + "description": "Chaitin Dongjian (X-Ray) security assessment system OpenAPI V2.8 integration.", + "version": "2.8", + "category": "integration", + "tags": [ + "vulnerability", + "web-security", + "integration" + ], + "useCases": [ + "integration", + "vulnerability-management", + "security-reporting" + ], + "trust": "official", + "riskLevel": "low", + "manifestPath": "plugins/tools/device/chaitin_dongjian_v2_8/manifest.json", + "descriptionCn": "长亭洞鉴(X-Ray)安全评估系统 OpenAPI V2.8 接入。" + }, + { + "id": "sangfor_atrust_v3", + "type": "device", + "name": "Sangfor aTrust Zero Trust", + "description": "Sangfor aTrust Zero Trust Access Control OpenAPI V3 integration.", + "version": "3.0", + "category": "integration", + "tags": [ + "iam", + "network", + "integration" + ], + "useCases": [ + "integration", + "incident-response", + "security-reporting" + ], + "trust": "official", + "riskLevel": "low", + "manifestPath": "plugins/tools/device/sangfor_atrust_v3/manifest.json", + "descriptionCn": "深信服 aTrust 零信任访问控制系统 OpenAPI V3 接入。" + }, + { + "id": "soc_ui", + "type": "webui", + "name": "SOC Workspace WebUI", + "description": "SOC workspace pages for posture, overview, and alert investigation.", + "descriptionCn": "SOC 工作区页面,包含态势、SOC 总览和告警调查。", + "version": "1.0.0", + "category": "workflow-automation", + "tags": [ + "siem", + "ndr", + "integration" + ], + "useCases": [ + "alert-triage", + "log-analysis", + "security-reporting" + ], + "trust": "official", + "riskLevel": "low", + "manifestPath": "plugins/webuis/soc_ui/manifest.json" + }, + { + "id": "soc_workspace_query", + "type": "tool", + "name": "Flocks SOC Workspace Query", + "description": "Query information from Flocks own SOC workspace pages, including dashboard, overview, alert lists, alert details, triage reports, and workflow status.", + "descriptionCn": "查询 Flocks 自身 SOC 工作区里的信息,包括 dashboard、overview、告警调查列表、告警详情、研判报告和工作流状态。", + "version": "1.0.0", + "category": "integration", + "tags": [ + "siem", + "ndr", + "integration" + ], + "useCases": [ + "alert-triage", + "log-analysis", + "security-reporting" + ], + "trust": "official", + "riskLevel": "low", + "manifestPath": "plugins/tools/python/soc_workspace_query/manifest.json" + }, + { + "id": "stream_alert_denoise", + "type": "workflow", + "name": "Stream Alert Denoise", + "nameCn": "流式HTTP降噪工作流", + "description": "Streaming alert denoise and deduplication pipeline with Syslog/API input.", + "descriptionCn": "流式告警降噪与去重工作流,支持 Syslog 和 API 输入。", + "version": "1.0.0", + "category": "workflow-automation", + "tags": [ + "siem", + "ndr", + "web-security" + ], + "useCases": [ + "alert-triage", + "log-analysis", + "security-reporting" + ], + "trust": "official", + "riskLevel": "medium", + "manifestPath": "plugins/workflows/stream_alert_denoise/manifest.json" + }, + { + "id": "stream_alert_triage", + "type": "workflow", + "name": "Stream Alert Triage", + "nameCn": "HTTP研判工作流", + "description": "Downstream alert triage workflow that writes triage results to SOC DB by default with optional JSONL output.", + "descriptionCn": "下游告警研判工作流,默认写入 SOC DB,并保留 JSONL 输出配置。", + "version": "1.0.0", + "category": "workflow-automation", + "tags": [ + "siem", + "ndr", + "web-security" + ], + "useCases": [ + "alert-triage", + "log-analysis", + "security-reporting" + ], + "trust": "official", + "riskLevel": "medium", + "manifestPath": "plugins/workflows/stream_alert_triage/manifest.json" + }, + { + "id": "soc-workspace", + "type": "component", + "name": "SOC Workspace Component", + "nameCn": "SOC 工作区场景套件", + "description": "Composite installer for the Flocks SOC workspace: pages, tools, denoise workflow, and triage workflow.", + "descriptionCn": "Flocks SOC 工作区组件安装器:包含页面、工具、降噪工作流和研判工作流。", + "version": "1.0.0", + "category": "workflow-automation", + "tags": [ + "siem", + "ndr", + "integration" + ], + "useCases": [ + "alert-triage", + "log-analysis", + "security-reporting" + ], + "trust": "official", + "riskLevel": "medium", + "manifestPath": "plugins/components/soc-workspace/manifest.json" } ] } diff --git a/.flocks/flockshub/plugins/components/soc-workspace/component.json b/.flocks/flockshub/plugins/components/soc-workspace/component.json new file mode 100644 index 000000000..dd3d2323b --- /dev/null +++ b/.flocks/flockshub/plugins/components/soc-workspace/component.json @@ -0,0 +1,26 @@ +{ + "schemaVersion": "hub.component.v1", + "id": "soc-workspace", + "name": "SOC Workspace", + "nameCn": "SOC 工作区", + "description": "Composite package for the Flocks SOC workspace WebUI, query tool, and denoise/triage workflows.", + "descriptionCn": "Flocks SOC 工作区组件包,包含 WebUI 页面、查询工具、降噪工作流和研判工作流。", + "components": [ + { + "type": "webui", + "id": "soc_ui" + }, + { + "type": "tool", + "id": "soc_workspace_query" + }, + { + "type": "workflow", + "id": "stream_alert_denoise" + }, + { + "type": "workflow", + "id": "stream_alert_triage" + } + ] +} diff --git a/.flocks/flockshub/plugins/components/soc-workspace/manifest.json b/.flocks/flockshub/plugins/components/soc-workspace/manifest.json new file mode 100644 index 000000000..bfdf7c3d9 --- /dev/null +++ b/.flocks/flockshub/plugins/components/soc-workspace/manifest.json @@ -0,0 +1,84 @@ +{ + "schemaVersion": "hub.plugin.v1", + "id": "soc-workspace", + "type": "component", + "name": "SOC Workspace Component", + "nameCn": "SOC 工作区场景套件", + "description": "Composite installer for the Flocks SOC workspace: pages, tools, denoise workflow, and triage workflow.", + "descriptionCn": "Flocks SOC 工作区组件安装器:包含页面、工具、降噪工作流和研判工作流。", + "version": "1.0.0", + "author": "Flocks Team", + "license": "MIT", + "homepage": "", + "category": "workflow-automation", + "tags": [ + "siem", + "ndr", + "integration" + ], + "useCases": [ + "alert-triage", + "log-analysis", + "security-reporting" + ], + "domains": [ + "security-ops" + ], + "capabilities": [ + "workflow" + ], + "trust": "official", + "source": { + "kind": "bundled", + "path": "plugins/components/soc-workspace" + }, + "compatibility": { + "flocks": ">=0.8.0", + "os": [ + "darwin", + "linux", + "windows" + ] + }, + "dependencies": { + "skills": [], + "tools": [], + "python": [], + "external": [] + }, + "permissions": { + "tools": [ + "soc_workspace_query" + ], + "network": false, + "shell": false, + "filesystem": "read-write" + }, + "risk": { + "level": "medium", + "reasons": [] + }, + "entrypoints": [ + "component.json" + ], + "components": [ + { + "type": "webui", + "id": "soc_ui" + }, + { + "type": "tool", + "id": "soc_workspace_query", + "adoptExisting": true + }, + { + "type": "workflow", + "id": "stream_alert_denoise" + }, + { + "type": "workflow", + "id": "stream_alert_triage" + } + ], + "checksums": {} +} diff --git a/.flocks/flockshub/plugins/tools/device/chaitin_dongjian_v2_8/_provider.yaml b/.flocks/flockshub/plugins/tools/device/chaitin_dongjian_v2_8/_provider.yaml new file mode 100644 index 000000000..11fed5924 --- /dev/null +++ b/.flocks/flockshub/plugins/tools/device/chaitin_dongjian_v2_8/_provider.yaml @@ -0,0 +1,40 @@ +name: chaitin_dongjian +vendor: chaitin +service_id: chaitin_dongjian_api +version: "2.8" +integration_type: device +description: > + Chaitin Dongjian (X-Ray) security assessment system OpenAPI V2.8 + integration. Configure the device URL and API Token. The handler normalizes + the base URL to /api/v2. +description_cn: > + 长亭洞鉴(X-Ray)安全评估系统 OpenAPI V2.8 接入。配置设备地址和 API Token; + handler 会将地址归一化到 /api/v2。 +auth: + type: custom + secret: chaitin_dongjian_token +credential_fields: + - key: base_url + label: 设备地址 + storage: config + config_key: base_url + input_type: url + required: true + placeholder: "https://dongjian.example.com" + - key: token + label: API Token + storage: secret + config_key: token + secret_id: chaitin_dongjian_token + input_type: password + required: true +defaults: + timeout: 30 + category: custom + product_version: "OpenAPI V2.8" + verify_ssl: false +notes: | + 认证规则来自《洞鉴(X-Ray)安全评估系统OpenAPI接口文档(标准版)V2.8》: + - 所有 API 请求需要在请求头 token 中携带认证信息。 + - 接口路径基于 /api/v2,例如 GET /api/v2/project/?limit=1&offset=0。 + - GET 用于获取指定数据;POST /filter/ 用于批量筛选查询。 diff --git a/.flocks/flockshub/plugins/tools/device/chaitin_dongjian_v2_8/_test.yaml b/.flocks/flockshub/plugins/tools/device/chaitin_dongjian_v2_8/_test.yaml new file mode 100644 index 000000000..223818a67 --- /dev/null +++ b/.flocks/flockshub/plugins/tools/device/chaitin_dongjian_v2_8/_test.yaml @@ -0,0 +1,59 @@ +schema_version: 1 +provider: chaitin_dongjian_api + +connectivity: + tool: chaitin_dongjian_projects + params: + action: project_list + limit: 1 + offset: 0 + +fixtures: + chaitin_dongjian_projects: + - label: List projects + label_cn: 查询组织单位列表 + tags: [smoke] + params: + action: project_list + limit: 10 + offset: 0 + assert: + success: true + + chaitin_dongjian_tasks: + - label: Filter scan task instances + label_cn: 筛选任务实例 + tags: [task] + params: + action: xprocess_filter + body: + limit: 10 + offset: 0 + + chaitin_dongjian_results: + - label: Filter vulnerabilities + label_cn: 筛选漏洞资产 + tags: [vulnerability] + params: + action: vuln_filter + body: + limit: 10 + offset: 0 + + chaitin_dongjian_api_readonly: + - label: Show API catalog + label_cn: 查看 API 目录 + tags: [api] + params: + action: api_catalog + assert: + success: true + + chaitin_dongjian_api_mutation: + - label: Show API catalog before confirmed mutation + label_cn: 变更调用前查看 API 目录 + tags: [api, mutation] + params: + action: api_catalog + assert: + success: true diff --git a/.flocks/flockshub/plugins/tools/device/chaitin_dongjian_v2_8/chaitin_dongjian.handler.py b/.flocks/flockshub/plugins/tools/device/chaitin_dongjian_v2_8/chaitin_dongjian.handler.py new file mode 100644 index 000000000..00dfe3105 --- /dev/null +++ b/.flocks/flockshub/plugins/tools/device/chaitin_dongjian_v2_8/chaitin_dongjian.handler.py @@ -0,0 +1,371 @@ +from __future__ import annotations + +import asyncio +import json +import os +from pathlib import Path +from typing import Any, Callable + +import requests + +from flocks.config.config_writer import ConfigWriter +from flocks.security import get_secret_manager +from flocks.tool.registry import ToolContext, ToolResult + + +SERVICE_ID = "chaitin_dongjian_api" +STORAGE_KEY = "chaitin_dongjian_v2_8" +PRODUCT_VERSION = "OpenAPI V2.8" +DEFAULT_TIMEOUT = 30 +DEFAULT_VERIFY_SSL = False +CATALOG_FILE = Path(__file__).with_name("chaitin_dongjian_api_catalog.json") + + +class ChaitinDongjianError(RuntimeError): + pass + + +class RuntimeConfig: + def __init__( + self, + *, + base_url: str, + token: str, + verify_ssl: bool, + timeout: int, + ) -> None: + self.base_url = base_url + self.token = token + self.verify_ssl = verify_ssl + self.timeout = timeout + + +def _resolve_ref(value: Any) -> str: + if value is None: + return "" + if not isinstance(value, str): + return str(value) + if value.startswith("{secret:") and value.endswith("}"): + return get_secret_manager().get(value[len("{secret:") : -1]) or "" + if value.startswith("{env:") and value.endswith("}"): + return os.getenv(value[len("{env:") : -1], "") + return value + + +def _raw_service_config() -> dict[str, Any]: + raw = ConfigWriter.get_api_service_raw(SERVICE_ID) + if not isinstance(raw, dict): + raw = ConfigWriter.get_api_service_raw(STORAGE_KEY) + return raw if isinstance(raw, dict) else {} + + +def _config_value(raw: dict[str, Any], *keys: str) -> Any: + for key in keys: + if raw.get(key) is not None: + return raw[key] + custom_settings = raw.get("custom_settings") + if isinstance(custom_settings, dict): + for key in keys: + if custom_settings.get(key) is not None: + return custom_settings[key] + return None + + +def _as_bool(value: Any, default: bool) -> bool: + if value is None: + return default + if isinstance(value, bool): + return value + if isinstance(value, str): + text = value.strip().lower() + if text in {"1", "true", "yes", "on"}: + return True + if text in {"0", "false", "no", "off"}: + return False + return bool(value) + + +def _normalize_base_url(base_url: str) -> str: + text = base_url.strip().rstrip("/") + if not text.endswith("/api/v2"): + text = f"{text}/api/v2" + return text.rstrip("/") + + +def resolve_config() -> RuntimeConfig: + raw = _raw_service_config() + base_url = ( + _resolve_ref(_config_value(raw, "base_url", "baseUrl")) + or os.getenv("CHAITIN_DONGJIAN_BASE_URL", "") + ) + if not base_url: + raise ChaitinDongjianError("Chaitin Dongjian base_url is not configured") + + token = ( + _resolve_ref(_config_value(raw, "token", "api_token", "apiToken")) + or get_secret_manager().get("chaitin_dongjian_token") + or get_secret_manager().get(f"{SERVICE_ID}_token") + or os.getenv("CHAITIN_DONGJIAN_TOKEN", "") + ) + if not token: + raise ChaitinDongjianError("Chaitin Dongjian token is not configured") + + try: + timeout = int(_config_value(raw, "timeout") or DEFAULT_TIMEOUT) + except (TypeError, ValueError): + timeout = DEFAULT_TIMEOUT + verify_ssl = _as_bool( + _config_value(raw, "verify_ssl", "ssl_verify", "verifySsl") + if _config_value(raw, "verify_ssl", "ssl_verify", "verifySsl") is not None + else os.getenv("CHAITIN_DONGJIAN_VERIFY_SSL"), + DEFAULT_VERIFY_SSL, + ) + return RuntimeConfig( + base_url=_normalize_base_url(base_url), + token=token, + verify_ssl=verify_ssl, + timeout=timeout, + ) + + +def _render_path(path: str, args: dict[str, Any]) -> str: + rendered = path + path_params = args.get("path_params") if isinstance(args.get("path_params"), dict) else {} + for key, value in {**path_params, **args}.items(): + if isinstance(key, str): + rendered = rendered.replace("{" + key + "}", str(value)) + if "{" in rendered or "}" in rendered: + raise ChaitinDongjianError(f"Missing path parameter for {path}") + return rendered + + +class DongjianClient: + def __init__(self, config: RuntimeConfig) -> None: + self.config = config + + def request( + self, + method: str, + path: str, + *, + query: dict[str, Any] | None = None, + body: Any = None, + ) -> Any: + url = f"{self.config.base_url}{path}" + headers = { + "Accept": "application/json", + "token": self.config.token, + } + if method.upper() in {"POST", "PUT", "DELETE", "PATCH"}: + headers["Content-Type"] = "application/json" + response = requests.request( + method.upper(), + url, + params={k: v for k, v in (query or {}).items() if v is not None}, + json=body if body not in (None, "") else None, + headers=headers, + timeout=self.config.timeout, + verify=self.config.verify_ssl, + ) + return _json_response(response) + + +def _json_response(response: requests.Response) -> Any: + try: + payload = response.json() + except ValueError as exc: + raise ChaitinDongjianError(f"Invalid JSON response: HTTP {response.status_code}") from exc + if response.status_code >= 400: + raise ChaitinDongjianError(f"HTTP {response.status_code}: {payload}") + if isinstance(payload, dict) and payload.get("err") not in (None, ""): + raise ChaitinDongjianError(str(payload.get("msg") or payload.get("err"))) + return payload + + +def _ok(data: Any, *, action: str) -> ToolResult: + return ToolResult( + success=True, + output=data, + metadata={"source": "Chaitin Dongjian", "version": PRODUCT_VERSION, "action": action}, + ) + + +def get_client() -> DongjianClient: + return DongjianClient(resolve_config()) + + +def _request_args(args: dict[str, Any], default_method: str, default_path: str) -> tuple[str, str, dict[str, Any], Any]: + method = str(args.get("method") or default_method).upper() + path = _render_path(str(args.get("path") or default_path), args) + query = args.get("query") if isinstance(args.get("query"), dict) else {} + body = args.get("body") + if method == "GET" and not query: + ignored = {"action", "method", "path", "query", "body", "path_params"} + query = {k: v for k, v in args.items() if k not in ignored and v is not None} + return method, path, dict(query), body + + +def _load_api_catalog() -> list[dict[str, Any]]: + try: + data = json.loads(CATALOG_FILE.read_text(encoding="utf-8")) + except FileNotFoundError: + return [] + entries = data.get("entries") + return entries if isinstance(entries, list) else [] + + +def _catalog_pairs(kind: str) -> set[tuple[str, str]]: + return { + (str(entry.get("method", "")).upper(), str(entry.get("path", ""))) + for entry in _load_api_catalog() + if entry.get("kind") == kind and entry.get("method") and entry.get("path") + } + + +READONLY_ACTIONS: dict[str, tuple[str, str]] = { + "project_list": ("GET", "/project/"), + "project_filter": ("POST", "/project/filter/"), + "template_list": ("GET", "/template/"), + "plan_filter": ("POST", "/plan/filter/"), + "plan_detail": ("GET", "/plan/{id}/"), + "plugin_filter": ("POST", "/plugin/filter/"), + "engine_filter": ("POST", "/engine/filter/"), + "xprocess_filter": ("POST", "/xprocess/filter/"), + "xprocess_detail": ("GET", "/xprocess/{id}/"), + "xprocess_progress": ("GET", "/xprocess/{id}/progress/"), + "result_filter": ("POST", "/result/filter/"), + "result_detail": ("GET", "/result/{id}/"), + "website_filter": ("POST", "/website/filter/"), + "host_filter": ("POST", "/ip/filter/"), + "service_filter": ("POST", "/service/filter/"), + "domain_filter": ("POST", "/domain/filter/"), + "vuln_filter": ("POST", "/vuln/filter/"), + "vuln_detail": ("GET", "/vuln/{id}/"), + "auditlog_filter": ("POST", "/auditlog/filter/"), + "report_filter": ("POST", "/report/filter/"), + "system_info_mgmt": ("GET", "/system/info/mgmt/"), + "system_services": ("GET", "/system/info/services/"), +} + + +PROJECT_ACTIONS = {"project_list", "project_filter", "template_list"} +TASK_ACTIONS = { + "plan_filter", + "plan_detail", + "plugin_filter", + "engine_filter", + "xprocess_filter", + "xprocess_detail", + "xprocess_progress", +} +ASSET_ACTIONS = {"website_filter", "host_filter", "service_filter", "domain_filter"} +RESULT_ACTIONS = {"result_filter", "result_detail", "vuln_filter", "vuln_detail", "auditlog_filter", "report_filter"} +SYSTEM_ACTIONS = {"system_info_mgmt", "system_services"} + + +def call_rest(action: str, args: dict[str, Any]) -> ToolResult: + method, path = READONLY_ACTIONS[action] + req_method, req_path, query, body = _request_args(args, method, path) + return _ok(get_client().request(req_method, req_path, query=query, body=body), action=action) + + +def api_catalog(args: dict[str, Any]) -> ToolResult: + del args + catalog = _load_api_catalog() + return _ok( + { + "base_path": "/api/v2", + "catalog_counts": { + "total": len(catalog), + "readonly": sum(1 for entry in catalog if entry.get("kind") == "readonly"), + "mutation": sum(1 for entry in catalog if entry.get("kind") == "mutation"), + }, + "documented_api_catalog": catalog, + "common_actions": { + "projects": sorted(PROJECT_ACTIONS), + "tasks": sorted(TASK_ACTIONS), + "assets": sorted(ASSET_ACTIONS), + "results": sorted(RESULT_ACTIONS), + "system": sorted(SYSTEM_ACTIONS), + }, + }, + action="api_catalog", + ) + + +def rest_call_readonly(args: dict[str, Any]) -> ToolResult: + method, path, query, body = _request_args(args, "GET", "") + if (method, path) not in _catalog_pairs("readonly"): + raise ChaitinDongjianError("Only documented read-only REST method/path pairs are allowed") + return _ok(get_client().request(method, path, query=query, body=body), action="rest_call_readonly") + + +def rest_call_mutation(args: dict[str, Any]) -> ToolResult: + method, path, query, body = _request_args(args, "POST", "") + if (method, path) not in _catalog_pairs("mutation"): + raise ChaitinDongjianError("Only documented mutation REST method/path pairs are allowed") + return _ok(get_client().request(method, path, query=query, body=body), action="rest_call_mutation") + + +ACTION_HANDLERS: dict[str, Callable[[dict[str, Any]], ToolResult]] = { + "api_catalog": api_catalog, + "rest_call_readonly": rest_call_readonly, + "rest_call_mutation": rest_call_mutation, +} +for _action in READONLY_ACTIONS: + ACTION_HANDLERS[_action] = lambda args, action=_action: call_rest(action, args) + + +async def _dispatch(ctx: ToolContext, allowed: set[str], action: str, **params: Any) -> ToolResult: + del ctx + if action == "test": + action = "project_list" + params.setdefault("limit", 1) + params.setdefault("offset", 0) + if action not in allowed: + return ToolResult( + success=False, + error=f"Unsupported Chaitin Dongjian action: {action}. Available: {', '.join(sorted(allowed))}", + ) + try: + return await asyncio.to_thread(ACTION_HANDLERS[action], params) + except ChaitinDongjianError as exc: + return ToolResult( + success=False, + error=str(exc), + metadata={"source": "Chaitin Dongjian", "version": PRODUCT_VERSION, "action": action}, + ) + except Exception as exc: + return ToolResult( + success=False, + error=f"Unexpected Chaitin Dongjian error: {exc}", + metadata={"source": "Chaitin Dongjian", "version": PRODUCT_VERSION, "action": action}, + ) + + +async def projects(ctx: ToolContext, action: str, **params: Any) -> ToolResult: + return await _dispatch(ctx, PROJECT_ACTIONS | {"test"}, action, **params) + + +async def tasks(ctx: ToolContext, action: str, **params: Any) -> ToolResult: + return await _dispatch(ctx, TASK_ACTIONS | {"test"}, action, **params) + + +async def assets(ctx: ToolContext, action: str, **params: Any) -> ToolResult: + return await _dispatch(ctx, ASSET_ACTIONS | {"test"}, action, **params) + + +async def results(ctx: ToolContext, action: str, **params: Any) -> ToolResult: + return await _dispatch(ctx, RESULT_ACTIONS | {"test"}, action, **params) + + +async def system(ctx: ToolContext, action: str, **params: Any) -> ToolResult: + return await _dispatch(ctx, SYSTEM_ACTIONS | {"test"}, action, **params) + + +async def api_readonly(ctx: ToolContext, action: str, **params: Any) -> ToolResult: + return await _dispatch(ctx, {"api_catalog", "rest_call_readonly", *READONLY_ACTIONS.keys(), "test"}, action, **params) + + +async def api_mutation(ctx: ToolContext, action: str, **params: Any) -> ToolResult: + return await _dispatch(ctx, {"api_catalog", "rest_call_mutation"}, action, **params) diff --git a/.flocks/flockshub/plugins/tools/device/chaitin_dongjian_v2_8/chaitin_dongjian_api_catalog.json b/.flocks/flockshub/plugins/tools/device/chaitin_dongjian_v2_8/chaitin_dongjian_api_catalog.json new file mode 100644 index 000000000..08c89c657 --- /dev/null +++ b/.flocks/flockshub/plugins/tools/device/chaitin_dongjian_v2_8/chaitin_dongjian_api_catalog.json @@ -0,0 +1,854 @@ +{ + "schema_version": 1, + "source": "洞鉴(X-Ray)安全评估系统OpenAPI接口文档(标准版)V2.8.pdf", + "base_path": "/api/v2", + "entries": [ + { + "method": "GET", + "path": "/project/", + "kind": "readonly", + "description": "获取组织单位列表", + "page": 24 + }, + { + "method": "POST", + "path": "/project/", + "kind": "mutation", + "description": "创建组织单位", + "page": 25 + }, + { + "method": "POST", + "path": "/project/filter/", + "kind": "readonly", + "description": "功能 2: 根据组织单位全称查询组织单位详情", + "page": 27 + }, + { + "method": "GET", + "path": "/project/{id}/", + "kind": "readonly", + "description": "获取组织单位详情", + "page": 29 + }, + { + "method": "GET", + "path": "/template/", + "kind": "readonly", + "description": "获取策略模版列表", + "page": 32 + }, + { + "method": "GET", + "path": "/template/{id}/", + "kind": "readonly", + "description": "获取策略模版详情", + "page": 33 + }, + { + "method": "POST", + "path": "/plan/filter/", + "kind": "readonly", + "description": "按条件批量获取任务计划列表", + "page": 36 + }, + { + "method": "GET", + "path": "/plan/{id}/", + "kind": "readonly", + "description": "获取任务计划详情", + "page": 38 + }, + { + "method": "DELETE", + "path": "/plan/{id}/", + "kind": "mutation", + "description": "删除任务计划", + "page": 39 + }, + { + "method": "POST", + "path": "/plan/create/", + "kind": "mutation", + "description": "创建任务计划", + "page": 40 + }, + { + "method": "POST", + "path": "/plan/update/", + "kind": "mutation", + "description": "更新任务计划", + "page": 42 + }, + { + "method": "POST", + "path": "/plan/stop/", + "kind": "mutation", + "description": "停止循环任务的循环设置,不影响当前正在扫描的任务实例。", + "page": 44 + }, + { + "method": "POST", + "path": "/plan/execute/", + "kind": "readonly", + "description": "例", + "page": 45 + }, + { + "method": "POST", + "path": "/plugin/filter/", + "kind": "readonly", + "description": "获取漏洞插件列表", + "page": 49 + }, + { + "method": "POST", + "path": "/custom_plugin/filter/", + "kind": "readonly", + "description": "获取自定义插件列表", + "page": 50 + }, + { + "method": "GET", + "path": "/custom_plugin/{id}/", + "kind": "readonly", + "description": "漏洞插件的标识,作为策略配置中插件启用/禁用的输入参数", + "page": 51 + }, + { + "method": "POST", + "path": "/scannerdict/", + "kind": "mutation", + "description": "创建字典", + "page": 52 + }, + { + "method": "DELETE", + "path": "/scannerdict/", + "kind": "mutation", + "description": "删除字典列表", + "page": 53 + }, + { + "method": "POST", + "path": "/scannerdict/filter/", + "kind": "readonly", + "description": "获取字典列表", + "page": 54 + }, + { + "method": "GET", + "path": "/scannerdict/{id}/", + "kind": "readonly", + "description": "获取字典详情", + "page": 55 + }, + { + "method": "POST", + "path": "/portgroup/filter/", + "kind": "readonly", + "description": "获取端口组列表", + "page": 56 + }, + { + "method": "POST", + "path": "/hostlogrule/filter/", + "kind": "readonly", + "description": "则”参数", + "page": 57 + }, + { + "method": "POST", + "path": "/upload_file/", + "kind": "mutation", + "description": "上传文件", + "page": 58 + }, + { + "method": "POST", + "path": "/engine/filter/", + "kind": "readonly", + "description": "获取引擎节点列表", + "page": 59 + }, + { + "method": "GET", + "path": "/reverse_platform/{uuid}/", + "kind": "readonly", + "description": "获取反连平台详情", + "page": 60 + }, + { + "method": "POST", + "path": "/reverse_platform/filter/", + "kind": "readonly", + "description": "获取反连平台列表", + "page": 61 + }, + { + "method": "POST", + "path": "/whitelist/", + "kind": "mutation", + "description": "创建全局白名单", + "page": 62 + }, + { + "method": "POST", + "path": "/whitelist/update/", + "kind": "mutation", + "description": "更新全局白名单", + "page": 65 + }, + { + "method": "POST", + "path": "/whitelist/filter/", + "kind": "readonly", + "description": "获取全局白名单列表", + "page": 67 + }, + { + "method": "GET", + "path": "/whitelist/{id}/", + "kind": "readonly", + "description": "获取全局白名单详情", + "page": 68 + }, + { + "method": "POST", + "path": "/xprocess/filter/", + "kind": "readonly", + "description": "按条件批量获取任务实例列表", + "page": 75 + }, + { + "method": "GET", + "path": "/xprocess/{id}/", + "kind": "readonly", + "description": "获取任务实例", + "page": 77 + }, + { + "method": "GET", + "path": "/xprocess/{id}/progress/", + "kind": "readonly", + "description": "务是否完成应该根据进度提供的 status 判断。", + "page": 78 + }, + { + "method": "POST", + "path": "/xprocess/stop/", + "kind": "readonly", + "description": "结束正在执行过程中的扫描实例", + "page": 80 + }, + { + "method": "POST", + "path": "/xprocess/pause/", + "kind": "mutation", + "description": "暂停正在执行过程中的扫描实例", + "page": 81 + }, + { + "method": "POST", + "path": "/xprocess/resume/", + "kind": "mutation", + "description": "仅能恢复手动暂停的任务实例", + "page": 82 + }, + { + "method": "POST", + "path": "/xprocess/pause/stage/", + "kind": "readonly", + "description": "当前仅支持扫描策略为被动 Web 扫描(流量)的任务", + "page": 83 + }, + { + "method": "POST", + "path": "/xprocess/resume/stage/", + "kind": "readonly", + "description": "仅支持扫描策略为被动 Web 扫描(流量)的任务", + "page": 84 + }, + { + "method": "POST", + "path": "/result/filter/", + "kind": "readonly", + "description": "按条件批量获取任务结果", + "page": 89 + }, + { + "method": "GET", + "path": "/result/{id}/", + "kind": "readonly", + "description": "获取任务结果", + "page": 91 + }, + { + "method": "POST", + "path": "/ssh_key/filter/", + "kind": "readonly", + "description": "按条件批量获取 SSH 认证私钥列表", + "page": 101 + }, + { + "method": "POST", + "path": "/ssh_key/create/", + "kind": "mutation", + "description": "创建 SSH 认证私钥", + "page": 102 + }, + { + "method": "POST", + "path": "/check_sets/filter/", + "kind": "readonly", + "description": "按条件批量获取检查策略列表", + "page": 103 + }, + { + "method": "POST", + "path": "/baseline/task/filter/", + "kind": "readonly", + "description": "按条件批量获取基线任务列表", + "page": 105 + }, + { + "method": "POST", + "path": "/baseline/task/create/", + "kind": "readonly", + "description": "仅支持在线检查", + "page": 107 + }, + { + "method": "POST", + "path": "/baseline/task/stop/", + "kind": "readonly", + "description": "仅支持在线检查", + "page": 111 + }, + { + "method": "POST", + "path": "/baseline/task/execute/", + "kind": "readonly", + "description": "仅支持在线检查", + "page": 112 + }, + { + "method": "POST", + "path": "/process/item/filter/", + "kind": "readonly", + "description": "按条件批量获取检查结果", + "page": 113 + }, + { + "method": "POST", + "path": "/website/", + "kind": "mutation", + "description": "批量增加 Web 站点", + "page": 118 + }, + { + "method": "DELETE", + "path": "/website/", + "kind": "mutation", + "description": "批量删除 Web 站点", + "page": 119 + }, + { + "method": "POST", + "path": "/website/filter/", + "kind": "readonly", + "description": "逐步废弃此 API,改用更精简的 /website/filter/simple", + "page": 120 + }, + { + "method": "POST", + "path": "/website/filter/simple/", + "kind": "readonly", + "description": "按条件批量获取简易版 Web 站点列表", + "page": 121 + }, + { + "method": "GET", + "path": "/website/{id}/", + "kind": "readonly", + "description": "获取 Web 站点详情", + "page": 122 + }, + { + "method": "POST", + "path": "/website/{id}/", + "kind": "mutation", + "description": "修改 Web 站点", + "page": 125 + }, + { + "method": "GET", + "path": "/website/openapi/{id}/", + "kind": "readonly", + "description": "获取 API 资产详情", + "page": 126 + }, + { + "method": "GET", + "path": "/ip/os/", + "kind": "readonly", + "description": "直接调用接口即可获取全量的操作系统列表", + "page": 134 + }, + { + "method": "POST", + "path": "/ip/", + "kind": "mutation", + "description": "批量增加主机", + "page": 135 + }, + { + "method": "DELETE", + "path": "/ip/", + "kind": "mutation", + "description": "批量删除主机", + "page": 136 + }, + { + "method": "POST", + "path": "/ip/filter/", + "kind": "readonly", + "description": "按条件批量获取主机列表", + "page": 137 + }, + { + "method": "GET", + "path": "/ip/{id}/", + "kind": "readonly", + "description": "获取主机资产详情", + "page": 139 + }, + { + "method": "POST", + "path": "/ip/{id}/", + "kind": "mutation", + "description": "修改主机资产", + "page": 142 + }, + { + "method": "GET", + "path": "/service/application_protocol/", + "kind": "readonly", + "description": "获取服务列表", + "page": 146 + }, + { + "method": "POST", + "path": "/service/", + "kind": "mutation", + "description": "批量增加服务", + "page": 147 + }, + { + "method": "DELETE", + "path": "/service/", + "kind": "mutation", + "description": "批量删除服务", + "page": 148 + }, + { + "method": "POST", + "path": "/service/filter/", + "kind": "readonly", + "description": "按条件批量获取服务", + "page": 149 + }, + { + "method": "GET", + "path": "/service/{id}/", + "kind": "readonly", + "description": "获取服务资产", + "page": 151 + }, + { + "method": "POST", + "path": "/service/{id}/", + "kind": "mutation", + "description": "修改服务资产", + "page": 152 + }, + { + "method": "POST", + "path": "/domain/", + "kind": "mutation", + "description": "批量增加域名", + "page": 156 + }, + { + "method": "DELETE", + "path": "/domain/", + "kind": "mutation", + "description": "批量删除域名", + "page": 157 + }, + { + "method": "POST", + "path": "/domain/filter/", + "kind": "readonly", + "description": "逐步废弃此 API,改用更精简的 /domain/filter/simple", + "page": 158 + }, + { + "method": "POST", + "path": "/domain/filter/simple/", + "kind": "readonly", + "description": "按条件批量获取精简的域名", + "page": 159 + }, + { + "method": "GET", + "path": "/domain/{id}/", + "kind": "readonly", + "description": "获取域名资产", + "page": 160 + }, + { + "method": "POST", + "path": "/domain/{id}/", + "kind": "mutation", + "description": "修改域名资产", + "page": 161 + }, + { + "method": "GET", + "path": "/vuln/{id}/", + "kind": "readonly", + "description": "获取漏洞资产详情", + "page": 166 + }, + { + "method": "POST", + "path": "/vuln/{id}/", + "kind": "mutation", + "description": "修改漏洞资产状态", + "page": 170 + }, + { + "method": "DELETE", + "path": "/vuln/", + "kind": "mutation", + "description": "批量删除漏洞资产", + "page": 172 + }, + { + "method": "POST", + "path": "/vuln/filter/", + "kind": "readonly", + "description": "按条件批量获取漏洞列表", + "page": 173 + }, + { + "method": "POST", + "path": "/vuln/retest/", + "kind": "mutation", + "description": "复测漏洞资产", + "page": 177 + }, + { + "method": "GET", + "path": "/vuln/retest/{task_id}/", + "kind": "readonly", + "description": "漏洞资产复测结果获取", + "page": 178 + }, + { + "method": "POST", + "path": "/auditlog/filter/", + "kind": "readonly", + "description": "按条件批量获取审计日志", + "page": 187 + }, + { + "method": "GET", + "path": "/auditlog/action/", + "kind": "readonly", + "description": "审计日志操作类型", + "page": 189 + }, + { + "method": "POST", + "path": "/report/download/", + "kind": "readonly", + "description": "下载报表", + "page": 192 + }, + { + "method": "POST", + "path": "/report/", + "kind": "mutation", + "description": "创建报表", + "page": 193 + }, + { + "method": "POST", + "path": "/report/template/filter/", + "kind": "readonly", + "description": "按条件批量获取报表模版列表", + "page": 195 + }, + { + "method": "POST", + "path": "/report/filter/", + "kind": "readonly", + "description": "按条件批量获取报表列表", + "page": 197 + }, + { + "method": "GET", + "path": "/report/{id}/", + "kind": "readonly", + "description": "查询报表详细信息", + "page": 199 + }, + { + "method": "DELETE", + "path": "/report/{id}/", + "kind": "mutation", + "description": "删除报表", + "page": 200 + }, + { + "method": "DELETE", + "path": "/engine/{id}/", + "kind": "readonly", + "description": "仅超级管理员可进行此操作", + "page": 206 + }, + { + "method": "GET", + "path": "/system/hosts/engine/{id}/", + "kind": "readonly", + "description": "获取指定引擎的静态 host 配置", + "page": 207 + }, + { + "method": "POST", + "path": "/system/hosts/engine/{id}/", + "kind": "mutation", + "description": "设置引擎节点的静态 hosts 配置", + "page": 208 + }, + { + "method": "GET", + "path": "/system/dns/engine/{id}/", + "kind": "readonly", + "description": "获取指定引擎的 dns 配置", + "page": 209 + }, + { + "method": "POST", + "path": "/system/dns/engine/{id}/", + "kind": "mutation", + "description": "设置引擎节点的 dns 配置", + "page": 210 + }, + { + "method": "GET", + "path": "/system/info/engine/{id}/", + "kind": "readonly", + "description": "获取引擎的系统信息", + "page": 211 + }, + { + "method": "GET", + "path": "/system/info/mgmt/", + "kind": "readonly", + "description": "查询管理节点系统信息和负载信息", + "page": 213 + }, + { + "method": "GET", + "path": "/system/info/services/", + "kind": "readonly", + "description": "查询管理节点服务容器名称列表", + "page": 215 + }, + { + "method": "POST", + "path": "/system/info/services/", + "kind": "readonly", + "description": "查询系统服务工作状态和负载信息", + "page": 216 + }, + { + "method": "POST", + "path": "/upload_upgrade_package/", + "kind": "mutation", + "description": "支持引擎升级包、数据库升级包,需要通过文件上传接口上传安装包", + "page": 219 + }, + { + "method": "POST", + "path": "/package/check/", + "kind": "mutation", + "description": "升级前需通过该接口对升级包进行校验", + "page": 220 + }, + { + "method": "POST", + "path": "/engine/upgrade/", + "kind": "mutation", + "description": "升级前需要通过升级包校验接口校验通过,方可升级成功", + "page": 222 + }, + { + "method": "POST", + "path": "/vuln_library/upgrade/", + "kind": "mutation", + "description": "升级前需要通过升级包校验接口校验通过,方可升级成功", + "page": 223 + }, + { + "method": "POST", + "path": "/execution/filter/", + "kind": "readonly", + "description": "执行状态列表", + "page": 224 + }, + { + "method": "POST", + "path": "/customtag/", + "kind": "readonly", + "description": "获取可用自定义 POC 标签列表", + "page": 226 + }, + { + "method": "POST", + "path": "/upload/custompoc/", + "kind": "mutation", + "description": "通过文件上传创建自定义 POC", + "page": 227 + }, + { + "method": "POST", + "path": "/custompoc/", + "kind": "mutation", + "description": "创建自定义 POC", + "page": 228 + }, + { + "method": "DELETE", + "path": "/custompoc/", + "kind": "mutation", + "description": "批量删除自定义 POC", + "page": 230 + }, + { + "method": "POST", + "path": "/custompoc/filter/", + "kind": "readonly", + "description": "按条件批量获取自定义 POC", + "page": 231 + }, + { + "method": "POST", + "path": "/custompoc/update/", + "kind": "mutation", + "description": "修改自定义 POC", + "page": 233 + }, + { + "method": "GET", + "path": "/vuln_category/", + "kind": "readonly", + "description": "获取可用漏洞类型列表", + "page": 235 + }, + { + "method": "POST", + "path": "/business_system/filter/", + "kind": "readonly", + "description": "筛选业务系统列表", + "page": 238 + }, + { + "method": "GET", + "path": "/business_system/{id}/", + "kind": "readonly", + "description": "获取业务系统详情", + "page": 240 + }, + { + "method": "POST", + "path": "/asset_tag/filter/", + "kind": "readonly", + "description": "筛选资产标签列表", + "page": 241 + }, + { + "method": "GET", + "path": "/asset_tag/{id}/", + "kind": "readonly", + "description": "获取资产标签详情", + "page": 242 + }, + { + "method": "POST", + "path": "/network_region/filter/", + "kind": "readonly", + "description": "筛选网络区域列表", + "page": 243 + }, + { + "method": "GET", + "path": "/network_region/{id}/", + "kind": "readonly", + "description": "获取网络区域详情", + "page": 244 + }, + { + "method": "POST", + "path": "/location/filter/", + "kind": "readonly", + "description": "筛选行政区域规划位置", + "page": 245 + }, + { + "method": "GET", + "path": "/location/{id}/", + "kind": "readonly", + "description": "获取行政规划区域详情", + "page": 247 + }, + { + "method": "POST", + "path": "/user/", + "kind": "mutation", + "description": "创建用户", + "page": 250 + }, + { + "method": "DELETE", + "path": "/user/{id}/", + "kind": "mutation", + "description": "删除用户", + "page": 252 + }, + { + "method": "POST", + "path": "/user/filter/", + "kind": "readonly", + "description": "根据用户名筛选用户", + "page": 253 + }, + { + "method": "POST", + "path": "/role/", + "kind": "mutation", + "description": "创建角色", + "page": 255 + }, + { + "method": "POST", + "path": "/role/filter/", + "kind": "readonly", + "description": "根据角色名称筛选角色列表", + "page": 257 + } + ] +} diff --git a/.flocks/flockshub/plugins/tools/device/chaitin_dongjian_v2_8/chaitin_dongjian_api_mutation.yaml b/.flocks/flockshub/plugins/tools/device/chaitin_dongjian_v2_8/chaitin_dongjian_api_mutation.yaml new file mode 100644 index 000000000..758c5c3d7 --- /dev/null +++ b/.flocks/flockshub/plugins/tools/device/chaitin_dongjian_v2_8/chaitin_dongjian_api_mutation.yaml @@ -0,0 +1,36 @@ +name: chaitin_dongjian_api_mutation +description: Chaitin Dongjian documented mutation REST caller with confirmation. +description_cn: 长亭洞鉴文档内变更类 REST 调用工具。所有调用都需要确认。 +category: custom +enabled: true +requires_confirmation: true +provider: chaitin_dongjian_api +version: "2.8" +inputSchema: + type: object + properties: + action: + type: string + enum: + - api_catalog + - rest_call_mutation + method: + type: string + description: HTTP 方法,必须和 catalog 中 kind=mutation 的 method/path 匹配。 + path: + type: string + description: REST 路径,例如 /plan/create/。 + path_params: + type: object + description: 路径参数。 + query: + type: object + description: Query string 参数。 + body: + description: JSON body,按洞鉴 OpenAPI V2.8 对应接口填写。 + required: + - action +handler: + type: script + script_file: chaitin_dongjian.handler.py + function: api_mutation diff --git a/.flocks/flockshub/plugins/tools/device/chaitin_dongjian_v2_8/chaitin_dongjian_api_readonly.yaml b/.flocks/flockshub/plugins/tools/device/chaitin_dongjian_v2_8/chaitin_dongjian_api_readonly.yaml new file mode 100644 index 000000000..2c1fa5fc0 --- /dev/null +++ b/.flocks/flockshub/plugins/tools/device/chaitin_dongjian_v2_8/chaitin_dongjian_api_readonly.yaml @@ -0,0 +1,59 @@ +name: chaitin_dongjian_api_readonly +description: Chaitin Dongjian documented read-only REST caller. +description_cn: 长亭洞鉴文档内只读 REST 调用工具。使用 api_catalog 查看已收录 API,再用 rest_call_readonly 调用 kind=readonly 的 method/path。 +category: custom +enabled: true +requires_confirmation: false +provider: chaitin_dongjian_api +version: "2.8" +inputSchema: + type: object + properties: + action: + type: string + enum: + - api_catalog + - rest_call_readonly + - project_list + - project_filter + - template_list + - plan_filter + - plan_detail + - plugin_filter + - engine_filter + - xprocess_filter + - xprocess_detail + - xprocess_progress + - result_filter + - result_detail + - website_filter + - host_filter + - service_filter + - domain_filter + - vuln_filter + - vuln_detail + - auditlog_filter + - report_filter + - system_info_mgmt + - system_services + - test + method: + type: string + description: rest_call_readonly 使用的 HTTP 方法。 + path: + type: string + description: REST 路径,例如 /project/ 或 /xprocess/{id}/。 + path_params: + type: object + description: '路径参数,例如 {"id": 1}。' + query: + type: object + description: Query string 参数。 + body: + description: JSON body。 + required: + - action +handler: + type: script + script_file: chaitin_dongjian.handler.py + function: api_readonly diff --git a/.flocks/flockshub/plugins/tools/device/chaitin_dongjian_v2_8/chaitin_dongjian_assets.yaml b/.flocks/flockshub/plugins/tools/device/chaitin_dongjian_v2_8/chaitin_dongjian_assets.yaml new file mode 100644 index 000000000..4bec2a578 --- /dev/null +++ b/.flocks/flockshub/plugins/tools/device/chaitin_dongjian_v2_8/chaitin_dongjian_assets.yaml @@ -0,0 +1,35 @@ +name: chaitin_dongjian_assets +description: Chaitin Dongjian web, host, service, and domain asset read-only queries. +description_cn: 长亭洞鉴 Web、主机、服务和域名资产只读查询工具。 +category: custom +enabled: true +requires_confirmation: false +provider: chaitin_dongjian_api +version: "2.8" +inputSchema: + type: object + properties: + action: + type: string + enum: + - website_filter + - host_filter + - service_filter + - domain_filter + - test + description: | + - website_filter: POST /website/filter/,筛选 Web 站点。 + - host_filter: POST /ip/filter/,筛选主机资产。 + - service_filter: POST /service/filter/,筛选服务资产。 + - domain_filter: POST /domain/filter/,筛选域名资产。 + body: + description: POST 查询 body。 + query: + type: object + description: Query 参数,例如 project_id。 + required: + - action +handler: + type: script + script_file: chaitin_dongjian.handler.py + function: assets diff --git a/.flocks/flockshub/plugins/tools/device/chaitin_dongjian_v2_8/chaitin_dongjian_projects.yaml b/.flocks/flockshub/plugins/tools/device/chaitin_dongjian_v2_8/chaitin_dongjian_projects.yaml new file mode 100644 index 000000000..5c55717c3 --- /dev/null +++ b/.flocks/flockshub/plugins/tools/device/chaitin_dongjian_v2_8/chaitin_dongjian_projects.yaml @@ -0,0 +1,37 @@ +name: chaitin_dongjian_projects +description: Chaitin Dongjian project and template read-only queries. +description_cn: 长亭洞鉴组织单位与策略模板只读查询工具。 +category: custom +enabled: true +requires_confirmation: false +provider: chaitin_dongjian_api +version: "2.8" +inputSchema: + type: object + properties: + action: + type: string + enum: + - project_list + - project_filter + - template_list + - test + description: | + - project_list: GET /project/,获取组织单位列表。 + - project_filter: POST /project/filter/,按条件批量获取组织单位列表/详情。 + - template_list: GET /template/,获取策略模板列表。 + query: + type: object + description: GET query 参数。 + body: + description: POST 查询 body。 + limit: + type: integer + offset: + type: integer + required: + - action +handler: + type: script + script_file: chaitin_dongjian.handler.py + function: projects diff --git a/.flocks/flockshub/plugins/tools/device/chaitin_dongjian_v2_8/chaitin_dongjian_results.yaml b/.flocks/flockshub/plugins/tools/device/chaitin_dongjian_v2_8/chaitin_dongjian_results.yaml new file mode 100644 index 000000000..312c46b41 --- /dev/null +++ b/.flocks/flockshub/plugins/tools/device/chaitin_dongjian_v2_8/chaitin_dongjian_results.yaml @@ -0,0 +1,42 @@ +name: chaitin_dongjian_results +description: Chaitin Dongjian scan result, vulnerability, audit log, and report read-only queries. +description_cn: 长亭洞鉴扫描结果、漏洞、审计日志和报表只读查询工具。 +category: custom +enabled: true +requires_confirmation: false +provider: chaitin_dongjian_api +version: "2.8" +inputSchema: + type: object + properties: + action: + type: string + enum: + - result_filter + - result_detail + - vuln_filter + - vuln_detail + - auditlog_filter + - report_filter + - test + description: | + - result_filter: POST /result/filter/,筛选任务结果。 + - result_detail: GET /result/{id}/,查询任务结果详情。 + - vuln_filter: POST /vuln/filter/,筛选漏洞资产。 + - vuln_detail: GET /vuln/{id}/,查询漏洞资产详情。 + - auditlog_filter: POST /auditlog/filter/,筛选审计日志。 + - report_filter: POST /report/filter/,筛选报表。 + id: + type: integer + description: 路径中的结果或漏洞 ID。 + query: + type: object + description: GET query 参数。 + body: + description: POST 查询 body。 + required: + - action +handler: + type: script + script_file: chaitin_dongjian.handler.py + function: results diff --git a/.flocks/flockshub/plugins/tools/device/chaitin_dongjian_v2_8/chaitin_dongjian_system.yaml b/.flocks/flockshub/plugins/tools/device/chaitin_dongjian_v2_8/chaitin_dongjian_system.yaml new file mode 100644 index 000000000..ec90ab11f --- /dev/null +++ b/.flocks/flockshub/plugins/tools/device/chaitin_dongjian_v2_8/chaitin_dongjian_system.yaml @@ -0,0 +1,29 @@ +name: chaitin_dongjian_system +description: Chaitin Dongjian management-node and service status read-only queries. +description_cn: 长亭洞鉴管理节点与系统服务状态只读查询工具。 +category: custom +enabled: true +requires_confirmation: false +provider: chaitin_dongjian_api +version: "2.8" +inputSchema: + type: object + properties: + action: + type: string + enum: + - system_info_mgmt + - system_services + - test + description: | + - system_info_mgmt: GET /system/info/mgmt/,查询管理节点系统信息和负载。 + - system_services: GET /system/info/services/,查询系统服务容器名称列表。 + query: + type: object + description: GET query 参数。 + required: + - action +handler: + type: script + script_file: chaitin_dongjian.handler.py + function: system diff --git a/.flocks/flockshub/plugins/tools/device/chaitin_dongjian_v2_8/chaitin_dongjian_tasks.yaml b/.flocks/flockshub/plugins/tools/device/chaitin_dongjian_v2_8/chaitin_dongjian_tasks.yaml new file mode 100644 index 000000000..ee5905573 --- /dev/null +++ b/.flocks/flockshub/plugins/tools/device/chaitin_dongjian_v2_8/chaitin_dongjian_tasks.yaml @@ -0,0 +1,44 @@ +name: chaitin_dongjian_tasks +description: Chaitin Dongjian scan plan, configuration, and instance read-only queries. +description_cn: 长亭洞鉴扫描计划、任务配置和任务实例只读查询工具。 +category: custom +enabled: true +requires_confirmation: false +provider: chaitin_dongjian_api +version: "2.8" +inputSchema: + type: object + properties: + action: + type: string + enum: + - plan_filter + - plan_detail + - plugin_filter + - engine_filter + - xprocess_filter + - xprocess_detail + - xprocess_progress + - test + description: | + - plan_filter: POST /plan/filter/,筛选任务计划。 + - plan_detail: GET /plan/{id}/,查询任务计划详情。 + - plugin_filter: POST /plugin/filter/,获取漏洞插件列表。 + - engine_filter: POST /engine/filter/,获取引擎节点列表。 + - xprocess_filter: POST /xprocess/filter/,筛选任务实例。 + - xprocess_detail: GET /xprocess/{id}/,查询任务实例详情。 + - xprocess_progress: GET /xprocess/{id}/progress/,查询扫描进度。 + id: + type: integer + description: 路径中的计划或任务实例 ID。 + query: + type: object + description: GET query 参数。 + body: + description: POST 查询 body。 + required: + - action +handler: + type: script + script_file: chaitin_dongjian.handler.py + function: tasks diff --git a/.flocks/flockshub/plugins/tools/device/chaitin_dongjian_v2_8/manifest.json b/.flocks/flockshub/plugins/tools/device/chaitin_dongjian_v2_8/manifest.json new file mode 100644 index 000000000..597259327 --- /dev/null +++ b/.flocks/flockshub/plugins/tools/device/chaitin_dongjian_v2_8/manifest.json @@ -0,0 +1,72 @@ +{ + "schemaVersion": "hub.plugin.v1", + "id": "chaitin_dongjian_v2_8", + "type": "device", + "name": "Chaitin Dongjian", + "description": "Chaitin Dongjian (X-Ray) security assessment system OpenAPI V2.8 integration.", + "descriptionCn": "长亭洞鉴(X-Ray)安全评估系统 OpenAPI V2.8 接入。", + "version": "2.8", + "author": "Flocks Team", + "license": "MIT", + "category": "integration", + "tags": [ + "vulnerability", + "web-security", + "integration" + ], + "useCases": [ + "integration", + "vulnerability-management", + "security-reporting" + ], + "domains": [ + "security-ops" + ], + "capabilities": [ + "device-integration", + "rest-api" + ], + "trust": "official", + "source": { + "kind": "bundled", + "path": "plugins/tools/device/chaitin_dongjian_v2_8" + }, + "compatibility": { + "flocks": ">=0.8.0", + "os": [ + "darwin", + "linux", + "windows" + ] + }, + "dependencies": { + "skills": [], + "tools": [], + "python": [], + "external": [] + }, + "permissions": { + "tools": [], + "network": true, + "shell": false, + "filesystem": "none" + }, + "risk": { + "level": "low", + "reasons": [] + }, + "entrypoints": [ + "_provider.yaml", + "_test.yaml", + "chaitin_dongjian.handler.py", + "chaitin_dongjian_api_catalog.json", + "chaitin_dongjian_projects.yaml", + "chaitin_dongjian_tasks.yaml", + "chaitin_dongjian_assets.yaml", + "chaitin_dongjian_results.yaml", + "chaitin_dongjian_system.yaml", + "chaitin_dongjian_api_readonly.yaml", + "chaitin_dongjian_api_mutation.yaml" + ], + "checksums": {} +} diff --git a/.flocks/flockshub/plugins/tools/device/chaitin_muyun_api_v3/_provider.yaml b/.flocks/flockshub/plugins/tools/device/chaitin_muyun_api_v3/_provider.yaml new file mode 100644 index 000000000..39c7482ed --- /dev/null +++ b/.flocks/flockshub/plugins/tools/device/chaitin_muyun_api_v3/_provider.yaml @@ -0,0 +1,47 @@ +name: chaitin_muyun +vendor: chaitin +service_id: chaitin_muyun_api +version: "API 3.0" +integration_type: device +description: > + Chaitin Muyun host security platform JSON-RPC API integration. Configure + the device URL, API Token, and optional organization ID. All business + APIs are called through POST /rpc with JSON-RPC 2.0 payloads. +description_cn: > + 长亭牧云主机安全平台 API 3.0 接入。配置设备地址、API Token 和可选机构 ID; + 业务接口统一通过 POST /rpc 的 JSON-RPC 2.0 调用。 +auth: + type: custom + secret: chaitin_muyun_api_token +credential_fields: + - key: base_url + label: 设备地址 + storage: config + config_key: base_url + input_type: url + required: true + placeholder: "https://muyun.example.com" + - key: api_token + label: API Token + storage: secret + config_key: api_token + secret_id: chaitin_muyun_api_token + input_type: password + required: true + - key: org_id + label: 机构 ID(可选) + storage: config + config_key: org_id + input_type: text + required: false +defaults: + timeout: 30 + category: custom + product_version: "API 3.0" + verify_ssl: false +notes: | + 认证规则来自《API 3.0接口使用说明》: + - 所有 JSON-RPC 接口暴露在 https://${SERVER_ADDR}/rpc。 + - 请求头 Content-Type 为 application/json。 + - 推荐使用 API Token,Token 以 Cookie: API-Token=... 传递。 + - 机构用户访问跨机构数据时可配置 org_id,handler 会写入 X-CW-OID。 diff --git a/.flocks/flockshub/plugins/tools/device/chaitin_muyun_api_v3/_test.yaml b/.flocks/flockshub/plugins/tools/device/chaitin_muyun_api_v3/_test.yaml new file mode 100644 index 000000000..2b1766e3b --- /dev/null +++ b/.flocks/flockshub/plugins/tools/device/chaitin_muyun_api_v3/_test.yaml @@ -0,0 +1,60 @@ +schema_version: 1 +provider: chaitin_muyun_api + +connectivity: + tool: chaitin_muyun_assets + params: + action: product_info + +fixtures: + chaitin_muyun_assets: + - label: Query product information + label_cn: 查询产品信息 + tags: [smoke, system] + params: + action: product_info + assert: + success: true + - label: Count host assets + label_cn: 统计主机资产数量 + tags: [asset] + params: + action: host_count + + chaitin_muyun_events: + - label: List malware events + label_cn: 查询恶意文件事件 + tags: [event] + params: + action: malware_events + params: + limit: 10 + offset: 0 + + chaitin_muyun_risk: + - label: List vulnerability events + label_cn: 查询漏洞事件 + tags: [vulnerability] + params: + action: vuln_list + params: + limit: 10 + offset: 0 + + chaitin_muyun_api_readonly: + - label: Show API catalog + label_cn: 查看 API 目录 + tags: [api] + params: + action: api_catalog + assert: + success: true + + chaitin_muyun_api_mutation: + - label: Show API catalog before confirmed mutation + label_cn: 变更调用前查看 API 目录 + tags: [api, mutation] + params: + action: api_catalog + assert: + success: true diff --git a/.flocks/flockshub/plugins/tools/device/chaitin_muyun_api_v3/chaitin_muyun.handler.py b/.flocks/flockshub/plugins/tools/device/chaitin_muyun_api_v3/chaitin_muyun.handler.py new file mode 100644 index 000000000..07d7db6f0 --- /dev/null +++ b/.flocks/flockshub/plugins/tools/device/chaitin_muyun_api_v3/chaitin_muyun.handler.py @@ -0,0 +1,371 @@ +from __future__ import annotations + +import asyncio +import json +import os +import uuid +from pathlib import Path +from typing import Any, Callable + +import requests + +from flocks.config.config_writer import ConfigWriter +from flocks.security import get_secret_manager +from flocks.tool.registry import ToolContext, ToolResult + + +SERVICE_ID = "chaitin_muyun_api" +STORAGE_KEY = "chaitin_muyun_api_v3" +PRODUCT_VERSION = "API 3.0" +DEFAULT_TIMEOUT = 30 +DEFAULT_VERIFY_SSL = False +CATALOG_FILE = Path(__file__).with_name("chaitin_muyun_api_catalog.json") + + +class ChaitinMuyunError(RuntimeError): + pass + + +class RuntimeConfig: + def __init__( + self, + *, + base_url: str, + api_token: str, + org_id: str, + verify_ssl: bool, + timeout: int, + ) -> None: + self.base_url = base_url + self.api_token = api_token + self.org_id = org_id + self.verify_ssl = verify_ssl + self.timeout = timeout + + +def _resolve_ref(value: Any) -> str: + if value is None: + return "" + if not isinstance(value, str): + return str(value) + if value.startswith("{secret:") and value.endswith("}"): + return get_secret_manager().get(value[len("{secret:") : -1]) or "" + if value.startswith("{env:") and value.endswith("}"): + return os.getenv(value[len("{env:") : -1], "") + return value + + +def _raw_service_config() -> dict[str, Any]: + raw = ConfigWriter.get_api_service_raw(SERVICE_ID) + if not isinstance(raw, dict): + raw = ConfigWriter.get_api_service_raw(STORAGE_KEY) + return raw if isinstance(raw, dict) else {} + + +def _config_value(raw: dict[str, Any], *keys: str) -> Any: + for key in keys: + if raw.get(key) is not None: + return raw[key] + custom_settings = raw.get("custom_settings") + if isinstance(custom_settings, dict): + for key in keys: + if custom_settings.get(key) is not None: + return custom_settings[key] + return None + + +def _as_bool(value: Any, default: bool) -> bool: + if value is None: + return default + if isinstance(value, bool): + return value + if isinstance(value, str): + text = value.strip().lower() + if text in {"1", "true", "yes", "on"}: + return True + if text in {"0", "false", "no", "off"}: + return False + return bool(value) + + +def _normalize_base_url(base_url: str) -> str: + text = base_url.strip().rstrip("/") + if text.endswith("/rpc"): + text = text[:-4] + return text.rstrip("/") + + +def resolve_config() -> RuntimeConfig: + raw = _raw_service_config() + base_url = ( + _resolve_ref(_config_value(raw, "base_url", "baseUrl")) + or os.getenv("CHAITIN_MUYUN_BASE_URL", "") + ) + if not base_url: + raise ChaitinMuyunError("Chaitin Muyun base_url is not configured") + + api_token = ( + _resolve_ref(_config_value(raw, "api_token", "apiToken", "token")) + or get_secret_manager().get("chaitin_muyun_api_token") + or get_secret_manager().get(f"{SERVICE_ID}_token") + or os.getenv("CHAITIN_MUYUN_API_TOKEN", "") + ) + if not api_token: + raise ChaitinMuyunError("Chaitin Muyun API token is not configured") + + org_id = ( + _resolve_ref(_config_value(raw, "org_id", "oid")) + or os.getenv("CHAITIN_MUYUN_ORG_ID", "") + ) + try: + timeout = int(_config_value(raw, "timeout") or DEFAULT_TIMEOUT) + except (TypeError, ValueError): + timeout = DEFAULT_TIMEOUT + + verify_ssl = _as_bool( + _config_value(raw, "verify_ssl", "ssl_verify", "verifySsl") + if _config_value(raw, "verify_ssl", "ssl_verify", "verifySsl") is not None + else os.getenv("CHAITIN_MUYUN_VERIFY_SSL"), + DEFAULT_VERIFY_SSL, + ) + return RuntimeConfig( + base_url=_normalize_base_url(base_url), + api_token=api_token, + org_id=org_id, + verify_ssl=verify_ssl, + timeout=timeout, + ) + + +class MuyunClient: + def __init__(self, config: RuntimeConfig) -> None: + self.config = config + + @property + def rpc_url(self) -> str: + return f"{self.config.base_url}/rpc" + + def rpc(self, method: str, params: dict[str, Any] | None = None) -> Any: + body = { + "jsonrpc": "2.0", + "method": method, + "params": params or {}, + "id": str(uuid.uuid4()), + } + headers = { + "Content-Type": "application/json", + "Cookie": f"API-Token={self.config.api_token}", + } + if self.config.org_id: + headers["X-CW-OID"] = self.config.org_id + response = requests.post( + self.rpc_url, + json=body, + headers=headers, + timeout=self.config.timeout, + verify=self.config.verify_ssl, + ) + return _json_rpc_response(response) + + +def _json_rpc_response(response: requests.Response) -> Any: + try: + payload = response.json() + except ValueError as exc: + raise ChaitinMuyunError(f"Invalid JSON response: HTTP {response.status_code}") from exc + if not isinstance(payload, dict): + raise ChaitinMuyunError("Unexpected JSON-RPC response shape: expected object") + if response.status_code >= 400: + raise ChaitinMuyunError(f"HTTP {response.status_code}: {payload}") + if payload.get("error"): + error = payload["error"] + if isinstance(error, dict): + message = error.get("message") or error.get("code") or error + else: + message = error + raise ChaitinMuyunError(str(message)) + return payload.get("result", payload) + + +def _ok(data: Any, *, action: str) -> ToolResult: + return ToolResult( + success=True, + output=data, + metadata={"source": "Chaitin Muyun", "version": PRODUCT_VERSION, "action": action}, + ) + + +def get_client() -> MuyunClient: + return MuyunClient(resolve_config()) + + +def _params(args: dict[str, Any]) -> dict[str, Any]: + raw = args.get("params") + if isinstance(raw, dict): + return dict(raw) + ignored = {"action", "method"} + return {k: v for k, v in args.items() if k not in ignored and v is not None} + + +def _load_api_catalog() -> list[dict[str, Any]]: + try: + data = json.loads(CATALOG_FILE.read_text(encoding="utf-8")) + except FileNotFoundError: + return [] + entries = data.get("entries") + return entries if isinstance(entries, list) else [] + + +def _catalog_methods(kind: str) -> set[str]: + return { + str(entry.get("method")) + for entry in _load_api_catalog() + if entry.get("kind") == kind and entry.get("method") + } + + +READONLY_ACTIONS: dict[str, str] = { + "product_info": "CloudwalkerSettingService.GetProductInfo", + "current_user": "AccountAuthService.GetCurrentUserInfo", + "host_count": "HostAssetService.CountHost", + "host_list": "HostAssetService.GetHostAssetList", + "host_detail": "HostAssetService.GetHostInfoDetail", + "application_list": "ApplicationAssetService.GetApplicationAssetList", + "website_list": "WebsiteAssetService.GetWebsiteList", + "process_list": "ProcessAssetService.GetProcessList", + "webshell_events": "WebshellEventService.GetEventList", + "malware_events": "MalwareEventService.GetEventList", + "bruteforce_events": "BruteForceService.GetEventList", + "abnormal_login_events": "AbnormalLoginEventService.GetEventList", + "realtime_events": "ThreatOverviewService.ListRealTimeEvents", + "vuln_list": "VulnService.GetVulnList", + "vuln_detail": "VulnService.GetVuln", + "security_check_events": "SecurityCheckService.GetEventList", + "baseline_tasks": "BaselineV2Service.GetTaskList", + "emergency_vulns": "EmergencyVulnService.ListVuln", +} + + +ASSET_ACTIONS = { + "product_info", + "current_user", + "host_count", + "host_list", + "host_detail", + "application_list", + "website_list", + "process_list", +} +EVENT_ACTIONS = { + "webshell_events", + "malware_events", + "bruteforce_events", + "abnormal_login_events", + "realtime_events", +} +RISK_ACTIONS = { + "vuln_list", + "vuln_detail", + "security_check_events", + "baseline_tasks", + "emergency_vulns", +} + + +def call_method(method: str, args: dict[str, Any], *, action: str) -> ToolResult: + return _ok(get_client().rpc(method, _params(args)), action=action) + + +def api_catalog(args: dict[str, Any]) -> ToolResult: + del args + catalog = _load_api_catalog() + return _ok( + { + "rpc": "/rpc", + "catalog_counts": { + "total": len(catalog), + "readonly": sum(1 for entry in catalog if entry.get("kind") == "readonly"), + "mutation": sum(1 for entry in catalog if entry.get("kind") == "mutation"), + }, + "documented_api_catalog": catalog, + "common_actions": { + "assets": sorted(ASSET_ACTIONS), + "events": sorted(EVENT_ACTIONS), + "risk": sorted(RISK_ACTIONS), + }, + }, + action="api_catalog", + ) + + +def rpc_call_readonly(args: dict[str, Any]) -> ToolResult: + method = str(args.get("method") or "").strip() + if not method: + raise ChaitinMuyunError("method is required") + allowed = _catalog_methods("readonly") | set(READONLY_ACTIONS.values()) + if method not in allowed: + raise ChaitinMuyunError("Only documented read-only JSON-RPC methods are allowed") + return call_method(method, args, action="rpc_call_readonly") + + +def rpc_call_mutation(args: dict[str, Any]) -> ToolResult: + method = str(args.get("method") or "").strip() + if not method: + raise ChaitinMuyunError("method is required") + if method not in _catalog_methods("mutation"): + raise ChaitinMuyunError("Only documented mutation JSON-RPC methods are allowed") + return call_method(method, args, action="rpc_call_mutation") + + +ACTION_HANDLERS: dict[str, Callable[[dict[str, Any]], ToolResult]] = { + "api_catalog": api_catalog, + "rpc_call_readonly": rpc_call_readonly, + "rpc_call_mutation": rpc_call_mutation, +} +for _action, _method in READONLY_ACTIONS.items(): + ACTION_HANDLERS[_action] = lambda args, method=_method, action=_action: call_method(method, args, action=action) + + +async def _dispatch(ctx: ToolContext, allowed: set[str], action: str, **params: Any) -> ToolResult: + del ctx + if action == "test": + action = "product_info" + if action not in allowed: + return ToolResult( + success=False, + error=f"Unsupported Chaitin Muyun action: {action}. Available: {', '.join(sorted(allowed))}", + ) + try: + return await asyncio.to_thread(ACTION_HANDLERS[action], params) + except ChaitinMuyunError as exc: + return ToolResult( + success=False, + error=str(exc), + metadata={"source": "Chaitin Muyun", "version": PRODUCT_VERSION, "action": action}, + ) + except Exception as exc: + return ToolResult( + success=False, + error=f"Unexpected Chaitin Muyun error: {exc}", + metadata={"source": "Chaitin Muyun", "version": PRODUCT_VERSION, "action": action}, + ) + + +async def assets(ctx: ToolContext, action: str, **params: Any) -> ToolResult: + return await _dispatch(ctx, ASSET_ACTIONS | {"test"}, action, **params) + + +async def events(ctx: ToolContext, action: str, **params: Any) -> ToolResult: + return await _dispatch(ctx, EVENT_ACTIONS | {"test"}, action, **params) + + +async def risk(ctx: ToolContext, action: str, **params: Any) -> ToolResult: + return await _dispatch(ctx, RISK_ACTIONS | {"test"}, action, **params) + + +async def api_readonly(ctx: ToolContext, action: str, **params: Any) -> ToolResult: + return await _dispatch(ctx, {"api_catalog", "rpc_call_readonly", *READONLY_ACTIONS.keys(), "test"}, action, **params) + + +async def api_mutation(ctx: ToolContext, action: str, **params: Any) -> ToolResult: + return await _dispatch(ctx, {"api_catalog", "rpc_call_mutation"}, action, **params) diff --git a/.flocks/flockshub/plugins/tools/device/chaitin_muyun_api_v3/chaitin_muyun_api_catalog.json b/.flocks/flockshub/plugins/tools/device/chaitin_muyun_api_v3/chaitin_muyun_api_catalog.json new file mode 100644 index 000000000..f8dbc4d0b --- /dev/null +++ b/.flocks/flockshub/plugins/tools/device/chaitin_muyun_api_v3/chaitin_muyun_api_catalog.json @@ -0,0 +1,7787 @@ +{ + "schema_version": 1, + "source": "长亭牧云api/openapi.json", + "rpc_endpoint": "/rpc", + "entries": [ + { + "method": "AbnormalLoginEventService.CreateWhitelist", + "service": "AbnormalLoginEventService", + "operation": "CreateWhitelist", + "kind": "mutation", + "description": "生成白名单规则", + "tags": [ + "异常登录" + ] + }, + { + "method": "AbnormalLoginEventService.DeleteEvent", + "service": "AbnormalLoginEventService", + "operation": "DeleteEvent", + "kind": "mutation", + "description": "删除所选 ID 的事件", + "tags": [ + "异常登录" + ] + }, + { + "method": "AbnormalLoginEventService.DeleteEventByHost", + "service": "AbnormalLoginEventService", + "operation": "DeleteEventByHost", + "kind": "mutation", + "description": "删除所选主机的事件", + "tags": [ + "异常登录" + ] + }, + { + "method": "AbnormalLoginEventService.DeleteEventByLoginIP", + "service": "AbnormalLoginEventService", + "operation": "DeleteEventByLoginIP", + "kind": "mutation", + "description": "删除所选登陆 IP 的事件", + "tags": [ + "异常登录" + ] + }, + { + "method": "AbnormalLoginEventService.EditEventComment", + "service": "AbnormalLoginEventService", + "operation": "EditEventComment", + "kind": "mutation", + "description": "改变所选事件备注", + "tags": [ + "异常登录" + ] + }, + { + "method": "AbnormalLoginEventService.EditEventState", + "service": "AbnormalLoginEventService", + "operation": "EditEventState", + "kind": "mutation", + "description": "改变所选 ID 事件的处置状态", + "tags": [ + "异常登录" + ] + }, + { + "method": "AbnormalLoginEventService.EditEventStateByHost", + "service": "AbnormalLoginEventService", + "operation": "EditEventStateByHost", + "kind": "mutation", + "description": "改变所选主机 ID 事件的处置状态", + "tags": [ + "异常登录" + ] + }, + { + "method": "AbnormalLoginEventService.EditEventStateByLoginIP", + "service": "AbnormalLoginEventService", + "operation": "EditEventStateByLoginIP", + "kind": "mutation", + "description": "改变所选登陆 IP 事件的处置状态", + "tags": [ + "异常登录" + ] + }, + { + "method": "AbnormalLoginEventService.GenerateEventFirewallRule", + "service": "AbnormalLoginEventService", + "operation": "GenerateEventFirewallRule", + "kind": "mutation", + "description": "根据所选事件生成网络阻断规则", + "tags": [ + "异常登陆" + ] + }, + { + "method": "AbnormalLoginEventService.GetEvent", + "service": "AbnormalLoginEventService", + "operation": "GetEvent", + "kind": "readonly", + "description": "获取事件详情", + "tags": [ + "异常登录" + ] + }, + { + "method": "AbnormalLoginEventService.GetEventByHostList", + "service": "AbnormalLoginEventService", + "operation": "GetEventByHostList", + "kind": "readonly", + "description": "获取按主机聚合的事件列表", + "tags": [ + "异常登录" + ] + }, + { + "method": "AbnormalLoginEventService.GetEventByLoginIPList", + "service": "AbnormalLoginEventService", + "operation": "GetEventByLoginIPList", + "kind": "readonly", + "description": "获取按登陆 IP 聚合的事件列表", + "tags": [ + "异常登录" + ] + }, + { + "method": "AbnormalLoginEventService.GetEventList", + "service": "AbnormalLoginEventService", + "operation": "GetEventList", + "kind": "readonly", + "description": "获取事件列表", + "tags": [ + "异常登录" + ] + }, + { + "method": "AbnormalLoginEventService.GetGeographicalLocation", + "service": "AbnormalLoginEventService", + "operation": "GetGeographicalLocation", + "kind": "readonly", + "description": "获取地理信息", + "tags": [ + "异常登录" + ] + }, + { + "method": "AbnormalLoginEventService.GetWhitelist", + "service": "AbnormalLoginEventService", + "operation": "GetWhitelist", + "kind": "readonly", + "description": "获取白名单规则", + "tags": [ + "异常登录" + ] + }, + { + "method": "AbnormalLoginEventService.MarkAsRead", + "service": "AbnormalLoginEventService", + "operation": "MarkAsRead", + "kind": "mutation", + "description": "标记事件为已读", + "tags": [ + "异常登录" + ] + }, + { + "method": "AbnormalLoginEventService.ResetEventState", + "service": "AbnormalLoginEventService", + "operation": "ResetEventState", + "kind": "mutation", + "description": "reset the state of event to 'risky'", + "tags": [ + "异常登录" + ] + }, + { + "method": "AbnormalLoginEventService.ResetEventStateByHost", + "service": "AbnormalLoginEventService", + "operation": "ResetEventStateByHost", + "kind": "mutation", + "description": "reset the state of event to 'risky'", + "tags": [ + "异常登录" + ] + }, + { + "method": "AbnormalLoginEventService.ResetEventStateByLoginIP", + "service": "AbnormalLoginEventService", + "operation": "ResetEventStateByLoginIP", + "kind": "mutation", + "description": "改变所选主机 ID 事件的处置状态", + "tags": [ + "异常登录" + ] + }, + { + "method": "AccountAuthService.BindCurrentUserOAuth", + "service": "AccountAuthService", + "operation": "BindCurrentUserOAuth", + "kind": "mutation", + "description": "绑定当前用户 Oauth", + "tags": [ + "用户管理" + ] + }, + { + "method": "AccountAuthService.ConfirmCurrentUserFeitian", + "service": "AccountAuthService", + "operation": "ConfirmCurrentUserFeitian", + "kind": "mutation", + "description": "绑定当前用户 TOTP", + "tags": [ + "用户管理" + ] + }, + { + "method": "AccountAuthService.ConfirmCurrentUserTotp", + "service": "AccountAuthService", + "operation": "ConfirmCurrentUserTotp", + "kind": "mutation", + "description": "绑定当前用户 TOTP", + "tags": [ + "用户管理" + ] + }, + { + "method": "AccountAuthService.CreateAPIToken", + "service": "AccountAuthService", + "operation": "CreateAPIToken", + "kind": "mutation", + "description": "创建 API Token", + "tags": [ + "用户管理" + ] + }, + { + "method": "AccountAuthService.CreateLoginToken", + "service": "AccountAuthService", + "operation": "CreateLoginToken", + "kind": "mutation", + "description": "使用 API Token 获取登录 Token", + "tags": [ + "用户管理" + ] + }, + { + "method": "AccountAuthService.CreateRole", + "service": "AccountAuthService", + "operation": "CreateRole", + "kind": "mutation", + "description": "创建角色", + "tags": [ + "用户管理" + ] + }, + { + "method": "AccountAuthService.CreateSessionToken", + "service": "AccountAuthService", + "operation": "CreateSessionToken", + "kind": "mutation", + "description": "创建会话 Token", + "tags": [ + "用户管理" + ] + }, + { + "method": "AccountAuthService.CreateUser", + "service": "AccountAuthService", + "operation": "CreateUser", + "kind": "mutation", + "description": "创建用户", + "tags": [ + "用户管理" + ] + }, + { + "method": "AccountAuthService.DeleteAPIToken", + "service": "AccountAuthService", + "operation": "DeleteAPIToken", + "kind": "mutation", + "description": "删除 API Token", + "tags": [ + "用户管理" + ] + }, + { + "method": "AccountAuthService.DeleteCurrentUserTotp", + "service": "AccountAuthService", + "operation": "DeleteCurrentUserTotp", + "kind": "mutation", + "description": "解除绑定当前用户 TOTP", + "tags": [ + "用户管理" + ] + }, + { + "method": "AccountAuthService.DeleteRole", + "service": "AccountAuthService", + "operation": "DeleteRole", + "kind": "mutation", + "description": "删除角色", + "tags": [ + "用户管理" + ] + }, + { + "method": "AccountAuthService.DeleteUser", + "service": "AccountAuthService", + "operation": "DeleteUser", + "kind": "mutation", + "description": "删除用户", + "tags": [ + "用户管理" + ] + }, + { + "method": "AccountAuthService.EditAPIToken", + "service": "AccountAuthService", + "operation": "EditAPIToken", + "kind": "mutation", + "description": "编辑 API Token", + "tags": [ + "用户管理" + ] + }, + { + "method": "AccountAuthService.EditAPITokenEnable", + "service": "AccountAuthService", + "operation": "EditAPITokenEnable", + "kind": "mutation", + "description": "启用/禁用 API Token", + "tags": [ + "用户管理" + ] + }, + { + "method": "AccountAuthService.EditCurrentUserCredential", + "service": "AccountAuthService", + "operation": "EditCurrentUserCredential", + "kind": "mutation", + "description": "编辑当前用户登录凭证", + "tags": [ + "用户管理" + ] + }, + { + "method": "AccountAuthService.EditCurrentUserIPPolicy", + "service": "AccountAuthService", + "operation": "EditCurrentUserIPPolicy", + "kind": "mutation", + "description": "编辑当前用户 IP 访问规则", + "tags": [ + "用户管理" + ] + }, + { + "method": "AccountAuthService.EditCurrentUserInfo", + "service": "AccountAuthService", + "operation": "EditCurrentUserInfo", + "kind": "mutation", + "description": "编辑当前用户备注", + "tags": [ + "用户管理" + ] + }, + { + "method": "AccountAuthService.EditCurrentUserLanguage", + "service": "AccountAuthService", + "operation": "EditCurrentUserLanguage", + "kind": "mutation", + "description": "编辑当前用户语言", + "tags": [ + "用户管理" + ] + }, + { + "method": "AccountAuthService.EditCurrentUserName", + "service": "AccountAuthService", + "operation": "EditCurrentUserName", + "kind": "mutation", + "description": "编辑当前用户名", + "tags": [ + "用户管理" + ] + }, + { + "method": "AccountAuthService.EditCurrentUserSessionExpireTime", + "service": "AccountAuthService", + "operation": "EditCurrentUserSessionExpireTime", + "kind": "mutation", + "description": "编辑当前用户会话过期超时时间", + "tags": [ + "用户管理" + ] + }, + { + "method": "AccountAuthService.EditCurrentUserTimezone", + "service": "AccountAuthService", + "operation": "EditCurrentUserTimezone", + "kind": "mutation", + "description": "编辑当前用户时区", + "tags": [ + "用户管理" + ] + }, + { + "method": "AccountAuthService.EditRole", + "service": "AccountAuthService", + "operation": "EditRole", + "kind": "mutation", + "description": "编辑角色", + "tags": [ + "用户管理" + ] + }, + { + "method": "AccountAuthService.EditUser", + "service": "AccountAuthService", + "operation": "EditUser", + "kind": "mutation", + "description": "编辑用户信息", + "tags": [ + "用户管理" + ] + }, + { + "method": "AccountAuthService.EditUserCredential", + "service": "AccountAuthService", + "operation": "EditUserCredential", + "kind": "mutation", + "description": "编辑用户登录凭证", + "tags": [ + "用户管理" + ] + }, + { + "method": "AccountAuthService.EditUserEnable", + "service": "AccountAuthService", + "operation": "EditUserEnable", + "kind": "mutation", + "description": "启用/禁用用户", + "tags": [ + "用户管理" + ] + }, + { + "method": "AccountAuthService.EditUserExpireTime", + "service": "AccountAuthService", + "operation": "EditUserExpireTime", + "kind": "mutation", + "description": "编辑用户过期时间", + "tags": [ + "用户管理" + ] + }, + { + "method": "AccountAuthService.EditUserIPPolicy", + "service": "AccountAuthService", + "operation": "EditUserIPPolicy", + "kind": "mutation", + "description": "编辑用户 IP 访问规则", + "tags": [ + "用户管理" + ] + }, + { + "method": "AccountAuthService.EditUserInfo", + "service": "AccountAuthService", + "operation": "EditUserInfo", + "kind": "mutation", + "description": "编辑用户备注", + "tags": [ + "用户管理" + ] + }, + { + "method": "AccountAuthService.EditUserName", + "service": "AccountAuthService", + "operation": "EditUserName", + "kind": "mutation", + "description": "编辑用户名", + "tags": [ + "用户管理" + ] + }, + { + "method": "AccountAuthService.EditUserRole", + "service": "AccountAuthService", + "operation": "EditUserRole", + "kind": "mutation", + "description": "编辑用户角色", + "tags": [ + "用户管理" + ] + }, + { + "method": "AccountAuthService.EditUserSessionExpireTime", + "service": "AccountAuthService", + "operation": "EditUserSessionExpireTime", + "kind": "mutation", + "description": "编辑用户会话过期超时时间", + "tags": [ + "用户管理" + ] + }, + { + "method": "AccountAuthService.GenerateTotp", + "service": "AccountAuthService", + "operation": "GenerateTotp", + "kind": "mutation", + "description": "生成 TOTP 随机密码和链接", + "tags": [ + "用户管理" + ] + }, + { + "method": "AccountAuthService.GetAPIToken", + "service": "AccountAuthService", + "operation": "GetAPIToken", + "kind": "readonly", + "description": "获取 API Token 信息", + "tags": [ + "用户管理" + ] + }, + { + "method": "AccountAuthService.GetCurrentUserInfo", + "service": "AccountAuthService", + "operation": "GetCurrentUserInfo", + "kind": "readonly", + "description": "获取当前用户信息", + "tags": [ + "用户管理" + ] + }, + { + "method": "AccountAuthService.GetCurrentUserOAuthBind", + "service": "AccountAuthService", + "operation": "GetCurrentUserOAuthBind", + "kind": "readonly", + "description": "获取当前用户 Oauth 绑定信息", + "tags": [ + "用户管理" + ] + }, + { + "method": "AccountAuthService.GetCurrentUserOtpConfig", + "service": "AccountAuthService", + "operation": "GetCurrentUserOtpConfig", + "kind": "readonly", + "description": "获取当前用户 OTP 配置", + "tags": [ + "用户管理" + ] + }, + { + "method": "AccountAuthService.GetCurrentUserPermission", + "service": "AccountAuthService", + "operation": "GetCurrentUserPermission", + "kind": "readonly", + "description": "获取当前用户权限", + "tags": [ + "用户管理" + ] + }, + { + "method": "AccountAuthService.GetPermissionInfo", + "service": "AccountAuthService", + "operation": "GetPermissionInfo", + "kind": "readonly", + "description": "获取权限列表", + "tags": [ + "用户管理" + ] + }, + { + "method": "AccountAuthService.GetRoleInfo", + "service": "AccountAuthService", + "operation": "GetRoleInfo", + "kind": "readonly", + "description": "获取角色详情", + "tags": [ + "用户管理" + ] + }, + { + "method": "AccountAuthService.GetRoleList", + "service": "AccountAuthService", + "operation": "GetRoleList", + "kind": "readonly", + "description": "获取角色列表", + "tags": [ + "用户管理" + ] + }, + { + "method": "AccountAuthService.GetSessionTokenExpire", + "service": "AccountAuthService", + "operation": "GetSessionTokenExpire", + "kind": "readonly", + "description": "获取会话 Token 过期时间", + "tags": [ + "用户管理" + ] + }, + { + "method": "AccountAuthService.GetUserInfo", + "service": "AccountAuthService", + "operation": "GetUserInfo", + "kind": "readonly", + "description": "获取用户信息", + "tags": [ + "用户管理" + ] + }, + { + "method": "AccountAuthService.GetUserList", + "service": "AccountAuthService", + "operation": "GetUserList", + "kind": "readonly", + "description": "获取用户列表", + "tags": [ + "用户管理" + ] + }, + { + "method": "AccountAuthService.Logout", + "service": "AccountAuthService", + "operation": "Logout", + "kind": "mutation", + "description": "用户登出", + "tags": [ + "用户管理" + ] + }, + { + "method": "AccountAuthService.UnbindCurrentUserOAuth", + "service": "AccountAuthService", + "operation": "UnbindCurrentUserOAuth", + "kind": "mutation", + "description": "解绑当前用户 Oauth", + "tags": [ + "用户管理" + ] + }, + { + "method": "AccountNoAuthService.GetAvailableOAuth", + "service": "AccountNoAuthService", + "operation": "GetAvailableOAuth", + "kind": "readonly", + "description": "获取系统可用 Oauth 登录类型", + "tags": [ + "用户管理" + ] + }, + { + "method": "AccountNoAuthService.Login", + "service": "AccountNoAuthService", + "operation": "Login", + "kind": "mutation", + "description": "用户登录", + "tags": [ + "用户管理" + ] + }, + { + "method": "AccountNoAuthService.Mfa", + "service": "AccountNoAuthService", + "operation": "Mfa", + "kind": "mutation", + "description": "用户登录", + "tags": [ + "用户管理" + ] + }, + { + "method": "AdminAccountService.CreateUser", + "service": "AdminAccountService", + "operation": "CreateUser", + "kind": "mutation", + "description": "创建用户", + "tags": [ + "后台:账号管理" + ] + }, + { + "method": "AdminAccountService.DeleteUser", + "service": "AdminAccountService", + "operation": "DeleteUser", + "kind": "mutation", + "description": "删除用户", + "tags": [ + "后台:账号管理" + ] + }, + { + "method": "AdminAccountService.GetUserInfo", + "service": "AdminAccountService", + "operation": "GetUserInfo", + "kind": "readonly", + "description": "获取用户信息", + "tags": [ + "后台:账号管理" + ] + }, + { + "method": "AdminAccountService.ListUser", + "service": "AdminAccountService", + "operation": "ListUser", + "kind": "readonly", + "description": "用户列表", + "tags": [ + "后台:账号管理" + ] + }, + { + "method": "AdminAccountService.UpdateCurrentUser", + "service": "AdminAccountService", + "operation": "UpdateCurrentUser", + "kind": "mutation", + "description": "更新当前用户信息", + "tags": [ + "后台:账号管理" + ] + }, + { + "method": "AdminAccountService.UpdateUser", + "service": "AdminAccountService", + "operation": "UpdateUser", + "kind": "mutation", + "description": "更新用户信息", + "tags": [ + "后台:账号管理" + ] + }, + { + "method": "AdminAgentService.ApplyPackage", + "service": "AdminAgentService", + "operation": "ApplyPackage", + "kind": "mutation", + "description": "应用探针模块升级包", + "tags": [ + "后台:探针管理" + ] + }, + { + "method": "AdminAgentService.DeleteModule", + "service": "AdminAgentService", + "operation": "DeleteModule", + "kind": "mutation", + "description": "删除模块", + "tags": [ + "后台:探针管理" + ] + }, + { + "method": "AdminAgentService.GetAgentList", + "service": "AdminAgentService", + "operation": "GetAgentList", + "kind": "readonly", + "description": "获取探针列表", + "tags": [ + "后台:探针管理" + ] + }, + { + "method": "AdminAgentService.GetModuleDetail", + "service": "AdminAgentService", + "operation": "GetModuleDetail", + "kind": "readonly", + "description": "获取探针模块详情", + "tags": [ + "后台:探针管理" + ] + }, + { + "method": "AdminAgentService.GetModuleList", + "service": "AdminAgentService", + "operation": "GetModuleList", + "kind": "readonly", + "description": "获取探针模块列表", + "tags": [ + "后台:探针管理" + ] + }, + { + "method": "AdminStrategyService.GetLoginControlStrategy", + "service": "AdminStrategyService", + "operation": "GetLoginControlStrategy", + "kind": "readonly", + "description": "获取登陆控制策略", + "tags": [ + "后台:策略管理" + ] + }, + { + "method": "AdminStrategyService.GetSystemStrategy", + "service": "AdminStrategyService", + "operation": "GetSystemStrategy", + "kind": "readonly", + "description": "获取系统默认策略", + "tags": [ + "后台:策略管理" + ] + }, + { + "method": "AdminStrategyService.RestoreSystemStrategy", + "service": "AdminStrategyService", + "operation": "RestoreSystemStrategy", + "kind": "mutation", + "description": "恢复系统默认策略", + "tags": [ + "后台:策略管理" + ] + }, + { + "method": "AdminStrategyService.SetLoginControlStrategy", + "service": "AdminStrategyService", + "operation": "SetLoginControlStrategy", + "kind": "mutation", + "description": "设置安全登陆控制策略", + "tags": [ + "后台:策略管理" + ] + }, + { + "method": "AdminStrategyService.SetSystemStrategy", + "service": "AdminStrategyService", + "operation": "SetSystemStrategy", + "kind": "mutation", + "description": "设置系统默认策略", + "tags": [ + "后台:策略管理" + ] + }, + { + "method": "AgentCEService.CreateCmd", + "service": "AgentCEService", + "operation": "CreateCmd", + "kind": "mutation", + "description": "创建命令推送", + "tags": [ + "命令推送" + ] + }, + { + "method": "AgentCEService.DeleteCmd", + "service": "AgentCEService", + "operation": "DeleteCmd", + "kind": "mutation", + "description": "删除命令推送", + "tags": [ + "命令推送" + ] + }, + { + "method": "AgentCEService.GetCe", + "service": "AgentCEService", + "operation": "GetCe", + "kind": "readonly", + "description": "获取某个探针的命令推送执行详情", + "tags": [ + "命令推送" + ] + }, + { + "method": "AgentCEService.GetCmd", + "service": "AgentCEService", + "operation": "GetCmd", + "kind": "readonly", + "description": "获取某个命令推送的详情", + "tags": [ + "命令推送" + ] + }, + { + "method": "AgentCEService.ListCmd", + "service": "AgentCEService", + "operation": "ListCmd", + "kind": "readonly", + "description": "获取命令推送列表", + "tags": [ + "命令推送" + ] + }, + { + "method": "AgentCEService.RerunCe", + "service": "AgentCEService", + "operation": "RerunCe", + "kind": "mutation", + "description": "触发探针重新执行某个命令", + "tags": [ + "命令推送" + ] + }, + { + "method": "AgentCEService.RerunCmd", + "service": "AgentCEService", + "operation": "RerunCmd", + "kind": "mutation", + "description": "重新推送某条命令", + "tags": [ + "命令推送" + ] + }, + { + "method": "AgentFileManageService.CopyFile", + "service": "AgentFileManageService", + "operation": "CopyFile", + "kind": "mutation", + "description": "复制探针文件", + "tags": [ + "文件管理" + ] + }, + { + "method": "AgentFileManageService.CreateDir", + "service": "AgentFileManageService", + "operation": "CreateDir", + "kind": "mutation", + "description": "创建探针目录", + "tags": [ + "文件管理" + ] + }, + { + "method": "AgentFileManageService.CreateFile", + "service": "AgentFileManageService", + "operation": "CreateFile", + "kind": "mutation", + "description": "创建探针文件", + "tags": [ + "文件管理" + ] + }, + { + "method": "AgentFileManageService.DelFile", + "service": "AgentFileManageService", + "operation": "DelFile", + "kind": "mutation", + "description": "删除探针文件", + "tags": [ + "文件管理" + ] + }, + { + "method": "AgentFileManageService.GetHostMaintenanceEnabled", + "service": "AgentFileManageService", + "operation": "GetHostMaintenanceEnabled", + "kind": "readonly", + "description": "获取业务运维功能是否启用", + "tags": [ + "文件管理" + ] + }, + { + "method": "AgentFileManageService.GetWindowsDrive", + "service": "AgentFileManageService", + "operation": "GetWindowsDrive", + "kind": "readonly", + "description": "获取 Windows 探针盘符的盘符项", + "tags": [ + "文件管理" + ] + }, + { + "method": "AgentFileManageService.ListDir", + "service": "AgentFileManageService", + "operation": "ListDir", + "kind": "readonly", + "description": "浏览探针文件", + "tags": [ + "文件管理" + ] + }, + { + "method": "AgentFileManageService.MoveFile", + "service": "AgentFileManageService", + "operation": "MoveFile", + "kind": "mutation", + "description": "移动探针文件", + "tags": [ + "文件管理" + ] + }, + { + "method": "AgentModuleService.GetAgentList", + "service": "AgentModuleService", + "operation": "GetAgentList", + "kind": "readonly", + "description": "获取探针列表", + "tags": [ + "探针模块" + ] + }, + { + "method": "AgentModuleService.OperateModule", + "service": "AgentModuleService", + "operation": "OperateModule", + "kind": "mutation", + "description": "操作探针模块", + "tags": [ + "探针模块" + ] + }, + { + "method": "AgentModuleService.SetLogConfig", + "service": "AgentModuleService", + "operation": "SetLogConfig", + "kind": "mutation", + "description": "设置日志配置", + "tags": [ + "探针模块" + ] + }, + { + "method": "AgentModuleService.SetOverload", + "service": "AgentModuleService", + "operation": "SetOverload", + "kind": "mutation", + "description": "设置自动休眠设置", + "tags": [ + "探针模块" + ] + }, + { + "method": "AgentModuleService.SetResourceLimit", + "service": "AgentModuleService", + "operation": "SetResourceLimit", + "kind": "mutation", + "description": "设置资源配额", + "tags": [ + "探针模块" + ] + }, + { + "method": "AgentService.GetAgentGroupTree", + "service": "AgentService", + "operation": "GetAgentGroupTree", + "kind": "readonly", + "description": "获取业务组", + "tags": [ + "探针管理" + ] + }, + { + "method": "AlertConfigService.List", + "service": "AlertConfigService", + "operation": "List", + "kind": "readonly", + "description": "获取告警信息", + "tags": [ + "告警配置" + ] + }, + { + "method": "AlertConfigService.Update", + "service": "AlertConfigService", + "operation": "Update", + "kind": "mutation", + "description": "更新告警配置", + "tags": [ + "告警配置" + ] + }, + { + "method": "AntiRansomwareService.AddRule", + "service": "AntiRansomwareService", + "operation": "AddRule", + "kind": "mutation", + "description": "添加规则", + "tags": [ + "勒索防护" + ] + }, + { + "method": "AntiRansomwareService.DeleteEvent", + "service": "AntiRansomwareService", + "operation": "DeleteEvent", + "kind": "mutation", + "description": "删除规则", + "tags": [ + "勒索防护" + ] + }, + { + "method": "AntiRansomwareService.DeleteRule", + "service": "AntiRansomwareService", + "operation": "DeleteRule", + "kind": "mutation", + "description": "删除规则", + "tags": [ + "勒索防护" + ] + }, + { + "method": "AntiRansomwareService.DisposeEventList", + "service": "AntiRansomwareService", + "operation": "DisposeEventList", + "kind": "mutation", + "description": "处置事件,如阻断勒索进程、隔离勒索文件", + "tags": [ + "勒索防护" + ] + }, + { + "method": "AntiRansomwareService.EditEventComment", + "service": "AntiRansomwareService", + "operation": "EditEventComment", + "kind": "mutation", + "description": "改变所选事件备注", + "tags": [ + "勒索防护" + ] + }, + { + "method": "AntiRansomwareService.EditEventState", + "service": "AntiRansomwareService", + "operation": "EditEventState", + "kind": "mutation", + "description": "改变所选事件的处置状态", + "tags": [ + "勒索防护" + ] + }, + { + "method": "AntiRansomwareService.EditRule", + "service": "AntiRansomwareService", + "operation": "EditRule", + "kind": "mutation", + "description": "修改防护规则", + "tags": [ + "勒索防护" + ] + }, + { + "method": "AntiRansomwareService.EnableRule", + "service": "AntiRansomwareService", + "operation": "EnableRule", + "kind": "mutation", + "description": "获取规则列表", + "tags": [ + "勒索防护" + ] + }, + { + "method": "AntiRansomwareService.GetDecoyList", + "service": "AntiRansomwareService", + "operation": "GetDecoyList", + "kind": "readonly", + "description": "获取主机诱饵文件列表", + "tags": [ + "勒索防护" + ] + }, + { + "method": "AntiRansomwareService.GetEvent", + "service": "AntiRansomwareService", + "operation": "GetEvent", + "kind": "readonly", + "description": "获取事件详情", + "tags": [ + "勒索防护" + ] + }, + { + "method": "AntiRansomwareService.GetEventList", + "service": "AntiRansomwareService", + "operation": "GetEventList", + "kind": "readonly", + "description": "获取事件列表", + "tags": [ + "勒索防护" + ] + }, + { + "method": "AntiRansomwareService.GetHostList", + "service": "AntiRansomwareService", + "operation": "GetHostList", + "kind": "readonly", + "description": "获取主机防护状态列表", + "tags": [ + "勒索防护" + ] + }, + { + "method": "AntiRansomwareService.GetRule", + "service": "AntiRansomwareService", + "operation": "GetRule", + "kind": "readonly", + "description": "获取规则", + "tags": [ + "勒索防护" + ] + }, + { + "method": "AntiRansomwareService.GetRuleList", + "service": "AntiRansomwareService", + "operation": "GetRuleList", + "kind": "readonly", + "description": "获取规则列表", + "tags": [ + "勒索防护" + ] + }, + { + "method": "AntiRansomwareService.MarkAsRead", + "service": "AntiRansomwareService", + "operation": "MarkAsRead", + "kind": "mutation", + "description": "标记事件为已读", + "tags": [ + "勒索防护" + ] + }, + { + "method": "ApplicationAssetService.DeleteApplication", + "service": "ApplicationAssetService", + "operation": "DeleteApplication", + "kind": "mutation", + "description": "删除软件资产", + "tags": [ + "应用资产" + ] + }, + { + "method": "ApplicationAssetService.GetApplication", + "service": "ApplicationAssetService", + "operation": "GetApplication", + "kind": "readonly", + "description": "获取软件详情信息", + "tags": [ + "应用资产" + ] + }, + { + "method": "ApplicationAssetService.GetApplicationAssetList", + "service": "ApplicationAssetService", + "operation": "GetApplicationAssetList", + "kind": "readonly", + "description": "根据指定条件获取软件资产的列表", + "tags": [ + "应用资产" + ] + }, + { + "method": "ApplicationAssetService.GetApplicationAssetListByApp", + "service": "ApplicationAssetService", + "operation": "GetApplicationAssetListByApp", + "kind": "readonly", + "description": "软件数据分组,根据指定条件获取应用资产的列表", + "tags": [ + "应用资产" + ] + }, + { + "method": "ApplicationAssetService.GetApplicationAssetListByAppVersion", + "service": "ApplicationAssetService", + "operation": "GetApplicationAssetListByAppVersion", + "kind": "readonly", + "description": "软件版本号数据分组,根据指定条件获取软件资产的列表", + "tags": [ + "应用资产" + ] + }, + { + "method": "ApplicationAssetService.GetApplicationAssetListByHost", + "service": "ApplicationAssetService", + "operation": "GetApplicationAssetListByHost", + "kind": "readonly", + "description": "主机分组,根据指定条件获取软件资产的列表", + "tags": [ + "应用资产" + ] + }, + { + "method": "ApplicationAssetService.GetRefreshDateTime", + "service": "ApplicationAssetService", + "operation": "GetRefreshDateTime", + "kind": "readonly", + "description": "获取软件资产的上一次更新时间", + "tags": [ + "应用资产" + ] + }, + { + "method": "ApplicationAssetService.StatApplicationByCategory", + "service": "ApplicationAssetService", + "operation": "StatApplicationByCategory", + "kind": "readonly", + "description": "软件类型数据分组,根据指定条件获取软件资产的列表", + "tags": [ + "应用资产" + ] + }, + { + "method": "AssetCertService.Get", + "service": "AssetCertService", + "operation": "Get", + "kind": "readonly", + "description": "获取主机证书详情", + "tags": [ + "证书资产" + ] + }, + { + "method": "AssetCertService.GetList", + "service": "AssetCertService", + "operation": "GetList", + "kind": "readonly", + "description": "获取主机证书信息", + "tags": [ + "证书资产" + ] + }, + { + "method": "AssetConfigService.CreateWebScanRule", + "service": "AssetConfigService", + "operation": "CreateWebScanRule", + "kind": "mutation", + "description": "创建 Web 自定义路径", + "tags": [ + "资产识别配置" + ] + }, + { + "method": "AssetConfigService.DeleteWebScanRule", + "service": "AssetConfigService", + "operation": "DeleteWebScanRule", + "kind": "mutation", + "description": "删除 Web 自定义路径", + "tags": [ + "资产识别配置" + ] + }, + { + "method": "AssetConfigService.ListAssetCollectConfig", + "service": "AssetConfigService", + "operation": "ListAssetCollectConfig", + "kind": "readonly", + "description": "获取资产采集设置", + "tags": [ + "资产识别配置" + ] + }, + { + "method": "AssetConfigService.ListWebScanRule", + "service": "AssetConfigService", + "operation": "ListWebScanRule", + "kind": "readonly", + "description": "获取 Web 自定义路径列表", + "tags": [ + "资产识别配置" + ] + }, + { + "method": "AssetConfigService.ResetAssetCollectConfig", + "service": "AssetConfigService", + "operation": "ResetAssetCollectConfig", + "kind": "mutation", + "description": "重置资产采集设置", + "tags": [ + "资产识别配置" + ] + }, + { + "method": "AssetConfigService.UpdateAssetCollectConfig", + "service": "AssetConfigService", + "operation": "UpdateAssetCollectConfig", + "kind": "mutation", + "description": "更新资产采集设置", + "tags": [ + "资产识别配置" + ] + }, + { + "method": "AssetConfigService.UpdateWebScanRule", + "service": "AssetConfigService", + "operation": "UpdateWebScanRule", + "kind": "mutation", + "description": "更新 Web 自定义路径", + "tags": [ + "资产识别配置" + ] + }, + { + "method": "AssetCrontabService.GetCrontabList", + "service": "AssetCrontabService", + "operation": "GetCrontabList", + "kind": "readonly", + "description": "获取任务计划列表", + "tags": [ + "计划任务资产" + ] + }, + { + "method": "AssetCrontabService.GetRefreshDateTime", + "service": "AssetCrontabService", + "operation": "GetRefreshDateTime", + "kind": "readonly", + "description": "获取资产的更新时间", + "tags": [ + "计划任务资产" + ] + }, + { + "method": "AssetEnvService.GetList", + "service": "AssetEnvService", + "operation": "GetList", + "kind": "readonly", + "description": "获取主机环境变量信息", + "tags": [ + "环境变量资产" + ] + }, + { + "method": "AssetModuleService.GetModule", + "service": "AssetModuleService", + "operation": "GetModule", + "kind": "readonly", + "description": "获取主机内核模块信息", + "tags": [ + "内核模块" + ] + }, + { + "method": "AssetModuleService.GetModuleList", + "service": "AssetModuleService", + "operation": "GetModuleList", + "kind": "readonly", + "description": "获取主机分区信息列表", + "tags": [ + "内核模块" + ] + }, + { + "method": "AssetModuleService.GetRefreshDateTime", + "service": "AssetModuleService", + "operation": "GetRefreshDateTime", + "kind": "readonly", + "description": "获取资产的更新时间", + "tags": [ + "内核模块" + ] + }, + { + "method": "AssetRegistryService.GetList", + "service": "AssetRegistryService", + "operation": "GetList", + "kind": "readonly", + "description": "获取主机注册表信息", + "tags": [ + "注册表资产" + ] + }, + { + "method": "AssetStartupService.GetList", + "service": "AssetStartupService", + "operation": "GetList", + "kind": "readonly", + "description": "获取主机启动项信息", + "tags": [ + "启动项资产" + ] + }, + { + "method": "AssetStartupService.GetRefreshDateTime", + "service": "AssetStartupService", + "operation": "GetRefreshDateTime", + "kind": "readonly", + "description": "获取启动项资产的更新时间", + "tags": [ + "启动项资产" + ] + }, + { + "method": "BackupService.CreateBackup", + "service": "BackupService", + "operation": "CreateBackup", + "kind": "mutation", + "description": "手动创建备份", + "tags": [ + "配置备份" + ] + }, + { + "method": "BackupService.DeleteHistory", + "service": "BackupService", + "operation": "DeleteHistory", + "kind": "mutation", + "description": "删除备份历史", + "tags": [ + "配置备份" + ] + }, + { + "method": "BackupService.GetConfig", + "service": "BackupService", + "operation": "GetConfig", + "kind": "readonly", + "description": "获取备份配置", + "tags": [ + "配置备份" + ] + }, + { + "method": "BackupService.GetHistory", + "service": "BackupService", + "operation": "GetHistory", + "kind": "readonly", + "description": "获取备份历史详情", + "tags": [ + "配置备份" + ] + }, + { + "method": "BackupService.GetRecovery", + "service": "BackupService", + "operation": "GetRecovery", + "kind": "readonly", + "description": "获取恢复历史详情", + "tags": [ + "配置备份" + ] + }, + { + "method": "BackupService.GetStatus", + "service": "BackupService", + "operation": "GetStatus", + "kind": "readonly", + "description": "获取备份恢复状态", + "tags": [ + "配置备份" + ] + }, + { + "method": "BackupService.ListHistory", + "service": "BackupService", + "operation": "ListHistory", + "kind": "readonly", + "description": "获取备份历史列表", + "tags": [ + "配置备份" + ] + }, + { + "method": "BackupService.RestoreByHistory", + "service": "BackupService", + "operation": "RestoreByHistory", + "kind": "mutation", + "description": "根据备份历史恢复", + "tags": [ + "配置备份" + ] + }, + { + "method": "BackupService.SetConfig", + "service": "BackupService", + "operation": "SetConfig", + "kind": "mutation", + "description": "更新备份配置", + "tags": [ + "配置备份" + ] + }, + { + "method": "BaselineV2Service.BatchUpdateItem", + "service": "BaselineV2Service", + "operation": "BatchUpdateItem", + "kind": "mutation", + "description": "编辑导入的核查项", + "tags": [ + "合规基线" + ] + }, + { + "method": "BaselineV2Service.CopyLogic", + "service": "BaselineV2Service", + "operation": "CopyLogic", + "kind": "mutation", + "description": "复制核查逻辑", + "tags": [ + "合规基线" + ] + }, + { + "method": "BaselineV2Service.CreateCredential", + "service": "BaselineV2Service", + "operation": "CreateCredential", + "kind": "mutation", + "description": "添加凭证", + "tags": [ + "合规基线" + ] + }, + { + "method": "BaselineV2Service.CreateItem", + "service": "BaselineV2Service", + "operation": "CreateItem", + "kind": "mutation", + "description": "导入新的核查项", + "tags": [ + "合规基线" + ] + }, + { + "method": "BaselineV2Service.CreateLogic", + "service": "BaselineV2Service", + "operation": "CreateLogic", + "kind": "mutation", + "description": "快速新增核查逻辑", + "tags": [ + "合规基线" + ] + }, + { + "method": "BaselineV2Service.CreateSet", + "service": "BaselineV2Service", + "operation": "CreateSet", + "kind": "mutation", + "description": "添加策略", + "tags": [ + "合规基线" + ] + }, + { + "method": "BaselineV2Service.CreateTask", + "service": "BaselineV2Service", + "operation": "CreateTask", + "kind": "mutation", + "description": "新建核查任务", + "tags": [ + "合规基线" + ] + }, + { + "method": "BaselineV2Service.CreateWhitelist", + "service": "BaselineV2Service", + "operation": "CreateWhitelist", + "kind": "mutation", + "description": "生成白名单规则", + "tags": [ + "合规基线" + ] + }, + { + "method": "BaselineV2Service.DeleteCredential", + "service": "BaselineV2Service", + "operation": "DeleteCredential", + "kind": "mutation", + "description": "删除凭证", + "tags": [ + "合规基线" + ] + }, + { + "method": "BaselineV2Service.DeleteItem", + "service": "BaselineV2Service", + "operation": "DeleteItem", + "kind": "mutation", + "description": "删除导入的核查项", + "tags": [ + "合规基线" + ] + }, + { + "method": "BaselineV2Service.DeleteLogic", + "service": "BaselineV2Service", + "operation": "DeleteLogic", + "kind": "mutation", + "description": "删除核查逻辑", + "tags": [ + "合规基线" + ] + }, + { + "method": "BaselineV2Service.DeleteSet", + "service": "BaselineV2Service", + "operation": "DeleteSet", + "kind": "mutation", + "description": "删除策略", + "tags": [ + "合规基线" + ] + }, + { + "method": "BaselineV2Service.DeleteSubtask", + "service": "BaselineV2Service", + "operation": "DeleteSubtask", + "kind": "mutation", + "description": "删除核查子任务", + "tags": [ + "合规基线" + ] + }, + { + "method": "BaselineV2Service.DeleteTask", + "service": "BaselineV2Service", + "operation": "DeleteTask", + "kind": "mutation", + "description": "删除核查任务,将会连带删除子任务", + "tags": [ + "合规基线" + ] + }, + { + "method": "BaselineV2Service.EditLogic", + "service": "BaselineV2Service", + "operation": "EditLogic", + "kind": "mutation", + "description": "编辑核查逻辑", + "tags": [ + "合规基线" + ] + }, + { + "method": "BaselineV2Service.EditSubtaskState", + "service": "BaselineV2Service", + "operation": "EditSubtaskState", + "kind": "mutation", + "description": "修改子任务状态", + "tags": [ + "合规基线" + ] + }, + { + "method": "BaselineV2Service.GetAllTag", + "service": "BaselineV2Service", + "operation": "GetAllTag", + "kind": "readonly", + "description": "获取目前用户自定义的所有 Tag", + "tags": [ + "合规基线" + ] + }, + { + "method": "BaselineV2Service.GetCredentialList", + "service": "BaselineV2Service", + "operation": "GetCredentialList", + "kind": "readonly", + "description": "获取凭证列表", + "tags": [ + "合规基线" + ] + }, + { + "method": "BaselineV2Service.GetItemList", + "service": "BaselineV2Service", + "operation": "GetItemList", + "kind": "readonly", + "description": "获取导入的核查项详情", + "tags": [ + "合规基线" + ] + }, + { + "method": "BaselineV2Service.GetLogic", + "service": "BaselineV2Service", + "operation": "GetLogic", + "kind": "readonly", + "description": "获取核查逻辑详细信息,仅应该在用户选择好逻辑后,获取几个具体逻辑的详细信息", + "tags": [ + "合规基线" + ] + }, + { + "method": "BaselineV2Service.GetLogicList", + "service": "BaselineV2Service", + "operation": "GetLogicList", + "kind": "readonly", + "description": "获取核查逻辑列表", + "tags": [ + "合规基线" + ] + }, + { + "method": "BaselineV2Service.GetLogicTags", + "service": "BaselineV2Service", + "operation": "GetLogicTags", + "kind": "readonly", + "description": "获取核查逻辑的所有标签", + "tags": [ + "合规基线" + ] + }, + { + "method": "BaselineV2Service.GetSet", + "service": "BaselineV2Service", + "operation": "GetSet", + "kind": "readonly", + "description": "获取策略信息", + "tags": [ + "合规基线" + ] + }, + { + "method": "BaselineV2Service.GetSetList", + "service": "BaselineV2Service", + "operation": "GetSetList", + "kind": "readonly", + "description": "获取策略列表", + "tags": [ + "合规基线" + ] + }, + { + "method": "BaselineV2Service.GetSubtask", + "service": "BaselineV2Service", + "operation": "GetSubtask", + "kind": "readonly", + "description": "获取核查子任务详情", + "tags": [ + "合规基线" + ] + }, + { + "method": "BaselineV2Service.GetSubtaskList", + "service": "BaselineV2Service", + "operation": "GetSubtaskList", + "kind": "readonly", + "description": "获取核查子任务列表", + "tags": [ + "合规基线" + ] + }, + { + "method": "BaselineV2Service.GetTask", + "service": "BaselineV2Service", + "operation": "GetTask", + "kind": "readonly", + "description": "获取核查任务详情", + "tags": [ + "合规基线" + ] + }, + { + "method": "BaselineV2Service.GetTaskList", + "service": "BaselineV2Service", + "operation": "GetTaskList", + "kind": "readonly", + "description": "获取核查任务列表", + "tags": [ + "合规基线" + ] + }, + { + "method": "BaselineV2Service.GetTaskNameList", + "service": "BaselineV2Service", + "operation": "GetTaskNameList", + "kind": "readonly", + "description": "获取核查任务名称列表", + "tags": [ + "合规基线" + ] + }, + { + "method": "BaselineV2Service.GetTemplateLogic", + "service": "BaselineV2Service", + "operation": "GetTemplateLogic", + "kind": "readonly", + "description": "获取快速自定义核查逻辑详细信息,用于编辑快速自定义之前查询信息", + "tags": [ + "合规基线" + ] + }, + { + "method": "BaselineV2Service.GetWhitelistRequest", + "service": "BaselineV2Service", + "operation": "GetWhitelistRequest", + "kind": "readonly", + "description": "获取白名单规则", + "tags": [ + "合规基线" + ] + }, + { + "method": "BaselineV2Service.StartSubtask", + "service": "BaselineV2Service", + "operation": "StartSubtask", + "kind": "mutation", + "description": "立即进行子任务核查,将会覆盖原有的子任务结果", + "tags": [ + "合规基线" + ] + }, + { + "method": "BaselineV2Service.StartTask", + "service": "BaselineV2Service", + "operation": "StartTask", + "kind": "mutation", + "description": "开始核查任务", + "tags": [ + "合规基线" + ] + }, + { + "method": "BaselineV2Service.StopTask", + "service": "BaselineV2Service", + "operation": "StopTask", + "kind": "mutation", + "description": "停止核查任务", + "tags": [ + "合规基线" + ] + }, + { + "method": "BaselineV2Service.UpdateCredential", + "service": "BaselineV2Service", + "operation": "UpdateCredential", + "kind": "mutation", + "description": "编辑凭证", + "tags": [ + "合规基线" + ] + }, + { + "method": "BaselineV2Service.UpdateItem", + "service": "BaselineV2Service", + "operation": "UpdateItem", + "kind": "mutation", + "description": "编辑导入的核查项", + "tags": [ + "合规基线" + ] + }, + { + "method": "BaselineV2Service.UpdateLogic", + "service": "BaselineV2Service", + "operation": "UpdateLogic", + "kind": "mutation", + "description": "编辑核查逻辑,目前编辑核查逻辑只能编辑其默认参数(json schema 的 default)", + "tags": [ + "合规基线" + ] + }, + { + "method": "BaselineV2Service.UpdateSet", + "service": "BaselineV2Service", + "operation": "UpdateSet", + "kind": "mutation", + "description": "更新策略", + "tags": [ + "合规基线" + ] + }, + { + "method": "BaselineV2Service.UpdateTask", + "service": "BaselineV2Service", + "operation": "UpdateTask", + "kind": "mutation", + "description": "修改任务绑定的核查策略或者任务绑定的探针", + "tags": [ + "合规基线" + ] + }, + { + "method": "BruteForceService.CreateWhitelist", + "service": "BruteForceService", + "operation": "CreateWhitelist", + "kind": "mutation", + "description": "生成白名单规则", + "tags": [ + "暴力破解" + ] + }, + { + "method": "BruteForceService.DeleteEvent", + "service": "BruteForceService", + "operation": "DeleteEvent", + "kind": "mutation", + "description": "删除所选的事件", + "tags": [ + "暴力破解" + ] + }, + { + "method": "BruteForceService.EditEventComment", + "service": "BruteForceService", + "operation": "EditEventComment", + "kind": "mutation", + "description": "改变所选事件备注", + "tags": [ + "暴力破解" + ] + }, + { + "method": "BruteForceService.EditEventState", + "service": "BruteForceService", + "operation": "EditEventState", + "kind": "mutation", + "description": "改变所选事件的处置状态", + "tags": [ + "暴力破解" + ] + }, + { + "method": "BruteForceService.GenerateEventFirewallRule", + "service": "BruteForceService", + "operation": "GenerateEventFirewallRule", + "kind": "mutation", + "description": "根据所选事件生成网络阻断规则", + "tags": [ + "暴力破解" + ] + }, + { + "method": "BruteForceService.GetEvent", + "service": "BruteForceService", + "operation": "GetEvent", + "kind": "readonly", + "description": "获取事件详情", + "tags": [ + "暴力破解" + ] + }, + { + "method": "BruteForceService.GetEventList", + "service": "BruteForceService", + "operation": "GetEventList", + "kind": "readonly", + "description": "获取事件列表", + "tags": [ + "暴力破解" + ] + }, + { + "method": "BruteForceService.GetEventSourceIP", + "service": "BruteForceService", + "operation": "GetEventSourceIP", + "kind": "readonly", + "description": "获取某事件的攻击源 IP", + "tags": [ + "暴力破解" + ] + }, + { + "method": "BruteForceService.GetWhitelist", + "service": "BruteForceService", + "operation": "GetWhitelist", + "kind": "readonly", + "description": "获取白名单规则", + "tags": [ + "暴力破解" + ] + }, + { + "method": "BruteForceService.MarkAsRead", + "service": "BruteForceService", + "operation": "MarkAsRead", + "kind": "mutation", + "description": "标记事件为已读", + "tags": [ + "暴力破解" + ] + }, + { + "method": "BusinessGroupService.CreateBusinessGroup", + "service": "BusinessGroupService", + "operation": "CreateBusinessGroup", + "kind": "mutation", + "description": "新建子业务组", + "tags": [ + "业务组管理" + ] + }, + { + "method": "BusinessGroupService.DeleteBusinessGroup", + "service": "BusinessGroupService", + "operation": "DeleteBusinessGroup", + "kind": "mutation", + "description": "删除业务组", + "tags": [ + "业务组管理" + ] + }, + { + "method": "BusinessGroupService.EditBusinessGroup", + "service": "BusinessGroupService", + "operation": "EditBusinessGroup", + "kind": "mutation", + "description": "编辑业务组", + "tags": [ + "业务组管理" + ] + }, + { + "method": "BusinessGroupService.GetBusinessGroupDetail", + "service": "BusinessGroupService", + "operation": "GetBusinessGroupDetail", + "kind": "readonly", + "description": "获取业务组详情", + "tags": [ + "业务组管理" + ] + }, + { + "method": "BusinessGroupService.GetBusinessGroupTree", + "service": "BusinessGroupService", + "operation": "GetBusinessGroupTree", + "kind": "readonly", + "description": "获取业务组列表", + "tags": [ + "业务组管理" + ] + }, + { + "method": "BusinessGroupService.GetBusinessGroupTreeByOrg", + "service": "BusinessGroupService", + "operation": "GetBusinessGroupTreeByOrg", + "kind": "readonly", + "description": "获取某机构的业务组列表", + "tags": [ + "业务组管理" + ] + }, + { + "method": "CloudWalkerAccountAuthService.CreateRole", + "service": "CloudWalkerAccountAuthService", + "operation": "CreateRole", + "kind": "mutation", + "description": "创建角色", + "tags": [ + "用户角色管理" + ] + }, + { + "method": "CloudWalkerAccountAuthService.CreateUser", + "service": "CloudWalkerAccountAuthService", + "operation": "CreateUser", + "kind": "mutation", + "description": "创建用户", + "tags": [ + "用户角色管理" + ] + }, + { + "method": "CloudWalkerAccountAuthService.DeleteRole", + "service": "CloudWalkerAccountAuthService", + "operation": "DeleteRole", + "kind": "mutation", + "description": "删除角色", + "tags": [ + "用户角色管理" + ] + }, + { + "method": "CloudWalkerAccountAuthService.DeleteUser", + "service": "CloudWalkerAccountAuthService", + "operation": "DeleteUser", + "kind": "mutation", + "description": "删除用户", + "tags": [ + "用户角色管理" + ] + }, + { + "method": "CloudWalkerAccountAuthService.GetRole", + "service": "CloudWalkerAccountAuthService", + "operation": "GetRole", + "kind": "readonly", + "description": "获取角色", + "tags": [ + "用户角色管理" + ] + }, + { + "method": "CloudWalkerAccountAuthService.GetUserInfo", + "service": "CloudWalkerAccountAuthService", + "operation": "GetUserInfo", + "kind": "readonly", + "description": "获取用户信息详情", + "tags": [ + "用户角色管理" + ] + }, + { + "method": "CloudWalkerAccountAuthService.ListRole", + "service": "CloudWalkerAccountAuthService", + "operation": "ListRole", + "kind": "readonly", + "description": "获取角色列表", + "tags": [ + "用户角色管理" + ] + }, + { + "method": "CloudWalkerAccountAuthService.ListUser", + "service": "CloudWalkerAccountAuthService", + "operation": "ListUser", + "kind": "readonly", + "description": "获取用户列表", + "tags": [ + "用户角色管理" + ] + }, + { + "method": "CloudWalkerAccountAuthService.UpdateRole", + "service": "CloudWalkerAccountAuthService", + "operation": "UpdateRole", + "kind": "mutation", + "description": "更新角色", + "tags": [ + "用户角色管理" + ] + }, + { + "method": "CloudWalkerAccountAuthService.UpdateUser", + "service": "CloudWalkerAccountAuthService", + "operation": "UpdateUser", + "kind": "mutation", + "description": "更新用户", + "tags": [ + "用户角色管理" + ] + }, + { + "method": "CloudWalkerAccountAuthService.UpdateUserEnable", + "service": "CloudWalkerAccountAuthService", + "operation": "UpdateUserEnable", + "kind": "mutation", + "description": "启用/禁用用户", + "tags": [ + "用户角色管理" + ] + }, + { + "method": "CloudwalkerSettingService.ExternalLink", + "service": "CloudwalkerSettingService", + "operation": "ExternalLink", + "kind": "mutation", + "description": "外部连接", + "tags": [ + "牧云设置" + ] + }, + { + "method": "CloudwalkerSettingService.ExternalLinkRedirect", + "service": "CloudwalkerSettingService", + "operation": "ExternalLinkRedirect", + "kind": "mutation", + "description": "外部连接重定向", + "tags": [ + "牧云设置" + ] + }, + { + "method": "CloudwalkerSettingService.GetCurrentLanguage", + "service": "CloudwalkerSettingService", + "operation": "GetCurrentLanguage", + "kind": "readonly", + "description": "获取当前语言", + "tags": [ + "牧云设置" + ] + }, + { + "method": "CloudwalkerSettingService.GetCurrentTime", + "service": "CloudwalkerSettingService", + "operation": "GetCurrentTime", + "kind": "readonly", + "description": "获取当前系统时间", + "tags": [ + "系统配置" + ] + }, + { + "method": "CloudwalkerSettingService.GetDisabledFeature", + "service": "CloudwalkerSettingService", + "operation": "GetDisabledFeature", + "kind": "readonly", + "description": "获取被禁用的功能", + "tags": [ + "系统配置" + ] + }, + { + "method": "CloudwalkerSettingService.GetInstallationInfo", + "service": "CloudwalkerSettingService", + "operation": "GetInstallationInfo", + "kind": "readonly", + "description": "获取安装信息", + "tags": [ + "牧云设置" + ] + }, + { + "method": "CloudwalkerSettingService.GetProductInfo", + "service": "CloudwalkerSettingService", + "operation": "GetProductInfo", + "kind": "readonly", + "description": "获取产品信息", + "tags": [ + "牧云设置" + ] + }, + { + "method": "CloudwalkerSettingService.GetPublicKey", + "service": "CloudwalkerSettingService", + "operation": "GetPublicKey", + "kind": "readonly", + "description": "获取服务端公钥", + "tags": [ + "系统配置" + ] + }, + { + "method": "CloudwalkerSettingService.SetProductInfo", + "service": "CloudwalkerSettingService", + "operation": "SetProductInfo", + "kind": "mutation", + "description": "设置产品相关信息", + "tags": [ + "牧云设置" + ] + }, + { + "method": "CrontabService.CreatePlan", + "service": "CrontabService", + "operation": "CreatePlan", + "kind": "mutation", + "description": "创建任务计划", + "tags": [ + "任务计划" + ] + }, + { + "method": "CrontabService.CreateWeakPasswordPlan", + "service": "CrontabService", + "operation": "CreateWeakPasswordPlan", + "kind": "mutation", + "description": "创建弱口令任务计划", + "tags": [ + "任务计划" + ] + }, + { + "method": "CrontabService.DeletePlan", + "service": "CrontabService", + "operation": "DeletePlan", + "kind": "mutation", + "description": "删除任务计划", + "tags": [ + "任务计划" + ] + }, + { + "method": "CrontabService.EditPlan", + "service": "CrontabService", + "operation": "EditPlan", + "kind": "mutation", + "description": "修改任务计划", + "tags": [ + "任务计划" + ] + }, + { + "method": "CrontabService.EditWeakPasswordPlan", + "service": "CrontabService", + "operation": "EditWeakPasswordPlan", + "kind": "mutation", + "description": "修改弱口令任务计划", + "tags": [ + "任务计划" + ] + }, + { + "method": "CrontabService.GetPlan", + "service": "CrontabService", + "operation": "GetPlan", + "kind": "readonly", + "description": "获取任务计划详情", + "tags": [ + "任务计划" + ] + }, + { + "method": "CrontabService.GetPlanList", + "service": "CrontabService", + "operation": "GetPlanList", + "kind": "readonly", + "description": "获取任务计划列表", + "tags": [ + "任务计划" + ] + }, + { + "method": "CrontabService.GetPlanLog", + "service": "CrontabService", + "operation": "GetPlanLog", + "kind": "readonly", + "description": "获取任务计划日志", + "tags": [ + "任务计划" + ] + }, + { + "method": "CrontabService.GetTaskResult", + "service": "CrontabService", + "operation": "GetTaskResult", + "kind": "readonly", + "description": "获取任务计划结果", + "tags": [ + "任务计划" + ] + }, + { + "method": "CrontabService.GetWeakPasswordPlan", + "service": "CrontabService", + "operation": "GetWeakPasswordPlan", + "kind": "readonly", + "description": "获取弱口令任务计划详情", + "tags": [ + "任务计划" + ] + }, + { + "method": "CrontabService.GetWeakPasswordPlanList", + "service": "CrontabService", + "operation": "GetWeakPasswordPlanList", + "kind": "readonly", + "description": "获取弱口令任务计划列表", + "tags": [ + "任务计划" + ] + }, + { + "method": "CrontabService.GetWeakPasswordTaskConfig", + "service": "CrontabService", + "operation": "GetWeakPasswordTaskConfig", + "kind": "readonly", + "description": "获取弱口令任务默认配置", + "tags": [ + "任务计划" + ] + }, + { + "method": "CrontabService.RetryTaskByPlan", + "service": "CrontabService", + "operation": "RetryTaskByPlan", + "kind": "mutation", + "description": "重试任务计划中的失败的任务", + "tags": [ + "任务计划" + ] + }, + { + "method": "CrontabService.SetPlanEnablement", + "service": "CrontabService", + "operation": "SetPlanEnablement", + "kind": "mutation", + "description": "设置任务计划自动执行", + "tags": [ + "任务计划" + ] + }, + { + "method": "CrontabService.StartPlan", + "service": "CrontabService", + "operation": "StartPlan", + "kind": "mutation", + "description": "手动开始任务计划", + "tags": [ + "任务计划" + ] + }, + { + "method": "CrontabService.StopPlan", + "service": "CrontabService", + "operation": "StopPlan", + "kind": "mutation", + "description": "手动停止执行中的任务计划", + "tags": [ + "任务计划" + ] + }, + { + "method": "DetectionRuleService.CreateAbnormalLoginRule", + "service": "DetectionRuleService", + "operation": "CreateAbnormalLoginRule", + "kind": "mutation", + "description": "创建异常登陆检测规则", + "tags": [ + "入侵检测配置" + ] + }, + { + "method": "DetectionRuleService.CreateHoneypotRule", + "service": "DetectionRuleService", + "operation": "CreateHoneypotRule", + "kind": "mutation", + "description": "创建蜜罐诱捕检测规则", + "tags": [ + "入侵检测配置" + ] + }, + { + "method": "DetectionRuleService.CreateNetworkAuditRule", + "service": "DetectionRuleService", + "operation": "CreateNetworkAuditRule", + "kind": "mutation", + "description": "创建网络异常检测规则", + "tags": [ + "入侵检测配置" + ] + }, + { + "method": "DetectionRuleService.CreateSensitiveFileRule", + "service": "DetectionRuleService", + "operation": "CreateSensitiveFileRule", + "kind": "mutation", + "description": "创建敏感文件检测规则", + "tags": [ + "入侵检测配置" + ] + }, + { + "method": "DetectionRuleService.CreateSensitivePortRule", + "service": "DetectionRuleService", + "operation": "CreateSensitivePortRule", + "kind": "mutation", + "description": "创建敏感端口检测规则", + "tags": [ + "入侵检测配置" + ] + }, + { + "method": "DetectionRuleService.CreateSuspiciousOperationRule", + "service": "DetectionRuleService", + "operation": "CreateSuspiciousOperationRule", + "kind": "mutation", + "description": "创建可疑命令检测规则", + "tags": [ + "入侵检测配置" + ] + }, + { + "method": "DetectionRuleService.DeleteAbnormalLoginRule", + "service": "DetectionRuleService", + "operation": "DeleteAbnormalLoginRule", + "kind": "mutation", + "description": "删除异常登陆检测规则", + "tags": [ + "入侵检测配置" + ] + }, + { + "method": "DetectionRuleService.DeleteHoneypotRule", + "service": "DetectionRuleService", + "operation": "DeleteHoneypotRule", + "kind": "mutation", + "description": "删除蜜罐诱捕检测规则", + "tags": [ + "入侵检测配置" + ] + }, + { + "method": "DetectionRuleService.DeleteNetworkAuditRule", + "service": "DetectionRuleService", + "operation": "DeleteNetworkAuditRule", + "kind": "mutation", + "description": "删除网络异常检测规则", + "tags": [ + "入侵检测配置" + ] + }, + { + "method": "DetectionRuleService.DeleteSensitiveFileRule", + "service": "DetectionRuleService", + "operation": "DeleteSensitiveFileRule", + "kind": "mutation", + "description": "删除敏感文件检测规则", + "tags": [ + "入侵检测配置" + ] + }, + { + "method": "DetectionRuleService.DeleteSensitivePortRule", + "service": "DetectionRuleService", + "operation": "DeleteSensitivePortRule", + "kind": "mutation", + "description": "删除敏感端口检测规则", + "tags": [ + "入侵检测配置" + ] + }, + { + "method": "DetectionRuleService.DeleteSuspiciousOperationRule", + "service": "DetectionRuleService", + "operation": "DeleteSuspiciousOperationRule", + "kind": "mutation", + "description": "删除可疑命令检测规则", + "tags": [ + "入侵检测配置" + ] + }, + { + "method": "DetectionRuleService.EnablementAbnormalLoginRule", + "service": "DetectionRuleService", + "operation": "EnablementAbnormalLoginRule", + "kind": "mutation", + "description": "启禁用异常登录检测规则", + "tags": [ + "入侵检测配置" + ] + }, + { + "method": "DetectionRuleService.EnablementHoneypotRule", + "service": "DetectionRuleService", + "operation": "EnablementHoneypotRule", + "kind": "mutation", + "description": "启禁用蜜罐诱捕检测规则", + "tags": [ + "入侵检测配置" + ] + }, + { + "method": "DetectionRuleService.EnablementNetworkAuditRule", + "service": "DetectionRuleService", + "operation": "EnablementNetworkAuditRule", + "kind": "mutation", + "description": "启禁用网络异常检测规则", + "tags": [ + "入侵检测配置" + ] + }, + { + "method": "DetectionRuleService.EnablementSensitiveFileRule", + "service": "DetectionRuleService", + "operation": "EnablementSensitiveFileRule", + "kind": "mutation", + "description": "启禁用敏感文件检测规则", + "tags": [ + "入侵检测配置" + ] + }, + { + "method": "DetectionRuleService.EnablementSensitivePortRule", + "service": "DetectionRuleService", + "operation": "EnablementSensitivePortRule", + "kind": "mutation", + "description": "启禁用敏感端口检测规则", + "tags": [ + "入侵检测配置" + ] + }, + { + "method": "DetectionRuleService.EnablementSuspiciousOperationRule", + "service": "DetectionRuleService", + "operation": "EnablementSuspiciousOperationRule", + "kind": "mutation", + "description": "启禁用可疑命令检测规则", + "tags": [ + "入侵检测配置" + ] + }, + { + "method": "DetectionRuleService.GetBruteForceAdvCfg", + "service": "DetectionRuleService", + "operation": "GetBruteForceAdvCfg", + "kind": "readonly", + "description": "获取暴力破解高级配置", + "tags": [ + "入侵检测配置" + ] + }, + { + "method": "DetectionRuleService.GetHoneypotRule", + "service": "DetectionRuleService", + "operation": "GetHoneypotRule", + "kind": "readonly", + "description": "获取蜜罐诱捕检测规则详情", + "tags": [ + "入侵检测配置" + ] + }, + { + "method": "DetectionRuleService.GetMaliciousFileAdvCfg", + "service": "DetectionRuleService", + "operation": "GetMaliciousFileAdvCfg", + "kind": "readonly", + "description": "获取恶意文件高级配置", + "tags": [ + "入侵检测配置" + ] + }, + { + "method": "DetectionRuleService.GetNetworkAuditAdvCfg", + "service": "DetectionRuleService", + "operation": "GetNetworkAuditAdvCfg", + "kind": "readonly", + "description": "获取网络异常高级配置", + "tags": [ + "入侵检测配置" + ] + }, + { + "method": "DetectionRuleService.GetSuspiciousOperationAdvCfg", + "service": "DetectionRuleService", + "operation": "GetSuspiciousOperationAdvCfg", + "kind": "readonly", + "description": "获取可疑命令高级配置", + "tags": [ + "入侵检测配置" + ] + }, + { + "method": "DetectionRuleService.GetWebshellAdvCfg", + "service": "DetectionRuleService", + "operation": "GetWebshellAdvCfg", + "kind": "readonly", + "description": "获取 Webshell 高级配置", + "tags": [ + "入侵检测配置" + ] + }, + { + "method": "DetectionRuleService.ListAbnormalLoginRule", + "service": "DetectionRuleService", + "operation": "ListAbnormalLoginRule", + "kind": "readonly", + "description": "获取异常登陆检测规则列表", + "tags": [ + "入侵检测配置" + ] + }, + { + "method": "DetectionRuleService.ListHoneypotRule", + "service": "DetectionRuleService", + "operation": "ListHoneypotRule", + "kind": "readonly", + "description": "获取蜜罐诱捕检测规则列表", + "tags": [ + "入侵检测配置" + ] + }, + { + "method": "DetectionRuleService.ListNetworkAuditRule", + "service": "DetectionRuleService", + "operation": "ListNetworkAuditRule", + "kind": "readonly", + "description": "获取网络异常检测规则列表", + "tags": [ + "入侵检测配置" + ] + }, + { + "method": "DetectionRuleService.ListSensitiveFileRule", + "service": "DetectionRuleService", + "operation": "ListSensitiveFileRule", + "kind": "readonly", + "description": "获取敏感文件检测规则列表", + "tags": [ + "入侵检测配置" + ] + }, + { + "method": "DetectionRuleService.ListSensitivePortRule", + "service": "DetectionRuleService", + "operation": "ListSensitivePortRule", + "kind": "readonly", + "description": "获取敏感端口检测规则列表", + "tags": [ + "入侵检测配置" + ] + }, + { + "method": "DetectionRuleService.ListSuspiciousOperationRule", + "service": "DetectionRuleService", + "operation": "ListSuspiciousOperationRule", + "kind": "readonly", + "description": "获取可疑命令检测规则列表", + "tags": [ + "入侵检测配置" + ] + }, + { + "method": "DetectionRuleService.SetBruteForceAdvCfg", + "service": "DetectionRuleService", + "operation": "SetBruteForceAdvCfg", + "kind": "mutation", + "description": "设置暴力破解高级配置", + "tags": [ + "入侵检测配置" + ] + }, + { + "method": "DetectionRuleService.SetMaliciousFileAdvCfg", + "service": "DetectionRuleService", + "operation": "SetMaliciousFileAdvCfg", + "kind": "mutation", + "description": "设置恶意文件高级配置", + "tags": [ + "入侵检测配置" + ] + }, + { + "method": "DetectionRuleService.SetNetworkAuditAdvCfg", + "service": "DetectionRuleService", + "operation": "SetNetworkAuditAdvCfg", + "kind": "mutation", + "description": "设置网络异常高级配置", + "tags": [ + "入侵检测配置" + ] + }, + { + "method": "DetectionRuleService.SetSuspiciousOperationAdvCfg", + "service": "DetectionRuleService", + "operation": "SetSuspiciousOperationAdvCfg", + "kind": "mutation", + "description": "设置可疑命令高级配置", + "tags": [ + "入侵检测配置" + ] + }, + { + "method": "DetectionRuleService.SetWebshellAdvCfg", + "service": "DetectionRuleService", + "operation": "SetWebshellAdvCfg", + "kind": "mutation", + "description": "设置 Webshell 高级配置", + "tags": [ + "入侵检测配置" + ] + }, + { + "method": "DetectionRuleService.UpdateAbnormalLoginRule", + "service": "DetectionRuleService", + "operation": "UpdateAbnormalLoginRule", + "kind": "mutation", + "description": "更新异常登陆检测规则", + "tags": [ + "入侵检测配置" + ] + }, + { + "method": "DetectionRuleService.UpdateHoneypotRule", + "service": "DetectionRuleService", + "operation": "UpdateHoneypotRule", + "kind": "mutation", + "description": "更新蜜罐诱捕检测规则", + "tags": [ + "入侵检测配置" + ] + }, + { + "method": "DetectionRuleService.UpdateNetworkAuditRule", + "service": "DetectionRuleService", + "operation": "UpdateNetworkAuditRule", + "kind": "mutation", + "description": "更新网络异常检测规则", + "tags": [ + "入侵检测配置" + ] + }, + { + "method": "DetectionRuleService.UpdateSensitiveFileRule", + "service": "DetectionRuleService", + "operation": "UpdateSensitiveFileRule", + "kind": "mutation", + "description": "更新敏感文件检测规则", + "tags": [ + "入侵检测配置" + ] + }, + { + "method": "DetectionRuleService.UpdateSensitivePortRule", + "service": "DetectionRuleService", + "operation": "UpdateSensitivePortRule", + "kind": "mutation", + "description": "更新敏感端口检测规则", + "tags": [ + "入侵检测配置" + ] + }, + { + "method": "DetectionRuleService.UpdateSuspiciousOperationRule", + "service": "DetectionRuleService", + "operation": "UpdateSuspiciousOperationRule", + "kind": "mutation", + "description": "更新可疑命令检测规则", + "tags": [ + "入侵检测配置" + ] + }, + { + "method": "DockerContainerService.GetContainer", + "service": "DockerContainerService", + "operation": "GetContainer", + "kind": "readonly", + "description": "获取Docker容器资产详情", + "tags": [ + "Docker资产" + ] + }, + { + "method": "DockerContainerService.GetContainerList", + "service": "DockerContainerService", + "operation": "GetContainerList", + "kind": "readonly", + "description": "获取Docker容器资产列表", + "tags": [ + "Docker资产" + ] + }, + { + "method": "DockerContainerService.StatContainer", + "service": "DockerContainerService", + "operation": "StatContainer", + "kind": "readonly", + "description": "获取Docker容器统计结果", + "tags": [ + "Docker资产" + ] + }, + { + "method": "DockerImageService.GetImage", + "service": "DockerImageService", + "operation": "GetImage", + "kind": "readonly", + "description": "获取Docker镜像资产详情", + "tags": [ + "Docker资产" + ] + }, + { + "method": "DockerImageService.GetImageList", + "service": "DockerImageService", + "operation": "GetImageList", + "kind": "readonly", + "description": "获取Docker镜像资产列表", + "tags": [ + "Docker资产" + ] + }, + { + "method": "DockerImageService.StatImage", + "service": "DockerImageService", + "operation": "StatImage", + "kind": "readonly", + "description": "获取Docker镜像统计", + "tags": [ + "Docker资产" + ] + }, + { + "method": "DockerNetworkService.GetNetwork", + "service": "DockerNetworkService", + "operation": "GetNetwork", + "kind": "readonly", + "description": "获取Docker网络资产详情", + "tags": [ + "Docker资产" + ] + }, + { + "method": "DockerNetworkService.GetNetworkList", + "service": "DockerNetworkService", + "operation": "GetNetworkList", + "kind": "readonly", + "description": "获取Docker网络资产列表", + "tags": [ + "Docker资产" + ] + }, + { + "method": "DockerNetworkService.StatNetwork", + "service": "DockerNetworkService", + "operation": "StatNetwork", + "kind": "readonly", + "description": "获取Docker网络资产列表", + "tags": [ + "Docker资产" + ] + }, + { + "method": "ElevationProcessEventService.CreateWhitelist", + "service": "ElevationProcessEventService", + "operation": "CreateWhitelist", + "kind": "mutation", + "description": "生成白名单规则", + "tags": [ + "本地提权" + ] + }, + { + "method": "ElevationProcessEventService.DeleteEvent", + "service": "ElevationProcessEventService", + "operation": "DeleteEvent", + "kind": "mutation", + "description": "删除所选的事件", + "tags": [ + "本地提权" + ] + }, + { + "method": "ElevationProcessEventService.EditEventComment", + "service": "ElevationProcessEventService", + "operation": "EditEventComment", + "kind": "mutation", + "description": "改变所选事件备注", + "tags": [ + "本地提权" + ] + }, + { + "method": "ElevationProcessEventService.EditEventState", + "service": "ElevationProcessEventService", + "operation": "EditEventState", + "kind": "mutation", + "description": "改变所选事件的处置状态", + "tags": [ + "本地提权" + ] + }, + { + "method": "ElevationProcessEventService.GetEvent", + "service": "ElevationProcessEventService", + "operation": "GetEvent", + "kind": "readonly", + "description": "获取事件详情", + "tags": [ + "本地提权" + ] + }, + { + "method": "ElevationProcessEventService.GetEventList", + "service": "ElevationProcessEventService", + "operation": "GetEventList", + "kind": "readonly", + "description": "获取事件列表", + "tags": [ + "本地提权" + ] + }, + { + "method": "ElevationProcessEventService.GetEventListByExename", + "service": "ElevationProcessEventService", + "operation": "GetEventListByExename", + "kind": "readonly", + "description": "返回按进程聚合的事件列表", + "tags": [ + "本地提权" + ] + }, + { + "method": "ElevationProcessEventService.GetEventListByHost", + "service": "ElevationProcessEventService", + "operation": "GetEventListByHost", + "kind": "readonly", + "description": "返回按主机聚合的事件列表", + "tags": [ + "本地提权" + ] + }, + { + "method": "ElevationProcessEventService.GetEventListByOriginEffectUser", + "service": "ElevationProcessEventService", + "operation": "GetEventListByOriginEffectUser", + "kind": "readonly", + "description": "返回按原有效用户聚合的事件列表", + "tags": [ + "本地提权" + ] + }, + { + "method": "ElevationProcessEventService.GetWhitelist", + "service": "ElevationProcessEventService", + "operation": "GetWhitelist", + "kind": "readonly", + "description": "获取白名单规则", + "tags": [ + "本地提权" + ] + }, + { + "method": "ElevationProcessEventService.MarkAsRead", + "service": "ElevationProcessEventService", + "operation": "MarkAsRead", + "kind": "mutation", + "description": "标记事件为已读", + "tags": [ + "本地提权" + ] + }, + { + "method": "ElevationProcessEventService.ProcessKill", + "service": "ElevationProcessEventService", + "operation": "ProcessKill", + "kind": "mutation", + "description": "删除所选的事件", + "tags": [ + "本地提权" + ] + }, + { + "method": "ElevationProcessEventService.StatEventByExename", + "service": "ElevationProcessEventService", + "operation": "StatEventByExename", + "kind": "readonly", + "description": "获取按进程名聚合的统计结果", + "tags": [ + "本地提权" + ] + }, + { + "method": "ElevationProcessEventService.StatEventByHost", + "service": "ElevationProcessEventService", + "operation": "StatEventByHost", + "kind": "readonly", + "description": "获取按主机聚合的统计结果", + "tags": [ + "本地提权" + ] + }, + { + "method": "ElevationProcessEventService.StatEventByLevel", + "service": "ElevationProcessEventService", + "operation": "StatEventByLevel", + "kind": "readonly", + "description": "获取按事件风险等级聚合的统计结果", + "tags": [ + "本地提权" + ] + }, + { + "method": "ElevationProcessEventService.StatEventByOriginEffectUser", + "service": "ElevationProcessEventService", + "operation": "StatEventByOriginEffectUser", + "kind": "readonly", + "description": "获取按提权原有效用户聚合的统计结果", + "tags": [ + "本地提权" + ] + }, + { + "method": "ElevationProcessEventService.StatEventByState", + "service": "ElevationProcessEventService", + "operation": "StatEventByState", + "kind": "readonly", + "description": "获取按处置状态聚合的统计结果", + "tags": [ + "本地提权" + ] + }, + { + "method": "ElevationProcessEventService.StatEventByTime", + "service": "ElevationProcessEventService", + "operation": "StatEventByTime", + "kind": "readonly", + "description": "获取按事件发生时间聚合的统计结果", + "tags": [ + "本地提权" + ] + }, + { + "method": "ElevationProcessEventService.StatEventByType", + "service": "ElevationProcessEventService", + "operation": "StatEventByType", + "kind": "readonly", + "description": "获取按提权类型聚合的统计结果", + "tags": [ + "本地提权" + ] + }, + { + "method": "EmergencyVulnService.CancelIgnoreEventTask", + "service": "EmergencyVulnService", + "operation": "CancelIgnoreEventTask", + "kind": "mutation", + "description": "取消忽略漏洞事件", + "tags": [ + "漏洞应急" + ] + }, + { + "method": "EmergencyVulnService.CreateEventTask", + "service": "EmergencyVulnService", + "operation": "CreateEventTask", + "kind": "mutation", + "description": "漏洞应急事件复测", + "tags": [ + "漏洞应急" + ] + }, + { + "method": "EmergencyVulnService.CreateVulnTask", + "service": "EmergencyVulnService", + "operation": "CreateVulnTask", + "kind": "mutation", + "description": "创建漏洞应急扫描任务", + "tags": [ + "漏洞应急" + ] + }, + { + "method": "EmergencyVulnService.GetVulnInfo", + "service": "EmergencyVulnService", + "operation": "GetVulnInfo", + "kind": "readonly", + "description": "漏洞详细信息", + "tags": [ + "漏洞应急" + ] + }, + { + "method": "EmergencyVulnService.IgnoreEventTask", + "service": "EmergencyVulnService", + "operation": "IgnoreEventTask", + "kind": "mutation", + "description": "忽略漏洞事件", + "tags": [ + "漏洞应急" + ] + }, + { + "method": "EmergencyVulnService.ListVuln", + "service": "EmergencyVulnService", + "operation": "ListVuln", + "kind": "readonly", + "description": "漏洞应急列表页", + "tags": [ + "漏洞应急" + ] + }, + { + "method": "EmergencyVulnService.ListVulnEvent", + "service": "EmergencyVulnService", + "operation": "ListVulnEvent", + "kind": "readonly", + "description": "漏洞应急事件列表页", + "tags": [ + "漏洞应急" + ] + }, + { + "method": "EndpointService.ApplyConfig", + "service": "EndpointService", + "operation": "ApplyConfig", + "kind": "mutation", + "description": "应用配置变更响应", + "tags": [ + "其他系统设置" + ] + }, + { + "method": "EndpointService.EditAgentConfig", + "service": "EndpointService", + "operation": "EditAgentConfig", + "kind": "mutation", + "description": "修改探针连接配置", + "tags": [ + "Endpoint" + ] + }, + { + "method": "EndpointService.EditAgentPort", + "service": "EndpointService", + "operation": "EditAgentPort", + "kind": "mutation", + "description": "修改探针端口配置参数", + "tags": [ + "其他系统设置" + ] + }, + { + "method": "EndpointService.EditServerAddress", + "service": "EndpointService", + "operation": "EditServerAddress", + "kind": "mutation", + "description": "修改服务端地址(当前仅用于判断连接方式)", + "tags": [ + "其他系统设置" + ] + }, + { + "method": "EndpointService.EditWebPort", + "service": "EndpointService", + "operation": "EditWebPort", + "kind": "mutation", + "description": "修改服务端端口配置参数", + "tags": [ + "其他系统设置" + ] + }, + { + "method": "EndpointService.GetServerAddress", + "service": "EndpointService", + "operation": "GetServerAddress", + "kind": "readonly", + "description": "获取服务端地址(当前仅用于判断连接方式)", + "tags": [ + "其他系统设置" + ] + }, + { + "method": "EndpointService.GetWebConfig", + "service": "EndpointService", + "operation": "GetWebConfig", + "kind": "readonly", + "description": "获取web管理配置参数", + "tags": [ + "其他系统设置" + ] + }, + { + "method": "EndpointService.ListServerCert", + "service": "EndpointService", + "operation": "ListServerCert", + "kind": "readonly", + "description": "获取证书列表参数", + "tags": [ + "其他系统设置" + ] + }, + { + "method": "EndpointService.SwitchServerCert", + "service": "EndpointService", + "operation": "SwitchServerCert", + "kind": "mutation", + "description": "切换服务端证书参数", + "tags": [ + "其他系统设置" + ] + }, + { + "method": "FileDisposalService.Isolation", + "service": "FileDisposalService", + "operation": "Isolation", + "kind": "mutation", + "description": "文件隔离或者文件信任", + "tags": [ + "FileIsolation" + ] + }, + { + "method": "FileDisposalService.ListEvent", + "service": "FileDisposalService", + "operation": "ListEvent", + "kind": "readonly", + "description": "获取事件列表", + "tags": [ + "FileIsolation" + ] + }, + { + "method": "FirewallService.CreateRule", + "service": "FirewallService", + "operation": "CreateRule", + "kind": "mutation", + "description": "添加规则", + "tags": [ + "防火墙" + ] + }, + { + "method": "FirewallService.DeleteEvent", + "service": "FirewallService", + "operation": "DeleteEvent", + "kind": "mutation", + "description": "删除事件", + "tags": [ + "防火墙" + ] + }, + { + "method": "FirewallService.DeleteRule", + "service": "FirewallService", + "operation": "DeleteRule", + "kind": "mutation", + "description": "删除规则", + "tags": [ + "防火墙" + ] + }, + { + "method": "FirewallService.GetEventList", + "service": "FirewallService", + "operation": "GetEventList", + "kind": "readonly", + "description": "获取事件列表", + "tags": [ + "防火墙" + ] + }, + { + "method": "FirewallService.GetFirewallGlobalConfig", + "service": "FirewallService", + "operation": "GetFirewallGlobalConfig", + "kind": "readonly", + "description": "获取防火墙全局配置", + "tags": [ + "防火墙" + ] + }, + { + "method": "FirewallService.GetRule", + "service": "FirewallService", + "operation": "GetRule", + "kind": "readonly", + "description": "获取规则列表", + "tags": [ + "防火墙" + ] + }, + { + "method": "FirewallService.SetFirewallGlobalConfig", + "service": "FirewallService", + "operation": "SetFirewallGlobalConfig", + "kind": "mutation", + "description": "设置防火墙全局配置", + "tags": [ + "防火墙" + ] + }, + { + "method": "FirewallService.UpdateRule", + "service": "FirewallService", + "operation": "UpdateRule", + "kind": "mutation", + "description": "修改规则", + "tags": [ + "防火墙" + ] + }, + { + "method": "FullCommandService.Delete", + "service": "FullCommandService", + "operation": "Delete", + "kind": "mutation", + "description": "删除命令", + "tags": [ + "全量命令" + ] + }, + { + "method": "FullCommandService.EditEventComment", + "service": "FullCommandService", + "operation": "EditEventComment", + "kind": "mutation", + "description": "改变所选事件备注", + "tags": [ + "全量命令" + ] + }, + { + "method": "FullCommandService.Get", + "service": "FullCommandService", + "operation": "Get", + "kind": "readonly", + "description": "获取命令详情", + "tags": [ + "全量命令" + ] + }, + { + "method": "FullCommandService.GetList", + "service": "FullCommandService", + "operation": "GetList", + "kind": "readonly", + "description": "获取命令列表", + "tags": [ + "全量命令" + ] + }, + { + "method": "FullCommandService.GetListByHost", + "service": "FullCommandService", + "operation": "GetListByHost", + "kind": "readonly", + "description": "获取按主机聚合的事件列表", + "tags": [ + "全量命令" + ] + }, + { + "method": "FullCommandService.GetListByNetApp", + "service": "FullCommandService", + "operation": "GetListByNetApp", + "kind": "readonly", + "description": "获取按网络应用聚合的事件列表", + "tags": [ + "全量命令" + ] + }, + { + "method": "FullCommandService.GetListBySSHClientIP", + "service": "FullCommandService", + "operation": "GetListBySSHClientIP", + "kind": "readonly", + "description": "获取按SSH登录聚合的事件列表", + "tags": [ + "全量命令" + ] + }, + { + "method": "HoneypotService.CreateWhitelist", + "service": "HoneypotService", + "operation": "CreateWhitelist", + "kind": "mutation", + "description": "生成白名单规则", + "tags": [ + "蜜罐诱捕" + ] + }, + { + "method": "HoneypotService.DeleteEvent", + "service": "HoneypotService", + "operation": "DeleteEvent", + "kind": "mutation", + "description": "删除所选 ID 的事件", + "tags": [ + "蜜罐诱捕" + ] + }, + { + "method": "HoneypotService.DeleteEventByHoneypot", + "service": "HoneypotService", + "operation": "DeleteEventByHoneypot", + "kind": "mutation", + "description": "删除所选蜜罐名的事件", + "tags": [ + "蜜罐诱捕" + ] + }, + { + "method": "HoneypotService.DeleteEventByHost", + "service": "HoneypotService", + "operation": "DeleteEventByHost", + "kind": "mutation", + "description": "删除所选主机 ID 的事件", + "tags": [ + "蜜罐诱捕" + ] + }, + { + "method": "HoneypotService.DeleteEventBySrcIP", + "service": "HoneypotService", + "operation": "DeleteEventBySrcIP", + "kind": "mutation", + "description": "删除所选源 IP 的事件", + "tags": [ + "蜜罐诱捕" + ] + }, + { + "method": "HoneypotService.EditEventComment", + "service": "HoneypotService", + "operation": "EditEventComment", + "kind": "mutation", + "description": "改变所选事件备注", + "tags": [ + "蜜罐诱捕" + ] + }, + { + "method": "HoneypotService.EditState", + "service": "HoneypotService", + "operation": "EditState", + "kind": "mutation", + "description": "改变所选 ID 事件的处置状态", + "tags": [ + "蜜罐诱捕" + ] + }, + { + "method": "HoneypotService.EditStateByHoneypot", + "service": "HoneypotService", + "operation": "EditStateByHoneypot", + "kind": "mutation", + "description": "改变所选蜜罐名事件的处置状态", + "tags": [ + "蜜罐诱捕" + ] + }, + { + "method": "HoneypotService.EditStateByHost", + "service": "HoneypotService", + "operation": "EditStateByHost", + "kind": "mutation", + "description": "改变所选主机 ID 事件的处置状态", + "tags": [ + "蜜罐诱捕" + ] + }, + { + "method": "HoneypotService.EditStateBySrcIP", + "service": "HoneypotService", + "operation": "EditStateBySrcIP", + "kind": "mutation", + "description": "改变所选源 IP 事件的处置状态", + "tags": [ + "蜜罐诱捕" + ] + }, + { + "method": "HoneypotService.GetEvent", + "service": "HoneypotService", + "operation": "GetEvent", + "kind": "readonly", + "description": "获取事件详情", + "tags": [ + "蜜罐诱捕" + ] + }, + { + "method": "HoneypotService.GetWhitelist", + "service": "HoneypotService", + "operation": "GetWhitelist", + "kind": "readonly", + "description": "获取白名单规则", + "tags": [ + "蜜罐诱捕" + ] + }, + { + "method": "HoneypotService.List", + "service": "HoneypotService", + "operation": "List", + "kind": "readonly", + "description": "获取事件列表", + "tags": [ + "蜜罐诱捕" + ] + }, + { + "method": "HoneypotService.ListByHoneypot", + "service": "HoneypotService", + "operation": "ListByHoneypot", + "kind": "readonly", + "description": "获取按蜜罐名聚合的事件列表", + "tags": [ + "蜜罐诱捕" + ] + }, + { + "method": "HoneypotService.ListByHost", + "service": "HoneypotService", + "operation": "ListByHost", + "kind": "readonly", + "description": "获取按主机聚合的事件列表", + "tags": [ + "蜜罐诱捕" + ] + }, + { + "method": "HoneypotService.ListBySrcIP", + "service": "HoneypotService", + "operation": "ListBySrcIP", + "kind": "readonly", + "description": "获取按源 IP 聚合的事件列表", + "tags": [ + "蜜罐诱捕" + ] + }, + { + "method": "HoneypotService.MarkAsRead", + "service": "HoneypotService", + "operation": "MarkAsRead", + "kind": "mutation", + "description": "标记事件为已读", + "tags": [ + "蜜罐诱捕" + ] + }, + { + "method": "HoneypotService.StatEvent", + "service": "HoneypotService", + "operation": "StatEvent", + "kind": "readonly", + "description": "获取按多种视角聚合的事件统计信息", + "tags": [ + "蜜罐诱捕" + ] + }, + { + "method": "HostAssetService.AddAttributes", + "service": "HostAssetService", + "operation": "AddAttributes", + "kind": "mutation", + "description": "增加主机属性字段", + "tags": [ + "主机属性" + ] + }, + { + "method": "HostAssetService.BatchEditHostAttribute", + "service": "HostAssetService", + "operation": "BatchEditHostAttribute", + "kind": "mutation", + "description": "批量编辑主机属性信息", + "tags": [ + "主机资产" + ] + }, + { + "method": "HostAssetService.CountHost", + "service": "HostAssetService", + "operation": "CountHost", + "kind": "readonly", + "description": "获取主机资产数量", + "tags": [ + "主机资产" + ] + }, + { + "method": "HostAssetService.DeleteAttributes", + "service": "HostAssetService", + "operation": "DeleteAttributes", + "kind": "mutation", + "description": "删除主机属性字段", + "tags": [ + "主机属性" + ] + }, + { + "method": "HostAssetService.DeleteHost", + "service": "HostAssetService", + "operation": "DeleteHost", + "kind": "mutation", + "description": "卸载探针", + "tags": [ + "探针管理" + ] + }, + { + "method": "HostAssetService.DisableHost", + "service": "HostAssetService", + "operation": "DisableHost", + "kind": "mutation", + "description": "停用探针", + "tags": [ + "探针管理" + ] + }, + { + "method": "HostAssetService.EditAgentComment", + "service": "HostAssetService", + "operation": "EditAgentComment", + "kind": "mutation", + "description": "修改主机备注", + "tags": [ + "主机资产" + ] + }, + { + "method": "HostAssetService.EditAgentOrg", + "service": "HostAssetService", + "operation": "EditAgentOrg", + "kind": "mutation", + "description": "修改主机机构", + "tags": [ + "主机资产" + ] + }, + { + "method": "HostAssetService.EditAttributes", + "service": "HostAssetService", + "operation": "EditAttributes", + "kind": "mutation", + "description": "编辑主机属性字段", + "tags": [ + "主机属性" + ] + }, + { + "method": "HostAssetService.EditHostAttribute", + "service": "HostAssetService", + "operation": "EditHostAttribute", + "kind": "mutation", + "description": "编辑主机属性信息", + "tags": [ + "主机资产" + ] + }, + { + "method": "HostAssetService.GenerateInstallCommand", + "service": "HostAssetService", + "operation": "GenerateInstallCommand", + "kind": "mutation", + "description": "生成探针安装命令", + "tags": [ + "探针管理" + ] + }, + { + "method": "HostAssetService.GenerateInstallInstaller", + "service": "HostAssetService", + "operation": "GenerateInstallInstaller", + "kind": "mutation", + "description": "生成探针安装包", + "tags": [ + "探针管理" + ] + }, + { + "method": "HostAssetService.GetAgentCloneConfig", + "service": "HostAssetService", + "operation": "GetAgentCloneConfig", + "kind": "readonly", + "description": "获取探针克隆配置", + "tags": [ + "探针管理" + ] + }, + { + "method": "HostAssetService.GetAgentConfig", + "service": "HostAssetService", + "operation": "GetAgentConfig", + "kind": "readonly", + "description": "获取探针配置", + "tags": [ + "探针模块" + ] + }, + { + "method": "HostAssetService.GetAgentInfo", + "service": "HostAssetService", + "operation": "GetAgentInfo", + "kind": "readonly", + "description": "获取探针信息", + "tags": [ + "主机资产" + ] + }, + { + "method": "HostAssetService.GetAllHostTag", + "service": "HostAssetService", + "operation": "GetAllHostTag", + "kind": "readonly", + "description": "获取所有已有标签", + "tags": [ + "主机资产" + ] + }, + { + "method": "HostAssetService.GetAntiUninstallConfig", + "service": "HostAssetService", + "operation": "GetAntiUninstallConfig", + "kind": "readonly", + "description": "获取探针防卸载配置", + "tags": [ + "探针管理" + ] + }, + { + "method": "HostAssetService.GetAssetOverView", + "service": "HostAssetService", + "operation": "GetAssetOverView", + "kind": "readonly", + "description": "获取主机上的资产的统计信息", + "tags": [ + "主机资产" + ] + }, + { + "method": "HostAssetService.GetAttributes", + "service": "HostAssetService", + "operation": "GetAttributes", + "kind": "readonly", + "description": "获取主机属性字段", + "tags": [ + "主机资产" + ] + }, + { + "method": "HostAssetService.GetEventList", + "service": "HostAssetService", + "operation": "GetEventList", + "kind": "readonly", + "description": "获取入侵检测事件信息", + "tags": [ + "主机资产" + ] + }, + { + "method": "HostAssetService.GetEventOverView", + "service": "HostAssetService", + "operation": "GetEventOverView", + "kind": "readonly", + "description": "获取主机上的资产的统计信息", + "tags": [ + "主机资产" + ] + }, + { + "method": "HostAssetService.GetFileState", + "service": "HostAssetService", + "operation": "GetFileState", + "kind": "readonly", + "description": "获取文件状态", + "tags": [ + "主机资产" + ] + }, + { + "method": "HostAssetService.GetHostAssetList", + "service": "HostAssetService", + "operation": "GetHostAssetList", + "kind": "readonly", + "description": "获取主机资产列表信息", + "tags": [ + "主机资产" + ] + }, + { + "method": "HostAssetService.GetHostAttribute", + "service": "HostAssetService", + "operation": "GetHostAttribute", + "kind": "readonly", + "description": "获取主机属性信息", + "tags": [ + "主机资产" + ] + }, + { + "method": "HostAssetService.GetHostBusinessInfo", + "service": "HostAssetService", + "operation": "GetHostBusinessInfo", + "kind": "readonly", + "description": "获取业务组信息", + "tags": [ + "主机资产" + ] + }, + { + "method": "HostAssetService.GetHostEnviron", + "service": "HostAssetService", + "operation": "GetHostEnviron", + "kind": "readonly", + "description": "获取主机资产环境变量", + "tags": [ + "主机资产" + ] + }, + { + "method": "HostAssetService.GetHostInfoDetail", + "service": "HostAssetService", + "operation": "GetHostInfoDetail", + "kind": "readonly", + "description": "获取主机资产的详情信息, 包含软件信息,硬件信息", + "tags": [ + "主机资产" + ] + }, + { + "method": "HostAssetService.GetHostLog", + "service": "HostAssetService", + "operation": "GetHostLog", + "kind": "readonly", + "description": "获取探针端获取日志,默认忽略离线探针,默认超时时间为1min.", + "tags": [ + "主机资产" + ] + }, + { + "method": "HostAssetService.GetHostPhysicalInfo", + "service": "HostAssetService", + "operation": "GetHostPhysicalInfo", + "kind": "readonly", + "description": "获取主机资产物理信息", + "tags": [ + "主机资产" + ] + }, + { + "method": "HostAssetService.GetHostPprof", + "service": "HostAssetService", + "operation": "GetHostPprof", + "kind": "readonly", + "description": "探针端获取日志,默认忽略离线探针,默认超时时间为 1 min.", + "tags": [ + "主机资产" + ] + }, + { + "method": "HostAssetService.GetHostView", + "service": "HostAssetService", + "operation": "GetHostView", + "kind": "readonly", + "description": "获取主机信息", + "tags": [ + "主机资产" + ] + }, + { + "method": "HostAssetService.GetRiskOverView", + "service": "HostAssetService", + "operation": "GetRiskOverView", + "kind": "readonly", + "description": "获取主机上的资产的统计信息", + "tags": [ + "主机资产" + ] + }, + { + "method": "HostAssetService.GetScore", + "service": "HostAssetService", + "operation": "GetScore", + "kind": "readonly", + "description": "获取主机分数", + "tags": [ + "主机资产" + ] + }, + { + "method": "HostAssetService.GetUninstallCommand", + "service": "HostAssetService", + "operation": "GetUninstallCommand", + "kind": "readonly", + "description": "获取离线卸载 token", + "tags": [ + "探针管理" + ] + }, + { + "method": "HostAssetService.MultiEditTags", + "service": "HostAssetService", + "operation": "MultiEditTags", + "kind": "mutation", + "description": "更新主机的标签", + "tags": [ + "主机资产" + ] + }, + { + "method": "HostAssetService.RemoteInstallCancel", + "service": "HostAssetService", + "operation": "RemoteInstallCancel", + "kind": "mutation", + "description": "取消远程安装操作", + "tags": [ + "主机资产" + ] + }, + { + "method": "HostAssetService.RemoteInstallCurrent", + "service": "HostAssetService", + "operation": "RemoteInstallCurrent", + "kind": "mutation", + "description": "获取当前任务信息", + "tags": [ + "主机资产" + ] + }, + { + "method": "HostAssetService.RemoteInstallStart", + "service": "HostAssetService", + "operation": "RemoteInstallStart", + "kind": "mutation", + "description": "开始远程安装任务", + "tags": [ + "主机资产" + ] + }, + { + "method": "HostAssetService.RemoteInstallStatus", + "service": "HostAssetService", + "operation": "RemoteInstallStatus", + "kind": "mutation", + "description": "获取远程安装的状态信息", + "tags": [ + "主机资产" + ] + }, + { + "method": "HostAssetService.RepairHost", + "service": "HostAssetService", + "operation": "RepairHost", + "kind": "mutation", + "description": "修复探针", + "tags": [ + "探针管理" + ] + }, + { + "method": "HostAssetService.RestartHost", + "service": "HostAssetService", + "operation": "RestartHost", + "kind": "mutation", + "description": "重启探针", + "tags": [ + "探针管理" + ] + }, + { + "method": "HostAssetService.SetAgentCloneConfig", + "service": "HostAssetService", + "operation": "SetAgentCloneConfig", + "kind": "mutation", + "description": "设置探针克隆配置", + "tags": [ + "探针管理" + ] + }, + { + "method": "HostAssetService.SetAntiUninstallConfig", + "service": "HostAssetService", + "operation": "SetAntiUninstallConfig", + "kind": "mutation", + "description": "设置探针防卸载开关", + "tags": [ + "探针管理" + ] + }, + { + "method": "HostAssetService.SetGroup", + "service": "HostAssetService", + "operation": "SetGroup", + "kind": "mutation", + "description": "修改主机业务组", + "tags": [ + "主机资产" + ] + }, + { + "method": "HostAssetService.StatAgentState", + "service": "HostAssetService", + "operation": "StatAgentState", + "kind": "readonly", + "description": "获取按 探针状态 聚合的主机分布", + "tags": [ + "主机资产" + ] + }, + { + "method": "HostAssetService.StatAgentVersion", + "service": "HostAssetService", + "operation": "StatAgentVersion", + "kind": "readonly", + "description": "获取按 探针版本 聚合的主机分布", + "tags": [ + "主机资产" + ] + }, + { + "method": "HostAssetService.StatCpuCore", + "service": "HostAssetService", + "operation": "StatCpuCore", + "kind": "readonly", + "description": "获取按 cpu核数 聚合的主机分布", + "tags": [ + "主机资产" + ] + }, + { + "method": "HostAssetService.StatCpuUsage", + "service": "HostAssetService", + "operation": "StatCpuUsage", + "kind": "readonly", + "description": "获取按 CPU 用量 聚合的主机分布", + "tags": [ + "主机资产" + ] + }, + { + "method": "HostAssetService.StatHostCountByTime", + "service": "HostAssetService", + "operation": "StatHostCountByTime", + "kind": "readonly", + "description": "获取按 探针安装时间 聚合的主机分布", + "tags": [ + "主机资产" + ] + }, + { + "method": "HostAssetService.StatMemSize", + "service": "HostAssetService", + "operation": "StatMemSize", + "kind": "readonly", + "description": "获取按 内存大小 聚合的主机分布", + "tags": [ + "主机资产" + ] + }, + { + "method": "HostAssetService.StatMemUsage", + "service": "HostAssetService", + "operation": "StatMemUsage", + "kind": "readonly", + "description": "获取按 内存使用量 聚合的主机分布", + "tags": [ + "主机资产" + ] + }, + { + "method": "HostAssetService.StatOsReleaseName", + "service": "HostAssetService", + "operation": "StatOsReleaseName", + "kind": "readonly", + "description": "获取按 操作系统发行版本名称 聚合的主机分布", + "tags": [ + "主机资产" + ] + }, + { + "method": "HostAssetService.StatTag", + "service": "HostAssetService", + "operation": "StatTag", + "kind": "readonly", + "description": "获取按 主机特征(用户自定的) 聚合的主机分布", + "tags": [ + "主机资产" + ] + }, + { + "method": "HostAssetService.UpdateHostInfoDetail", + "service": "HostAssetService", + "operation": "UpdateHostInfoDetail", + "kind": "mutation", + "description": "立即更新主机详情信息。包含软件信息,硬件信息", + "tags": [ + "主机资产" + ] + }, + { + "method": "HostAssetService.UpgradeHost", + "service": "HostAssetService", + "operation": "UpgradeHost", + "kind": "mutation", + "description": "升级探针", + "tags": [ + "探针管理" + ] + }, + { + "method": "HostDiscoveryService.DeleteHost", + "service": "HostDiscoveryService", + "operation": "DeleteHost", + "kind": "mutation", + "description": "删除未知主机资产", + "tags": [ + "未知资产发现" + ] + }, + { + "method": "HostDiscoveryService.ListHost", + "service": "HostDiscoveryService", + "operation": "ListHost", + "kind": "readonly", + "description": "获取未知主机资产信息", + "tags": [ + "未知资产发现" + ] + }, + { + "method": "HostNicAssetService.GetHostNic", + "service": "HostNicAssetService", + "operation": "GetHostNic", + "kind": "readonly", + "description": "获取主机网卡信息", + "tags": [ + "主机资产" + ] + }, + { + "method": "HostPartitionAssetService.GetHostPartition", + "service": "HostPartitionAssetService", + "operation": "GetHostPartition", + "kind": "readonly", + "description": "获取主机分区信息", + "tags": [ + "分区资产" + ] + }, + { + "method": "HostPartitionAssetService.GetNetworkStorageFileSystem", + "service": "HostPartitionAssetService", + "operation": "GetNetworkStorageFileSystem", + "kind": "readonly", + "description": "获取所有主机网络存储文件系统", + "tags": [ + "分区资产" + ] + }, + { + "method": "HostPartitionAssetService.GetNetworkStoragePartition", + "service": "HostPartitionAssetService", + "operation": "GetNetworkStoragePartition", + "kind": "readonly", + "description": "获取主机网络存储分区信息", + "tags": [ + "分区资产" + ] + }, + { + "method": "HostPartitionAssetService.GetNetworkStoragePartitionByFileSystem", + "service": "HostPartitionAssetService", + "operation": "GetNetworkStoragePartitionByFileSystem", + "kind": "readonly", + "description": "获取主机网络存储分区信息,通过文件系统聚合", + "tags": [ + "分区资产" + ] + }, + { + "method": "HostPartitionAssetService.GetNetworkStoragePartitionByHost", + "service": "HostPartitionAssetService", + "operation": "GetNetworkStoragePartitionByHost", + "kind": "readonly", + "description": "获取主机网络存储分区信息,通过主机聚合", + "tags": [ + "分区资产" + ] + }, + { + "method": "HostPartitionAssetService.GetNetworkStoragePartitionByMountPoint", + "service": "HostPartitionAssetService", + "operation": "GetNetworkStoragePartitionByMountPoint", + "kind": "readonly", + "description": "获取主机网络存储分区信息,通过挂载点聚合", + "tags": [ + "分区资产" + ] + }, + { + "method": "HostPartitionAssetService.SetNetworkStorageScanEnablement", + "service": "HostPartitionAssetService", + "operation": "SetNetworkStorageScanEnablement", + "kind": "mutation", + "description": "设置主机网络存储分区资产扫描启用状态", + "tags": [ + "分区资产" + ] + }, + { + "method": "HostRouteAssetService.GetHostRoute", + "service": "HostRouteAssetService", + "operation": "GetHostRoute", + "kind": "readonly", + "description": "获取主机路由资产信息", + "tags": [ + "主机资产" + ] + }, + { + "method": "LighterManagerService.Apply", + "service": "LighterManagerService", + "operation": "Apply", + "kind": "mutation", + "description": "应用lighter升级包", + "tags": [ + "lighter模块" + ] + }, + { + "method": "LighterManagerService.GetLatestVersion", + "service": "LighterManagerService", + "operation": "GetLatestVersion", + "kind": "readonly", + "description": "获取最新的 lighter 版本", + "tags": [ + "lighter模块" + ] + }, + { + "method": "LighterManagerService.Upgrade", + "service": "LighterManagerService", + "operation": "Upgrade", + "kind": "mutation", + "description": "更新 lighter", + "tags": [ + "lighter 管理" + ] + }, + { + "method": "LogCollectService.CreateLogCollect", + "service": "LogCollectService", + "operation": "CreateLogCollect", + "kind": "mutation", + "description": "创建日志采集", + "tags": [ + "日志采集" + ] + }, + { + "method": "LogCollectService.DeleteLogCollect", + "service": "LogCollectService", + "operation": "DeleteLogCollect", + "kind": "mutation", + "description": "删除日志采集规则", + "tags": [ + "日志采集" + ] + }, + { + "method": "LogCollectService.EnablementLogCollect", + "service": "LogCollectService", + "operation": "EnablementLogCollect", + "kind": "mutation", + "description": "启禁用日志采集规则", + "tags": [ + "日志采集" + ] + }, + { + "method": "LogCollectService.GetLogCollectInfo", + "service": "LogCollectService", + "operation": "GetLogCollectInfo", + "kind": "readonly", + "description": "获取日志采集信息", + "tags": [ + "日志采集" + ] + }, + { + "method": "LogCollectService.ListLogCollect", + "service": "LogCollectService", + "operation": "ListLogCollect", + "kind": "readonly", + "description": "获取日志采集列表", + "tags": [ + "日志采集" + ] + }, + { + "method": "LogCollectService.UpdateLogCollect", + "service": "LogCollectService", + "operation": "UpdateLogCollect", + "kind": "mutation", + "description": "更新日志采集", + "tags": [ + "日志采集" + ] + }, + { + "method": "MalwareEventService.CreateWhitelist", + "service": "MalwareEventService", + "operation": "CreateWhitelist", + "kind": "mutation", + "description": "生成白名单规则", + "tags": [ + "恶意文件" + ] + }, + { + "method": "MalwareEventService.DeleteEvent", + "service": "MalwareEventService", + "operation": "DeleteEvent", + "kind": "mutation", + "description": "删除所选的事件", + "tags": [ + "恶意文件" + ] + }, + { + "method": "MalwareEventService.DeleteLibrary", + "service": "MalwareEventService", + "operation": "DeleteLibrary", + "kind": "mutation", + "description": "删除规则匹配引擎库", + "tags": [ + "恶意文件" + ] + }, + { + "method": "MalwareEventService.EditEventComment", + "service": "MalwareEventService", + "operation": "EditEventComment", + "kind": "mutation", + "description": "改变所选事件备注", + "tags": [ + "恶意文件" + ] + }, + { + "method": "MalwareEventService.EditEventState", + "service": "MalwareEventService", + "operation": "EditEventState", + "kind": "mutation", + "description": "改变所选事件的处置状态", + "tags": [ + "恶意文件" + ] + }, + { + "method": "MalwareEventService.GetCredibleEngineVersion", + "service": "MalwareEventService", + "operation": "GetCredibleEngineVersion", + "kind": "readonly", + "description": "获取可信引擎库版本", + "tags": [ + "恶意文件" + ] + }, + { + "method": "MalwareEventService.GetEvent", + "service": "MalwareEventService", + "operation": "GetEvent", + "kind": "readonly", + "description": "获取事件详情", + "tags": [ + "恶意文件" + ] + }, + { + "method": "MalwareEventService.GetEventList", + "service": "MalwareEventService", + "operation": "GetEventList", + "kind": "readonly", + "description": "获取事件列表", + "tags": [ + "恶意文件" + ] + }, + { + "method": "MalwareEventService.GetLibraryList", + "service": "MalwareEventService", + "operation": "GetLibraryList", + "kind": "readonly", + "description": "获取规则匹配引擎库列表", + "tags": [ + "恶意文件" + ] + }, + { + "method": "MalwareEventService.GetMalwareEngineVersion", + "service": "MalwareEventService", + "operation": "GetMalwareEngineVersion", + "kind": "readonly", + "description": "获取恶意文件检测引擎版本", + "tags": [ + "恶意文件" + ] + }, + { + "method": "MalwareEventService.GetWhitelist", + "service": "MalwareEventService", + "operation": "GetWhitelist", + "kind": "readonly", + "description": "获取白名单规则", + "tags": [ + "恶意文件" + ] + }, + { + "method": "MalwareEventService.IsolateTask", + "service": "MalwareEventService", + "operation": "IsolateTask", + "kind": "readonly", + "description": "恶意文件\"隔离\"操作任务", + "tags": [ + "恶意文件" + ] + }, + { + "method": "MalwareEventService.MarkAsRead", + "service": "MalwareEventService", + "operation": "MarkAsRead", + "kind": "mutation", + "description": "标记事件为已读", + "tags": [ + "恶意文件" + ] + }, + { + "method": "MalwareEventService.ProcessKill", + "service": "MalwareEventService", + "operation": "ProcessKill", + "kind": "mutation", + "description": "进程阻断", + "tags": [ + "恶意文件" + ] + }, + { + "method": "MalwareEventService.RetestEvent", + "service": "MalwareEventService", + "operation": "RetestEvent", + "kind": "mutation", + "description": "恶意文件快速复测", + "tags": [ + "恶意文件" + ] + }, + { + "method": "MalwareEventService.StatEventByFileType", + "service": "MalwareEventService", + "operation": "StatEventByFileType", + "kind": "readonly", + "description": "获取按文件类型聚合的统计结果", + "tags": [ + "恶意文件" + ] + }, + { + "method": "MalwareEventService.StatEventByState", + "service": "MalwareEventService", + "operation": "StatEventByState", + "kind": "readonly", + "description": "获取按状态聚合的统计结果", + "tags": [ + "恶意文件" + ] + }, + { + "method": "MalwareEventService.TrustTask", + "service": "MalwareEventService", + "operation": "TrustTask", + "kind": "mutation", + "description": "恶意文件 信任 操作任务", + "tags": [ + "恶意文件" + ] + }, + { + "method": "MessageQueueService.GetNsqSummary", + "service": "MessageQueueService", + "operation": "GetNsqSummary", + "kind": "readonly", + "description": "获取nsq消息", + "tags": [ + "Nsq" + ] + }, + { + "method": "MimicryService.DeleteEvent", + "service": "MimicryService", + "operation": "DeleteEvent", + "kind": "mutation", + "description": "删除事件", + "tags": [ + "MimicryService" + ] + }, + { + "method": "MimicryService.ListEvent", + "service": "MimicryService", + "operation": "ListEvent", + "kind": "readonly", + "description": "获取事件列表", + "tags": [ + "MimicryService" + ] + }, + { + "method": "MimicryService.StopMimicry", + "service": "MimicryService", + "operation": "StopMimicry", + "kind": "mutation", + "description": "停止拟态防护", + "tags": [ + "MimicryService" + ] + }, + { + "method": "NetworkAuditEventService.CreateWhitelist", + "service": "NetworkAuditEventService", + "operation": "CreateWhitelist", + "kind": "mutation", + "description": "生成白名单规则", + "tags": [ + "网络审计" + ] + }, + { + "method": "NetworkAuditEventService.DeleteEvent", + "service": "NetworkAuditEventService", + "operation": "DeleteEvent", + "kind": "mutation", + "description": "删除所选的事件", + "tags": [ + "网络审计" + ] + }, + { + "method": "NetworkAuditEventService.EditEventComment", + "service": "NetworkAuditEventService", + "operation": "EditEventComment", + "kind": "mutation", + "description": "改变所选事件备注", + "tags": [ + "网络审计" + ] + }, + { + "method": "NetworkAuditEventService.EditEventState", + "service": "NetworkAuditEventService", + "operation": "EditEventState", + "kind": "mutation", + "description": "改变所选事件的处置状态", + "tags": [ + "网络审计" + ] + }, + { + "method": "NetworkAuditEventService.GetEvent", + "service": "NetworkAuditEventService", + "operation": "GetEvent", + "kind": "readonly", + "description": "获取事件详情", + "tags": [ + "网络审计" + ] + }, + { + "method": "NetworkAuditEventService.GetEventList", + "service": "NetworkAuditEventService", + "operation": "GetEventList", + "kind": "readonly", + "description": "获取事件列表", + "tags": [ + "网络审计" + ] + }, + { + "method": "NetworkAuditEventService.GetEventListByHost", + "service": "NetworkAuditEventService", + "operation": "GetEventListByHost", + "kind": "readonly", + "description": "获取按主机聚合的事件列表", + "tags": [ + "网络审计" + ] + }, + { + "method": "NetworkAuditEventService.GetEventListByProcessName", + "service": "NetworkAuditEventService", + "operation": "GetEventListByProcessName", + "kind": "readonly", + "description": "获取按进程名聚合的事件列表", + "tags": [ + "网络审计" + ] + }, + { + "method": "NetworkAuditEventService.GetEventListByTarget", + "service": "NetworkAuditEventService", + "operation": "GetEventListByTarget", + "kind": "readonly", + "description": "获取按目标网络地址统计的事件列表", + "tags": [ + "网络审计" + ] + }, + { + "method": "NetworkAuditEventService.GetWhitelist", + "service": "NetworkAuditEventService", + "operation": "GetWhitelist", + "kind": "readonly", + "description": "获取白名单规则", + "tags": [ + "网络审计" + ] + }, + { + "method": "NetworkAuditEventService.MarkAsRead", + "service": "NetworkAuditEventService", + "operation": "MarkAsRead", + "kind": "mutation", + "description": "标记事件为已读", + "tags": [ + "网络审计" + ] + }, + { + "method": "NetworkAuditEventService.ProcessKill", + "service": "NetworkAuditEventService", + "operation": "ProcessKill", + "kind": "mutation", + "description": "进程阻断", + "tags": [ + "网络审计" + ] + }, + { + "method": "NetworkAuditEventService.StatEventByHost", + "service": "NetworkAuditEventService", + "operation": "StatEventByHost", + "kind": "readonly", + "description": "获取按主机聚合的事件列表", + "tags": [ + "网络审计" + ] + }, + { + "method": "NetworkAuditEventService.StatEventByLevelState", + "service": "NetworkAuditEventService", + "operation": "StatEventByLevelState", + "kind": "readonly", + "description": "获取按风险等级与处置状态聚合的事件列表", + "tags": [ + "网络审计" + ] + }, + { + "method": "NetworkAuditEventService.StatEventByProcessName", + "service": "NetworkAuditEventService", + "operation": "StatEventByProcessName", + "kind": "readonly", + "description": "获取按进程名聚合的事件列表", + "tags": [ + "网络审计" + ] + }, + { + "method": "NetworkAuditEventService.StatEventByTarget", + "service": "NetworkAuditEventService", + "operation": "StatEventByTarget", + "kind": "readonly", + "description": "获取按目标地址聚合的事件列表", + "tags": [ + "网络审计" + ] + }, + { + "method": "NetworkAuditEventService.StatEventByTimeState", + "service": "NetworkAuditEventService", + "operation": "StatEventByTimeState", + "kind": "readonly", + "description": "获取按事件发生时间聚合的事件列表", + "tags": [ + "网络审计" + ] + }, + { + "method": "NetworkAuditEventService.StatEventByType", + "service": "NetworkAuditEventService", + "operation": "StatEventByType", + "kind": "readonly", + "description": "获取按网络外连类型聚合的事件列表", + "tags": [ + "网络审计" + ] + }, + { + "method": "NetworkRejectService.DeleteEvent", + "service": "NetworkRejectService", + "operation": "DeleteEvent", + "kind": "mutation", + "description": "删除事件", + "tags": [ + "NetworkReject" + ] + }, + { + "method": "NetworkRejectService.ListEvent", + "service": "NetworkRejectService", + "operation": "ListEvent", + "kind": "readonly", + "description": "获取事件列表", + "tags": [ + "NetworkReject" + ] + }, + { + "method": "NetworkRejectService.ReleaseReject", + "service": "NetworkRejectService", + "operation": "ReleaseReject", + "kind": "mutation", + "description": "解除阻断", + "tags": [ + "NetworkReject" + ] + }, + { + "method": "NonWhiteProcessService.CreateRule", + "service": "NonWhiteProcessService", + "operation": "CreateRule", + "kind": "mutation", + "description": "创建命令白名单规则", + "tags": [ + "命令白名单" + ] + }, + { + "method": "NonWhiteProcessService.DeleteEvent", + "service": "NonWhiteProcessService", + "operation": "DeleteEvent", + "kind": "mutation", + "description": "删除事件", + "tags": [ + "命令白名单" + ] + }, + { + "method": "NonWhiteProcessService.DeleteRule", + "service": "NonWhiteProcessService", + "operation": "DeleteRule", + "kind": "mutation", + "description": "删除命令白名单规则", + "tags": [ + "命令白名单" + ] + }, + { + "method": "NonWhiteProcessService.EditEventComment", + "service": "NonWhiteProcessService", + "operation": "EditEventComment", + "kind": "mutation", + "description": "更新事件备注", + "tags": [ + "命令白名单" + ] + }, + { + "method": "NonWhiteProcessService.EditEventState", + "service": "NonWhiteProcessService", + "operation": "EditEventState", + "kind": "mutation", + "description": "编辑事件状态", + "tags": [ + "命令白名单" + ] + }, + { + "method": "NonWhiteProcessService.GetEventDetail", + "service": "NonWhiteProcessService", + "operation": "GetEventDetail", + "kind": "readonly", + "description": "获取事件详情", + "tags": [ + "命令白名单" + ] + }, + { + "method": "NonWhiteProcessService.GetEventList", + "service": "NonWhiteProcessService", + "operation": "GetEventList", + "kind": "readonly", + "description": "获取事件列表", + "tags": [ + "命令白名单" + ] + }, + { + "method": "NonWhiteProcessService.GetEventListByCmdline", + "service": "NonWhiteProcessService", + "operation": "GetEventListByCmdline", + "kind": "readonly", + "description": "根据进程cmdline聚合事件列表", + "tags": [ + "命令白名单" + ] + }, + { + "method": "NonWhiteProcessService.GetEventListByHost", + "service": "NonWhiteProcessService", + "operation": "GetEventListByHost", + "kind": "readonly", + "description": "获取按主机聚合的事件列表", + "tags": [ + "命令白名单" + ] + }, + { + "method": "NonWhiteProcessService.GetEventListByName", + "service": "NonWhiteProcessService", + "operation": "GetEventListByName", + "kind": "readonly", + "description": "根据进程名称聚合事件列", + "tags": [ + "命令白名单" + ] + }, + { + "method": "NonWhiteProcessService.ListRule", + "service": "NonWhiteProcessService", + "operation": "ListRule", + "kind": "readonly", + "description": "获取命令白名单规则", + "tags": [ + "命令白名单" + ] + }, + { + "method": "NonWhiteProcessService.MarkAsRead", + "service": "NonWhiteProcessService", + "operation": "MarkAsRead", + "kind": "mutation", + "description": "标记事件为已读", + "tags": [ + "命令白名单" + ] + }, + { + "method": "NonWhiteProcessService.UpdateRule", + "service": "NonWhiteProcessService", + "operation": "UpdateRule", + "kind": "mutation", + "description": "更新命令白名单规则", + "tags": [ + "命令白名单" + ] + }, + { + "method": "NonWhiteProcessService.WhiteEvent", + "service": "NonWhiteProcessService", + "operation": "WhiteEvent", + "kind": "mutation", + "description": "加白事件", + "tags": [ + "命令白名单" + ] + }, + { + "method": "OrganizationService.CreateOrg", + "service": "OrganizationService", + "operation": "CreateOrg", + "kind": "mutation", + "description": "创建机构", + "tags": [ + "后台:机构管理" + ] + }, + { + "method": "OrganizationService.DeleteOrg", + "service": "OrganizationService", + "operation": "DeleteOrg", + "kind": "mutation", + "description": "删除机构", + "tags": [ + "后台:机构管理" + ] + }, + { + "method": "OrganizationService.GetOrgInfo", + "service": "OrganizationService", + "operation": "GetOrgInfo", + "kind": "readonly", + "description": "获取机构信息", + "tags": [ + "后台:机构管理" + ] + }, + { + "method": "OrganizationService.ListOrg", + "service": "OrganizationService", + "operation": "ListOrg", + "kind": "readonly", + "description": "用户列表", + "tags": [ + "后台:机构管理" + ] + }, + { + "method": "OrganizationService.ListOrgWithBizGroup", + "service": "OrganizationService", + "operation": "ListOrgWithBizGroup", + "kind": "readonly", + "description": "用户列表", + "tags": [ + "后台:机构管理" + ] + }, + { + "method": "OrganizationService.UpdateOrg", + "service": "OrganizationService", + "operation": "UpdateOrg", + "kind": "mutation", + "description": "更新机构", + "tags": [ + "后台:机构管理" + ] + }, + { + "method": "PackageService.ApplyPackage", + "service": "PackageService", + "operation": "ApplyPackage", + "kind": "mutation", + "description": "应用升级包", + "tags": [ + "产品更新" + ] + }, + { + "method": "PackageService.BatchApplyPackage", + "service": "PackageService", + "operation": "BatchApplyPackage", + "kind": "mutation", + "description": "批量应用升级包", + "tags": [ + "产品更新" + ] + }, + { + "method": "PackageService.CancelDownload", + "service": "PackageService", + "operation": "CancelDownload", + "kind": "mutation", + "description": "取消下载", + "tags": [ + "产品更新" + ] + }, + { + "method": "PackageService.DeletePackage", + "service": "PackageService", + "operation": "DeletePackage", + "kind": "mutation", + "description": "删除升级包", + "tags": [ + "产品更新" + ] + }, + { + "method": "PackageService.DownloadPackage", + "service": "PackageService", + "operation": "DownloadPackage", + "kind": "mutation", + "description": "下载升级包", + "tags": [ + "产品更新" + ] + }, + { + "method": "PackageService.GetAllModules", + "service": "PackageService", + "operation": "GetAllModules", + "kind": "readonly", + "description": "获取所有模块", + "tags": [ + "产品更新" + ] + }, + { + "method": "PackageService.GetApplyHistory", + "service": "PackageService", + "operation": "GetApplyHistory", + "kind": "readonly", + "description": "获取历史更新记录", + "tags": [ + "产品更新" + ] + }, + { + "method": "PackageService.GetDownloadResult", + "service": "PackageService", + "operation": "GetDownloadResult", + "kind": "readonly", + "description": "获取升级包下载结果", + "tags": [ + "产品更新" + ] + }, + { + "method": "PackageService.GetLatestPackages", + "service": "PackageService", + "operation": "GetLatestPackages", + "kind": "readonly", + "description": "获取最近的升级包", + "tags": [ + "产品更新" + ] + }, + { + "method": "PackageService.GetModules", + "service": "PackageService", + "operation": "GetModules", + "kind": "readonly", + "description": "获取模块", + "tags": [ + "产品更新" + ] + }, + { + "method": "PackageService.GetOverview", + "service": "PackageService", + "operation": "GetOverview", + "kind": "readonly", + "description": "获取版本信息", + "tags": [ + "产品更新" + ] + }, + { + "method": "PackageService.GetPackage", + "service": "PackageService", + "operation": "GetPackage", + "kind": "readonly", + "description": "获取升级包信息", + "tags": [ + "产品更新" + ] + }, + { + "method": "PackageService.GetProxyHost", + "service": "PackageService", + "operation": "GetProxyHost", + "kind": "readonly", + "description": "获取代理主机信息", + "tags": [ + "产品更新" + ] + }, + { + "method": "PackageService.ListPackages", + "service": "PackageService", + "operation": "ListPackages", + "kind": "readonly", + "description": "获取升级包列表", + "tags": [ + "产品更新" + ] + }, + { + "method": "PackageService.UpsertProxyHost", + "service": "PackageService", + "operation": "UpsertProxyHost", + "kind": "mutation", + "description": "更新插入代理", + "tags": [ + "产品更新" + ] + }, + { + "method": "PatchInfoEventService.CreateWhitelist", + "service": "PatchInfoEventService", + "operation": "CreateWhitelist", + "kind": "mutation", + "description": "创建事件加白规则", + "tags": [ + "补丁风险" + ] + }, + { + "method": "PatchInfoEventService.DelPatchInfoEvent", + "service": "PatchInfoEventService", + "operation": "DelPatchInfoEvent", + "kind": "mutation", + "description": "删除事件", + "tags": [ + "补丁风险" + ] + }, + { + "method": "PatchInfoEventService.GetPatchInfoEvent", + "service": "PatchInfoEventService", + "operation": "GetPatchInfoEvent", + "kind": "readonly", + "description": "获取事件详情", + "tags": [ + "补丁风险" + ] + }, + { + "method": "PatchInfoEventService.GetPatchInfoEventList", + "service": "PatchInfoEventService", + "operation": "GetPatchInfoEventList", + "kind": "readonly", + "description": "获取补丁风险列表", + "tags": [ + "补丁风险" + ] + }, + { + "method": "PatchInfoEventService.GetPatchInfoEventListByHost", + "service": "PatchInfoEventService", + "operation": "GetPatchInfoEventListByHost", + "kind": "readonly", + "description": "获取按 主机 聚合的事件列表", + "tags": [ + "补丁风险" + ] + }, + { + "method": "PatchInfoEventService.GetPatchInfoEventListByPatchInfo", + "service": "PatchInfoEventService", + "operation": "GetPatchInfoEventListByPatchInfo", + "kind": "readonly", + "description": "获取按 补丁信息 聚合的事件列表", + "tags": [ + "补丁风险" + ] + }, + { + "method": "PatchInfoEventService.GetWhitelist", + "service": "PatchInfoEventService", + "operation": "GetWhitelist", + "kind": "readonly", + "description": "获取白名单规则", + "tags": [ + "补丁风险" + ] + }, + { + "method": "PatchInfoEventService.InstallPatchInfo", + "service": "PatchInfoEventService", + "operation": "InstallPatchInfo", + "kind": "mutation", + "description": "安装补丁", + "tags": [ + "补丁风险" + ] + }, + { + "method": "PatchInfoEventService.RetestPatchInfo", + "service": "PatchInfoEventService", + "operation": "RetestPatchInfo", + "kind": "mutation", + "description": "复测补丁", + "tags": [ + "补丁风险" + ] + }, + { + "method": "PatchInfoEventService.StatPatchInfoEventByHost", + "service": "PatchInfoEventService", + "operation": "StatPatchInfoEventByHost", + "kind": "readonly", + "description": "返回按 主机 聚合的统计视图", + "tags": [ + "补丁风险" + ] + }, + { + "method": "PatchInfoEventService.StatPatchInfoEventByLevelPatchState", + "service": "PatchInfoEventService", + "operation": "StatPatchInfoEventByLevelPatchState", + "kind": "readonly", + "description": "返回按 补丁处置状态 聚合的统计视图", + "tags": [ + "补丁风险" + ] + }, + { + "method": "PatchInfoEventService.StatPatchInfoEventByPatchInfo", + "service": "PatchInfoEventService", + "operation": "StatPatchInfoEventByPatchInfo", + "kind": "readonly", + "description": "返回按 补丁信息 聚合的统计视图", + "tags": [ + "补丁风险" + ] + }, + { + "method": "PatchInfoEventService.StatPatchInfoEventByTime", + "service": "PatchInfoEventService", + "operation": "StatPatchInfoEventByTime", + "kind": "readonly", + "description": "返回按 时间 聚合的统计视图", + "tags": [ + "补丁风险" + ] + }, + { + "method": "PatchInfoEventService.UpdatePatchInfoEventComment", + "service": "PatchInfoEventService", + "operation": "UpdatePatchInfoEventComment", + "kind": "mutation", + "description": "修改事件备注", + "tags": [ + "补丁风险" + ] + }, + { + "method": "PatchInfoEventService.UpdatePatchInfoEventPatchState", + "service": "PatchInfoEventService", + "operation": "UpdatePatchInfoEventPatchState", + "kind": "mutation", + "description": "编辑状态", + "tags": [ + "补丁风险" + ] + }, + { + "method": "PatchInfoService.EditCustomSolution", + "service": "PatchInfoService", + "operation": "EditCustomSolution", + "kind": "mutation", + "description": "修改补丁修复方案", + "tags": [ + "补丁管理" + ] + }, + { + "method": "PatchInfoService.GetPatchInfo", + "service": "PatchInfoService", + "operation": "GetPatchInfo", + "kind": "readonly", + "description": "获取补丁详情", + "tags": [ + "补丁管理" + ] + }, + { + "method": "PatchInfoService.GetPatchInfoList", + "service": "PatchInfoService", + "operation": "GetPatchInfoList", + "kind": "readonly", + "description": "获取补丁风险列表", + "tags": [ + "补丁管理" + ] + }, + { + "method": "PortAssetService.DeletePort", + "service": "PortAssetService", + "operation": "DeletePort", + "kind": "mutation", + "description": "删除端口资产", + "tags": [ + "端口资产" + ] + }, + { + "method": "PortAssetService.GetPortAssetList", + "service": "PortAssetService", + "operation": "GetPortAssetList", + "kind": "readonly", + "description": "获取端口资产列表", + "tags": [ + "端口资产" + ] + }, + { + "method": "PortAssetService.GetPortAssetListByCmd", + "service": "PortAssetService", + "operation": "GetPortAssetListByCmd", + "kind": "readonly", + "description": "进程名数据分组,按照指定条件获取端口资产", + "tags": [ + "端口资产" + ] + }, + { + "method": "PortAssetService.GetPortAssetListByProtocolIPPort", + "service": "PortAssetService", + "operation": "GetPortAssetListByProtocolIPPort", + "kind": "readonly", + "description": "监听端口协议数据分组,按照指定要求获取端口", + "tags": [ + "端口资产" + ] + }, + { + "method": "PortAssetService.GetPortAssetListByProtocolPort", + "service": "PortAssetService", + "operation": "GetPortAssetListByProtocolPort", + "kind": "readonly", + "description": "监听端口数据分组,按照指定要求获取端口", + "tags": [ + "端口资产" + ] + }, + { + "method": "PortAssetService.StatPort", + "service": "PortAssetService", + "operation": "StatPort", + "kind": "readonly", + "description": "表示端口开放情况", + "tags": [ + "端口资产" + ] + }, + { + "method": "ProcessAssetService.GetProcess", + "service": "ProcessAssetService", + "operation": "GetProcess", + "kind": "readonly", + "description": "获取进程资产详情", + "tags": [ + "进程资产" + ] + }, + { + "method": "ProcessAssetService.GetProcessList", + "service": "ProcessAssetService", + "operation": "GetProcessList", + "kind": "readonly", + "description": "获取主机上进程资产列表", + "tags": [ + "进程资产" + ] + }, + { + "method": "ProcessAssetService.GetProcessListByHost", + "service": "ProcessAssetService", + "operation": "GetProcessListByHost", + "kind": "readonly", + "description": "主机数据分组,按照指定条件获取进程资产列表", + "tags": [ + "进程资产" + ] + }, + { + "method": "ProcessAssetService.GetProcessListByName", + "service": "ProcessAssetService", + "operation": "GetProcessListByName", + "kind": "readonly", + "description": "进程名数据分组,按照指定信息获取主机上进程资产列表", + "tags": [ + "进程资产" + ] + }, + { + "method": "ProcessAssetService.GetProcessListByPath", + "service": "ProcessAssetService", + "operation": "GetProcessListByPath", + "kind": "readonly", + "description": "获取主机上进程列表", + "tags": [ + "进程资产" + ] + }, + { + "method": "ProcessAssetService.GetRefreshDateTime", + "service": "ProcessAssetService", + "operation": "GetRefreshDateTime", + "kind": "readonly", + "description": "获取进程资产的更新时间", + "tags": [ + "进程资产" + ] + }, + { + "method": "ProcessAssetService.Refresh", + "service": "ProcessAssetService", + "operation": "Refresh", + "kind": "mutation", + "description": "更新进程资产", + "tags": [ + "进程资产" + ] + }, + { + "method": "ProcessAssetService.StatProcessByEffectiveUser", + "service": "ProcessAssetService", + "operation": "StatProcessByEffectiveUser", + "kind": "readonly", + "description": "有效用户数据分组下,按照指定条件返回进程资产列表", + "tags": [ + "进程资产" + ] + }, + { + "method": "ProcessAssetService.StatProcessBySafety", + "service": "ProcessAssetService", + "operation": "StatProcessBySafety", + "kind": "readonly", + "description": "进程安全性数据分组下,按照指定条件返回进程资产列表", + "tags": [ + "进程资产" + ] + }, + { + "method": "ProcessAssetService.StatProcessByStatus", + "service": "ProcessAssetService", + "operation": "StatProcessByStatus", + "kind": "readonly", + "description": "进程状态数据分组下,按照指定条件返回进程资产列表", + "tags": [ + "进程资产" + ] + }, + { + "method": "ProcessAssetService.WhitelistProcess", + "service": "ProcessAssetService", + "operation": "WhitelistProcess", + "kind": "mutation", + "description": "给进程加白名单", + "tags": [ + "进程资产" + ] + }, + { + "method": "ProcessKillService.DeleteEvent", + "service": "ProcessKillService", + "operation": "DeleteEvent", + "kind": "mutation", + "description": "删除事件", + "tags": [ + "NetworkReject" + ] + }, + { + "method": "ProcessKillService.ListEvent", + "service": "ProcessKillService", + "operation": "ListEvent", + "kind": "readonly", + "description": "获取事件列表", + "tags": [ + "NetworkReject" + ] + }, + { + "method": "ReportService.CopyReport", + "service": "ReportService", + "operation": "CopyReport", + "kind": "mutation", + "description": "复制报告", + "tags": [ + "报告管理" + ] + }, + { + "method": "ReportService.CopyTemplate", + "service": "ReportService", + "operation": "CopyTemplate", + "kind": "mutation", + "description": "复制模板", + "tags": [ + "报告管理" + ] + }, + { + "method": "ReportService.CreateReport", + "service": "ReportService", + "operation": "CreateReport", + "kind": "mutation", + "description": "添加报告", + "tags": [ + "报告管理" + ] + }, + { + "method": "ReportService.CreateTemplate", + "service": "ReportService", + "operation": "CreateTemplate", + "kind": "mutation", + "description": "创建模板", + "tags": [ + "报告管理" + ] + }, + { + "method": "ReportService.DeleteReport", + "service": "ReportService", + "operation": "DeleteReport", + "kind": "mutation", + "description": "删除报告", + "tags": [ + "报告管理" + ] + }, + { + "method": "ReportService.DeleteReportHistory", + "service": "ReportService", + "operation": "DeleteReportHistory", + "kind": "mutation", + "description": "删除历史报告", + "tags": [ + "报告管理" + ] + }, + { + "method": "ReportService.DeleteTemplate", + "service": "ReportService", + "operation": "DeleteTemplate", + "kind": "mutation", + "description": "删除模板", + "tags": [ + "报告管理" + ] + }, + { + "method": "ReportService.EditReport", + "service": "ReportService", + "operation": "EditReport", + "kind": "mutation", + "description": "编辑报告", + "tags": [ + "报告管理" + ] + }, + { + "method": "ReportService.EditTemplate", + "service": "ReportService", + "operation": "EditTemplate", + "kind": "mutation", + "description": "编辑模板", + "tags": [ + "报告管理" + ] + }, + { + "method": "ReportService.GenerateReport", + "service": "ReportService", + "operation": "GenerateReport", + "kind": "mutation", + "description": "生成报告", + "tags": [ + "报告管理" + ] + }, + { + "method": "ReportService.GetReport", + "service": "ReportService", + "operation": "GetReport", + "kind": "readonly", + "description": "获取报告详情", + "tags": [ + "报告管理" + ] + }, + { + "method": "ReportService.GetReportGeneratingInfo", + "service": "ReportService", + "operation": "GetReportGeneratingInfo", + "kind": "readonly", + "description": "生成报告", + "tags": [ + "报告管理" + ] + }, + { + "method": "ReportService.GetTemplate", + "service": "ReportService", + "operation": "GetTemplate", + "kind": "readonly", + "description": "获取模板", + "tags": [ + "报告管理" + ] + }, + { + "method": "ReportService.GetTemplateTree", + "service": "ReportService", + "operation": "GetTemplateTree", + "kind": "readonly", + "description": "获取模板树", + "tags": [ + "报告管理" + ] + }, + { + "method": "ReportService.GetTemplateTypes", + "service": "ReportService", + "operation": "GetTemplateTypes", + "kind": "readonly", + "description": "获取模板类型", + "tags": [ + "报告管理" + ] + }, + { + "method": "ReportService.ListReport", + "service": "ReportService", + "operation": "ListReport", + "kind": "readonly", + "description": "获取报告列表", + "tags": [ + "报告管理" + ] + }, + { + "method": "ReportService.ListReportHistory", + "service": "ReportService", + "operation": "ListReportHistory", + "kind": "readonly", + "description": "查阅历史报告", + "tags": [ + "报告管理" + ] + }, + { + "method": "ReportService.ListTemplate", + "service": "ReportService", + "operation": "ListTemplate", + "kind": "readonly", + "description": "获取模板列表", + "tags": [ + "报告管理" + ] + }, + { + "method": "RevshellEventService.CreateWhitelist", + "service": "RevshellEventService", + "operation": "CreateWhitelist", + "kind": "mutation", + "description": "生成白名单规则", + "tags": [ + "反弹 Shell" + ] + }, + { + "method": "RevshellEventService.DeleteEvent", + "service": "RevshellEventService", + "operation": "DeleteEvent", + "kind": "mutation", + "description": "删除所选的事件", + "tags": [ + "反弹 Shell" + ] + }, + { + "method": "RevshellEventService.EditEventComment", + "service": "RevshellEventService", + "operation": "EditEventComment", + "kind": "mutation", + "description": "改变所选事件备注", + "tags": [ + "反弹 Shell" + ] + }, + { + "method": "RevshellEventService.EditEventState", + "service": "RevshellEventService", + "operation": "EditEventState", + "kind": "mutation", + "description": "改变所选事件的处置状态", + "tags": [ + "反弹 Shell" + ] + }, + { + "method": "RevshellEventService.GenerateEventFirewallRule", + "service": "RevshellEventService", + "operation": "GenerateEventFirewallRule", + "kind": "mutation", + "description": "根据所选事件生成网络阻断规则", + "tags": [ + "反弹 Shell" + ] + }, + { + "method": "RevshellEventService.GetEvent", + "service": "RevshellEventService", + "operation": "GetEvent", + "kind": "readonly", + "description": "获取事件详情", + "tags": [ + "反弹 Shell" + ] + }, + { + "method": "RevshellEventService.GetEventByHostList", + "service": "RevshellEventService", + "operation": "GetEventByHostList", + "kind": "readonly", + "description": "获取按主机 ID 聚合的事件列表", + "tags": [ + "反弹 Shell" + ] + }, + { + "method": "RevshellEventService.GetEventByRemoteAddrList", + "service": "RevshellEventService", + "operation": "GetEventByRemoteAddrList", + "kind": "readonly", + "description": "获取按远程 IP 聚合的事件列表", + "tags": [ + "反弹 Shell" + ] + }, + { + "method": "RevshellEventService.GetEventList", + "service": "RevshellEventService", + "operation": "GetEventList", + "kind": "readonly", + "description": "获取事件列表", + "tags": [ + "反弹 Shell" + ] + }, + { + "method": "RevshellEventService.GetWhitelist", + "service": "RevshellEventService", + "operation": "GetWhitelist", + "kind": "readonly", + "description": "获取白名单规则", + "tags": [ + "反弹 Shell" + ] + }, + { + "method": "RevshellEventService.MarkAsRead", + "service": "RevshellEventService", + "operation": "MarkAsRead", + "kind": "mutation", + "description": "标记事件为已读", + "tags": [ + "反弹 Shell" + ] + }, + { + "method": "RevshellEventService.ProcessKill", + "service": "RevshellEventService", + "operation": "ProcessKill", + "kind": "mutation", + "description": "删除所选的事件", + "tags": [ + "反弹 Shell" + ] + }, + { + "method": "RevshellEventService.StatEventByAsset", + "service": "RevshellEventService", + "operation": "StatEventByAsset", + "kind": "readonly", + "description": "返回反弹shell最多的主机 top n ( n = 10 default )", + "tags": [ + "反弹 Shell" + ] + }, + { + "method": "RevshellEventService.StatEventByCmdline", + "service": "RevshellEventService", + "operation": "StatEventByCmdline", + "kind": "readonly", + "description": "返回反弹shell最多的cmdline top n ( n = 5 default )", + "tags": [ + "反弹 Shell" + ] + }, + { + "method": "RevshellEventService.StatEventByRemoteAddr", + "service": "RevshellEventService", + "operation": "StatEventByRemoteAddr", + "kind": "readonly", + "description": "返回反弹shell最多的远程地址 top n ( n = 10 default )", + "tags": [ + "反弹 Shell" + ] + }, + { + "method": "RevshellEventService.StatEventByState", + "service": "RevshellEventService", + "operation": "StatEventByState", + "kind": "readonly", + "description": "返回处置事件状态分布的统计信息", + "tags": [ + "反弹 Shell" + ] + }, + { + "method": "RevshellEventService.StatEventByTime", + "service": "RevshellEventService", + "operation": "StatEventByTime", + "kind": "readonly", + "description": "返回反弹shell按时间的趋势 top n ( n = 10 default )", + "tags": [ + "反弹 Shell" + ] + }, + { + "method": "RevshellEventService.StatEventByUname", + "service": "RevshellEventService", + "operation": "StatEventByUname", + "kind": "readonly", + "description": "返回反弹shell最多的User Name( 按shell权限 ) top n ( n = 5 default )", + "tags": [ + "反弹 Shell" + ] + }, + { + "method": "ScoutAgentApiService.GenerateInstallCommand", + "service": "ScoutAgentApiService", + "operation": "GenerateInstallCommand", + "kind": "mutation", + "description": "生成探针安装命令", + "tags": [ + "采集探针" + ] + }, + { + "method": "ScoutAgentApiService.GetAgentList", + "service": "ScoutAgentApiService", + "operation": "GetAgentList", + "kind": "readonly", + "description": "获取探针列表", + "tags": [ + "采集探针" + ] + }, + { + "method": "ScoutAgentApiService.SetCollectionRange", + "service": "ScoutAgentApiService", + "operation": "SetCollectionRange", + "kind": "mutation", + "description": "设置采集范围", + "tags": [ + "采集探针" + ] + }, + { + "method": "ScoutAgentApiService.SetKafkaAddrRange", + "service": "ScoutAgentApiService", + "operation": "SetKafkaAddrRange", + "kind": "mutation", + "description": "设置 kafka 地址", + "tags": [ + "采集探针" + ] + }, + { + "method": "ScoutAgentApiService.SetResourceLimit", + "service": "ScoutAgentApiService", + "operation": "SetResourceLimit", + "kind": "mutation", + "description": "设置资源配额", + "tags": [ + "采集探针" + ] + }, + { + "method": "ScoutAgentApiService.TestKafka", + "service": "ScoutAgentApiService", + "operation": "TestKafka", + "kind": "mutation", + "description": "测试 kafka", + "tags": [ + "采集探针" + ] + }, + { + "method": "ScoutAgentApiService.Uninstall", + "service": "ScoutAgentApiService", + "operation": "Uninstall", + "kind": "mutation", + "description": "卸载采集探针", + "tags": [ + "采集探针" + ] + }, + { + "method": "ScoutAgentApiService.Upgrade", + "service": "ScoutAgentApiService", + "operation": "Upgrade", + "kind": "mutation", + "description": "更新采集探针", + "tags": [ + "采集探针" + ] + }, + { + "method": "SecurityCheckService.DeleteEvent", + "service": "SecurityCheckService", + "operation": "DeleteEvent", + "kind": "mutation", + "description": "删除安全基线", + "tags": [ + "安全基线" + ] + }, + { + "method": "SecurityCheckService.EditEventComment", + "service": "SecurityCheckService", + "operation": "EditEventComment", + "kind": "mutation", + "description": "改变所选事件备注", + "tags": [ + "安全基线" + ] + }, + { + "method": "SecurityCheckService.EditEventState", + "service": "SecurityCheckService", + "operation": "EditEventState", + "kind": "mutation", + "description": "修改安全基线事件状态", + "tags": [ + "安全基线" + ] + }, + { + "method": "SecurityCheckService.GetEvent", + "service": "SecurityCheckService", + "operation": "GetEvent", + "kind": "readonly", + "description": "获取安全基线事件详情", + "tags": [ + "安全基线" + ] + }, + { + "method": "SecurityCheckService.GetEventList", + "service": "SecurityCheckService", + "operation": "GetEventList", + "kind": "readonly", + "description": "安全基线事件列表", + "tags": [ + "安全基线" + ] + }, + { + "method": "SecurityCheckService.GetEventListByHost", + "service": "SecurityCheckService", + "operation": "GetEventListByHost", + "kind": "readonly", + "description": "根据Host筛选条件获取安全基线事件列表", + "tags": [ + "安全基线" + ] + }, + { + "method": "SecurityCheckService.GetEventListByItemID", + "service": "SecurityCheckService", + "operation": "GetEventListByItemID", + "kind": "readonly", + "description": "根据风险筛选条件获取安全基线事件列表", + "tags": [ + "安全基线" + ] + }, + { + "method": "SecurityCheckService.GetPluginList", + "service": "SecurityCheckService", + "operation": "GetPluginList", + "kind": "readonly", + "description": "获取检测插件更新状态", + "tags": [ + "安全基线" + ] + }, + { + "method": "SecurityCheckService.RetestEvent", + "service": "SecurityCheckService", + "operation": "RetestEvent", + "kind": "mutation", + "description": "复测安全基线事件", + "tags": [ + "安全基线" + ] + }, + { + "method": "SecurityCheckService.StatEventByHost", + "service": "SecurityCheckService", + "operation": "StatEventByHost", + "kind": "readonly", + "description": "高风险主机", + "tags": [ + "安全基线" + ] + }, + { + "method": "SecurityCheckService.StatEventByItem", + "service": "SecurityCheckService", + "operation": "StatEventByItem", + "kind": "readonly", + "description": "高频风险", + "tags": [ + "安全基线" + ] + }, + { + "method": "SecurityCheckService.StatEventByLevelState", + "service": "SecurityCheckService", + "operation": "StatEventByLevelState", + "kind": "readonly", + "description": "时间状态分布", + "tags": [ + "安全基线" + ] + }, + { + "method": "SecurityCheckService.StatEventByTags", + "service": "SecurityCheckService", + "operation": "StatEventByTags", + "kind": "readonly", + "description": "风险特征分布", + "tags": [ + "安全基线" + ] + }, + { + "method": "SecurityCheckService.StatEventByTime", + "service": "SecurityCheckService", + "operation": "StatEventByTime", + "kind": "readonly", + "description": "风险发生趋势", + "tags": [ + "安全基线" + ] + }, + { + "method": "SecurityStrategyService.CreateSecurityStrategy", + "service": "SecurityStrategyService", + "operation": "CreateSecurityStrategy", + "kind": "mutation", + "description": "添加安全策略", + "tags": [ + "入侵检测配置" + ] + }, + { + "method": "SecurityStrategyService.DeleteSecurityStrategy", + "service": "SecurityStrategyService", + "operation": "DeleteSecurityStrategy", + "kind": "mutation", + "description": "删除安全策略", + "tags": [ + "入侵检测配置" + ] + }, + { + "method": "SecurityStrategyService.GetSecurityStrategyDetail", + "service": "SecurityStrategyService", + "operation": "GetSecurityStrategyDetail", + "kind": "readonly", + "description": "获取安全策略详情", + "tags": [ + "入侵检测配置" + ] + }, + { + "method": "SecurityStrategyService.GetSecurityStrategyTree", + "service": "SecurityStrategyService", + "operation": "GetSecurityStrategyTree", + "kind": "readonly", + "description": "获取安全策略树", + "tags": [ + "入侵检测配置" + ] + }, + { + "method": "SecurityStrategyService.ListSecurityStrategy", + "service": "SecurityStrategyService", + "operation": "ListSecurityStrategy", + "kind": "readonly", + "description": "获取安全策略列表", + "tags": [ + "入侵检测配置" + ] + }, + { + "method": "SecurityStrategyService.SetGroupStrategy", + "service": "SecurityStrategyService", + "operation": "SetGroupStrategy", + "kind": "mutation", + "description": "设置业务组的安全策略", + "tags": [ + "入侵检测配置" + ] + }, + { + "method": "SecurityStrategyService.UpdateSecurityStrategy", + "service": "SecurityStrategyService", + "operation": "UpdateSecurityStrategy", + "kind": "mutation", + "description": "更新安全策略", + "tags": [ + "入侵检测配置" + ] + }, + { + "method": "SensitiveFileService.DeleteEvent", + "service": "SensitiveFileService", + "operation": "DeleteEvent", + "kind": "mutation", + "description": "删除敏感文件事件", + "tags": [ + "敏感文件" + ] + }, + { + "method": "SensitiveFileService.EditEventComment", + "service": "SensitiveFileService", + "operation": "EditEventComment", + "kind": "mutation", + "description": "改变所选事件备注", + "tags": [ + "敏感文件" + ] + }, + { + "method": "SensitiveFileService.EditEventState", + "service": "SensitiveFileService", + "operation": "EditEventState", + "kind": "mutation", + "description": "修改敏感文件事件状态", + "tags": [ + "敏感文件" + ] + }, + { + "method": "SensitiveFileService.GetEvent", + "service": "SensitiveFileService", + "operation": "GetEvent", + "kind": "readonly", + "description": "获取敏感文件事件详情", + "tags": [ + "敏感文件" + ] + }, + { + "method": "SensitiveFileService.GetEventList", + "service": "SensitiveFileService", + "operation": "GetEventList", + "kind": "readonly", + "description": "获取敏感文件事件列表", + "tags": [ + "敏感文件" + ] + }, + { + "method": "SensitiveFileService.ResetEventState", + "service": "SensitiveFileService", + "operation": "ResetEventState", + "kind": "mutation", + "description": "重置事件状态为 'risky'", + "tags": [ + "敏感文件" + ] + }, + { + "method": "SensitivePortService.CreateWhitelist", + "service": "SensitivePortService", + "operation": "CreateWhitelist", + "kind": "mutation", + "description": "生成白名单规则", + "tags": [ + "敏感端口" + ] + }, + { + "method": "SensitivePortService.DeleteEvent", + "service": "SensitivePortService", + "operation": "DeleteEvent", + "kind": "mutation", + "description": "删除敏感端口事件", + "tags": [ + "敏感端口" + ] + }, + { + "method": "SensitivePortService.EditEventComment", + "service": "SensitivePortService", + "operation": "EditEventComment", + "kind": "mutation", + "description": "改变所选事件备注", + "tags": [ + "敏感端口" + ] + }, + { + "method": "SensitivePortService.EditEventState", + "service": "SensitivePortService", + "operation": "EditEventState", + "kind": "mutation", + "description": "修改敏感端口事件状态", + "tags": [ + "敏感端口" + ] + }, + { + "method": "SensitivePortService.GetEvent", + "service": "SensitivePortService", + "operation": "GetEvent", + "kind": "readonly", + "description": "获取事件详情", + "tags": [ + "敏感端口" + ] + }, + { + "method": "SensitivePortService.GetEventList", + "service": "SensitivePortService", + "operation": "GetEventList", + "kind": "readonly", + "description": "获取敏感端口事件列表", + "tags": [ + "敏感端口" + ] + }, + { + "method": "SensitivePortService.GetHostViewEventList", + "service": "SensitivePortService", + "operation": "GetHostViewEventList", + "kind": "readonly", + "description": "获取敏感端口事件列表(主机视角)", + "tags": [ + "敏感端口" + ] + }, + { + "method": "SensitivePortService.GetPortViewEventList", + "service": "SensitivePortService", + "operation": "GetPortViewEventList", + "kind": "readonly", + "description": "获取敏感端口事件列表(端口视角)", + "tags": [ + "敏感端口" + ] + }, + { + "method": "SensitivePortService.GetProcessViewEventList", + "service": "SensitivePortService", + "operation": "GetProcessViewEventList", + "kind": "readonly", + "description": "获取敏感端口事件列表(进程视角)", + "tags": [ + "敏感端口" + ] + }, + { + "method": "SensitivePortService.GetWhitelist", + "service": "SensitivePortService", + "operation": "GetWhitelist", + "kind": "readonly", + "description": "获取白名单规则", + "tags": [ + "敏感端口" + ] + }, + { + "method": "SensitivePortService.RetestEvent", + "service": "SensitivePortService", + "operation": "RetestEvent", + "kind": "mutation", + "description": "复测事件", + "tags": [ + "敏感端口" + ] + }, + { + "method": "SensitivePortService.StatEventByCmd", + "service": "SensitivePortService", + "operation": "StatEventByCmd", + "kind": "readonly", + "description": "敏感进程分布", + "tags": [ + "敏感端口" + ] + }, + { + "method": "SensitivePortService.StatEventByHost", + "service": "SensitivePortService", + "operation": "StatEventByHost", + "kind": "readonly", + "description": "敏感端口分布-主机维度", + "tags": [ + "敏感端口" + ] + }, + { + "method": "SensitivePortService.StatEventByPort", + "service": "SensitivePortService", + "operation": "StatEventByPort", + "kind": "readonly", + "description": "敏感端口分布", + "tags": [ + "敏感端口" + ] + }, + { + "method": "SensitivePortService.StatEventByState", + "service": "SensitivePortService", + "operation": "StatEventByState", + "kind": "readonly", + "description": "事件状态分布", + "tags": [ + "敏感端口" + ] + }, + { + "method": "SensitivePortService.StatEventByTime", + "service": "SensitivePortService", + "operation": "StatEventByTime", + "kind": "readonly", + "description": "事件时间分布", + "tags": [ + "敏感端口" + ] + }, + { + "method": "StatisticsService.GetEventOverview", + "service": "StatisticsService", + "operation": "GetEventOverview", + "kind": "readonly", + "description": "获取事件描述", + "tags": [ + "态势感知" + ] + }, + { + "method": "StatisticsService.GetHostScore", + "service": "StatisticsService", + "operation": "GetHostScore", + "kind": "readonly", + "description": "获取主机得分与事件概况", + "tags": [ + "态势感知" + ] + }, + { + "method": "StatisticsService.GetScanTime", + "service": "StatisticsService", + "operation": "GetScanTime", + "kind": "readonly", + "description": "获取最近一次扫描任务完成时间", + "tags": [ + "态势感知" + ] + }, + { + "method": "SuspiciousOperationService.CreateWhitelist", + "service": "SuspiciousOperationService", + "operation": "CreateWhitelist", + "kind": "mutation", + "description": "生成白名单规则", + "tags": [ + "可疑命令" + ] + }, + { + "method": "SuspiciousOperationService.DeleteEvent", + "service": "SuspiciousOperationService", + "operation": "DeleteEvent", + "kind": "mutation", + "description": "删除事件", + "tags": [ + "可疑命令" + ] + }, + { + "method": "SuspiciousOperationService.EditEventComment", + "service": "SuspiciousOperationService", + "operation": "EditEventComment", + "kind": "mutation", + "description": "改变所选事件备注", + "tags": [ + "可疑命令" + ] + }, + { + "method": "SuspiciousOperationService.EditEventState", + "service": "SuspiciousOperationService", + "operation": "EditEventState", + "kind": "mutation", + "description": "编辑事件状态", + "tags": [ + "可疑命令" + ] + }, + { + "method": "SuspiciousOperationService.GetEvent", + "service": "SuspiciousOperationService", + "operation": "GetEvent", + "kind": "readonly", + "description": "获取命令详情", + "tags": [ + "可疑命令" + ] + }, + { + "method": "SuspiciousOperationService.GetEventList", + "service": "SuspiciousOperationService", + "operation": "GetEventList", + "kind": "readonly", + "description": "获取命令列表", + "tags": [ + "可疑命令" + ] + }, + { + "method": "SuspiciousOperationService.GetEventListByHost", + "service": "SuspiciousOperationService", + "operation": "GetEventListByHost", + "kind": "readonly", + "description": "返回按 主机 聚合的可疑操作列表", + "tags": [ + "可疑命令" + ] + }, + { + "method": "SuspiciousOperationService.GetEventListByNetApp", + "service": "SuspiciousOperationService", + "operation": "GetEventListByNetApp", + "kind": "readonly", + "description": "返回按 网络应用 聚合的可疑操作列表", + "tags": [ + "可疑命令" + ] + }, + { + "method": "SuspiciousOperationService.GetEventListByRuleName", + "service": "SuspiciousOperationService", + "operation": "GetEventListByRuleName", + "kind": "readonly", + "description": "返回按 规则 聚合的可疑操作列表", + "tags": [ + "可疑命令" + ] + }, + { + "method": "SuspiciousOperationService.GetEventListBySSHClientIP", + "service": "SuspiciousOperationService", + "operation": "GetEventListBySSHClientIP", + "kind": "readonly", + "description": "返回按 SSH登录 聚合的可疑操作列表", + "tags": [ + "可疑命令" + ] + }, + { + "method": "SuspiciousOperationService.GetWhitelist", + "service": "SuspiciousOperationService", + "operation": "GetWhitelist", + "kind": "readonly", + "description": "获取白名单规则", + "tags": [ + "可疑命令" + ] + }, + { + "method": "SuspiciousOperationService.MarkAsRead", + "service": "SuspiciousOperationService", + "operation": "MarkAsRead", + "kind": "mutation", + "description": "标记事件为已读", + "tags": [ + "可疑命令" + ] + }, + { + "method": "SuspiciousOperationService.ProcessKill", + "service": "SuspiciousOperationService", + "operation": "ProcessKill", + "kind": "mutation", + "description": "进程阻断", + "tags": [ + "可疑命令" + ] + }, + { + "method": "SuspiciousOperationService.StatEventByHost", + "service": "SuspiciousOperationService", + "operation": "StatEventByHost", + "kind": "readonly", + "description": "返回按 主机 聚合的统计视图", + "tags": [ + "可疑命令" + ] + }, + { + "method": "SuspiciousOperationService.StatEventByLevelState", + "service": "SuspiciousOperationService", + "operation": "StatEventByLevelState", + "kind": "readonly", + "description": "返回按 风险级别类型 聚合的统计视图", + "tags": [ + "可疑命令" + ] + }, + { + "method": "SuspiciousOperationService.StatEventByNetApp", + "service": "SuspiciousOperationService", + "operation": "StatEventByNetApp", + "kind": "readonly", + "description": "返回按 网络应用 聚合的统计视图", + "tags": [ + "可疑命令" + ] + }, + { + "method": "SuspiciousOperationService.StatEventByRuleName", + "service": "SuspiciousOperationService", + "operation": "StatEventByRuleName", + "kind": "readonly", + "description": "返回按 规则名称 聚合的统计视图", + "tags": [ + "可疑命令" + ] + }, + { + "method": "SuspiciousOperationService.StatEventBySSHClientIP", + "service": "SuspiciousOperationService", + "operation": "StatEventBySSHClientIP", + "kind": "readonly", + "description": "返回按 SSH登录地址 聚合的统计视图", + "tags": [ + "可疑命令" + ] + }, + { + "method": "SuspiciousOperationService.StatEventByTime", + "service": "SuspiciousOperationService", + "operation": "StatEventByTime", + "kind": "readonly", + "description": "返回按 创建日期 聚合的统计视图", + "tags": [ + "可疑命令" + ] + }, + { + "method": "TamperProofService.AddRule", + "service": "TamperProofService", + "operation": "AddRule", + "kind": "mutation", + "description": "添加规则", + "tags": [ + "TamperProof" + ] + }, + { + "method": "TamperProofService.DeleteEventList", + "service": "TamperProofService", + "operation": "DeleteEventList", + "kind": "mutation", + "description": "删除事件", + "tags": [ + "TamperProof" + ] + }, + { + "method": "TamperProofService.DeleteRuleList", + "service": "TamperProofService", + "operation": "DeleteRuleList", + "kind": "mutation", + "description": "删除规则", + "tags": [ + "TamperProof" + ] + }, + { + "method": "TamperProofService.DisableHostList", + "service": "TamperProofService", + "operation": "DisableHostList", + "kind": "mutation", + "description": "关闭多个主机的文件防篡改功能", + "tags": [ + "TamperProof" + ] + }, + { + "method": "TamperProofService.EditEventListRead", + "service": "TamperProofService", + "operation": "EditEventListRead", + "kind": "mutation", + "description": "将多个事件置为已读或未读", + "tags": [ + "TamperProof" + ] + }, + { + "method": "TamperProofService.EditRule", + "service": "TamperProofService", + "operation": "EditRule", + "kind": "mutation", + "description": "修改防篡改规则", + "tags": [ + "TamperProof" + ] + }, + { + "method": "TamperProofService.EditRuleEnable", + "service": "TamperProofService", + "operation": "EditRuleEnable", + "kind": "mutation", + "description": "修改防篡改规则", + "tags": [ + "TamperProof" + ] + }, + { + "method": "TamperProofService.EnableHostList", + "service": "TamperProofService", + "operation": "EnableHostList", + "kind": "mutation", + "description": "开启多个主机的文件防篡改功能", + "tags": [ + "TamperProof" + ] + }, + { + "method": "TamperProofService.GetEventDetails", + "service": "TamperProofService", + "operation": "GetEventDetails", + "kind": "readonly", + "description": "事件的详情", + "tags": [ + "TamperProof" + ] + }, + { + "method": "TamperProofService.GetEventList", + "service": "TamperProofService", + "operation": "GetEventList", + "kind": "readonly", + "description": "文件篡改事件的列表", + "tags": [ + "TamperProof" + ] + }, + { + "method": "TamperProofService.GetHostList", + "service": "TamperProofService", + "operation": "GetHostList", + "kind": "readonly", + "description": "开启文件防篡改的主机列表", + "tags": [ + "TamperProof" + ] + }, + { + "method": "TamperProofService.GetMaxNum", + "service": "TamperProofService", + "operation": "GetMaxNum", + "kind": "readonly", + "description": "获取最大文件防篡改探针授权数量", + "tags": [ + "TamperProof" + ] + }, + { + "method": "TamperProofService.GetModuleList", + "service": "TamperProofService", + "operation": "GetModuleList", + "kind": "readonly", + "description": "文件防篡改模块的列表", + "tags": [ + "TamperProof" + ] + }, + { + "method": "TamperProofService.GetRule", + "service": "TamperProofService", + "operation": "GetRule", + "kind": "readonly", + "description": "获取单个文件防篡改规则", + "tags": [ + "TamperProof" + ] + }, + { + "method": "TamperProofService.GetRuleList", + "service": "TamperProofService", + "operation": "GetRuleList", + "kind": "readonly", + "description": "文件防篡改规则的列表", + "tags": [ + "TamperProof" + ] + }, + { + "method": "TamperProofService.Install", + "service": "TamperProofService", + "operation": "Install", + "kind": "mutation", + "description": "给主机安装防篡改模块", + "tags": [ + "TamperProof" + ] + }, + { + "method": "TamperProofService.Uninstall", + "service": "TamperProofService", + "operation": "Uninstall", + "kind": "mutation", + "description": "给主机拆卸防篡改模块", + "tags": [ + "TamperProof" + ] + }, + { + "method": "ThreatOverviewService.GetProcessedEventInfo", + "service": "ThreatOverviewService", + "operation": "GetProcessedEventInfo", + "kind": "readonly", + "description": "事件处置情况", + "tags": [ + "威胁事件" + ] + }, + { + "method": "ThreatOverviewService.ListEventDetectedTrendInfo", + "service": "ThreatOverviewService", + "operation": "ListEventDetectedTrendInfo", + "kind": "readonly", + "description": "事件发生趋势", + "tags": [ + "威胁事件" + ] + }, + { + "method": "ThreatOverviewService.ListEventTypeDistInfo", + "service": "ThreatOverviewService", + "operation": "ListEventTypeDistInfo", + "kind": "readonly", + "description": "事件类型分布", + "tags": [ + "威胁事件" + ] + }, + { + "method": "ThreatOverviewService.ListGetRiskyHostInfo", + "service": "ThreatOverviewService", + "operation": "ListGetRiskyHostInfo", + "kind": "readonly", + "description": "高风险主机", + "tags": [ + "威胁事件" + ] + }, + { + "method": "ThreatOverviewService.ListRealTimeEvents", + "service": "ThreatOverviewService", + "operation": "ListRealTimeEvents", + "kind": "readonly", + "description": "获取事件描述", + "tags": [ + "威胁事件" + ] + }, + { + "method": "UserAssetService.GetUserAuthorizedKeys", + "service": "UserAssetService", + "operation": "GetUserAuthorizedKeys", + "kind": "readonly", + "description": "获取用户公钥信息", + "tags": [ + "用户资产" + ] + }, + { + "method": "UserAssetService.GetUserList", + "service": "UserAssetService", + "operation": "GetUserList", + "kind": "readonly", + "description": "根据指定条件获取用户资产列表", + "tags": [ + "用户资产" + ] + }, + { + "method": "UserAssetService.GetUserListByUsername", + "service": "UserAssetService", + "operation": "GetUserListByUsername", + "kind": "readonly", + "description": "用户名数据分组,根据指定的条件获取用户资产列表", + "tags": [ + "用户资产" + ] + }, + { + "method": "UserAssetService.GetWindowsDomainUserList", + "service": "UserAssetService", + "operation": "GetWindowsDomainUserList", + "kind": "readonly", + "description": "获取 windows 域用户列表", + "tags": [ + "用户资产" + ] + }, + { + "method": "UserAssetService.GetWindowsDomainUserListByDomain", + "service": "UserAssetService", + "operation": "GetWindowsDomainUserListByDomain", + "kind": "readonly", + "description": "获取 windows 域用户列表,按域聚合", + "tags": [ + "用户资产" + ] + }, + { + "method": "UserAssetService.GetWindowsUserList", + "service": "UserAssetService", + "operation": "GetWindowsUserList", + "kind": "readonly", + "description": "获取 windows 用户列表", + "tags": [ + "用户资产" + ] + }, + { + "method": "UserAssetService.GetWindowsUserListByHost", + "service": "UserAssetService", + "operation": "GetWindowsUserListByHost", + "kind": "readonly", + "description": "获取 windows 用户列表,按主机聚合", + "tags": [ + "用户资产" + ] + }, + { + "method": "UserAssetService.GetWindowsUserListByUserName", + "service": "UserAssetService", + "operation": "GetWindowsUserListByUserName", + "kind": "readonly", + "description": "取 windows 用户列表,按用户名聚合", + "tags": [ + "用户资产" + ] + }, + { + "method": "UserAssetService.GetWindowsUserListByUsername", + "service": "UserAssetService", + "operation": "GetWindowsUserListByUsername", + "kind": "readonly", + "description": "获取 windows 域用户列表,按用户名聚合", + "tags": [ + "用户资产" + ] + }, + { + "method": "UserAssetService.StatUser", + "service": "UserAssetService", + "operation": "StatUser", + "kind": "readonly", + "description": "用户统计信息", + "tags": [ + "用户资产" + ] + }, + { + "method": "VulnInfoService.GetVulnInfo", + "service": "VulnInfoService", + "operation": "GetVulnInfo", + "kind": "readonly", + "description": "敏感端口事件导出", + "tags": [ + "漏洞情报" + ] + }, + { + "method": "VulnInfoService.ListVulnInfo", + "service": "VulnInfoService", + "operation": "ListVulnInfo", + "kind": "readonly", + "description": "获取事件列表", + "tags": [ + "漏洞情报" + ] + }, + { + "method": "VulnInfoService.StatVulnInfoByLevel", + "service": "VulnInfoService", + "operation": "StatVulnInfoByLevel", + "kind": "readonly", + "description": "漏洞级别分布", + "tags": [ + "漏洞情报" + ] + }, + { + "method": "VulnInfoService.TrendVulnInfo", + "service": "VulnInfoService", + "operation": "TrendVulnInfo", + "kind": "readonly", + "description": "漏洞公布时间趋势", + "tags": [ + "漏洞情报" + ] + }, + { + "method": "VulnService.CreateWhitelist", + "service": "VulnService", + "operation": "CreateWhitelist", + "kind": "mutation", + "description": "生成白名单规则", + "tags": [ + "通用漏洞" + ] + }, + { + "method": "VulnService.DeleteVuln", + "service": "VulnService", + "operation": "DeleteVuln", + "kind": "mutation", + "description": "根据漏洞 ID 删除漏洞", + "tags": [ + "通用漏洞" + ] + }, + { + "method": "VulnService.EditEventComment", + "service": "VulnService", + "operation": "EditEventComment", + "kind": "mutation", + "description": "改变所选事件备注", + "tags": [ + "通用漏洞" + ] + }, + { + "method": "VulnService.EditVulnState", + "service": "VulnService", + "operation": "EditVulnState", + "kind": "mutation", + "description": "修改漏洞事件状态", + "tags": [ + "通用漏洞" + ] + }, + { + "method": "VulnService.GetVuln", + "service": "VulnService", + "operation": "GetVuln", + "kind": "readonly", + "description": "获取漏洞事件详情", + "tags": [ + "通用漏洞" + ] + }, + { + "method": "VulnService.GetVulnByAppList", + "service": "VulnService", + "operation": "GetVulnByAppList", + "kind": "readonly", + "description": "根据影响软件返回漏洞事件列表", + "tags": [ + "通用漏洞" + ] + }, + { + "method": "VulnService.GetVulnByHostList", + "service": "VulnService", + "operation": "GetVulnByHostList", + "kind": "readonly", + "description": "根据主机返回漏洞事件列表", + "tags": [ + "通用漏洞" + ] + }, + { + "method": "VulnService.GetVulnByVulnList", + "service": "VulnService", + "operation": "GetVulnByVulnList", + "kind": "readonly", + "description": "根据漏洞筛选调教返回漏洞事件列表", + "tags": [ + "通用漏洞" + ] + }, + { + "method": "VulnService.GetVulnList", + "service": "VulnService", + "operation": "GetVulnList", + "kind": "readonly", + "description": "获取通用漏洞事件列表", + "tags": [ + "通用漏洞" + ] + }, + { + "method": "VulnService.GetVulnTypes", + "service": "VulnService", + "operation": "GetVulnTypes", + "kind": "readonly", + "description": "获取漏洞类型", + "tags": [ + "通用漏洞" + ] + }, + { + "method": "VulnService.GetWhitelist", + "service": "VulnService", + "operation": "GetWhitelist", + "kind": "readonly", + "description": "获取白名单规则", + "tags": [ + "通用漏洞" + ] + }, + { + "method": "VulnService.RetestVuln", + "service": "VulnService", + "operation": "RetestVuln", + "kind": "mutation", + "description": "根据漏洞事件 ID 列表进行复测", + "tags": [ + "通用漏洞" + ] + }, + { + "method": "VulnService.StatVulnByAV", + "service": "VulnService", + "operation": "StatVulnByAV", + "kind": "readonly", + "description": "漏洞的攻击途径分布", + "tags": [ + "通用漏洞" + ] + }, + { + "method": "VulnService.StatVulnByLevelState", + "service": "VulnService", + "operation": "StatVulnByLevelState", + "kind": "readonly", + "description": "返回按 风险级别类型 聚合的统计视图", + "tags": [ + "通用漏洞" + ] + }, + { + "method": "VulnService.StatVulnByTag", + "service": "VulnService", + "operation": "StatVulnByTag", + "kind": "readonly", + "description": "漏洞的标签分布", + "tags": [ + "通用漏洞" + ] + }, + { + "method": "VulnService.StatVulnByTime", + "service": "VulnService", + "operation": "StatVulnByTime", + "kind": "readonly", + "description": "返回按 时间 聚合的统计视图", + "tags": [ + "通用漏洞" + ] + }, + { + "method": "VulnService.StatVulnProcessByTime", + "service": "VulnService", + "operation": "StatVulnProcessByTime", + "kind": "readonly", + "description": "根据时间汇总漏洞处理详情", + "tags": [ + "通用漏洞" + ] + }, + { + "method": "WeakPasswdService.CreateDict", + "service": "WeakPasswdService", + "operation": "CreateDict", + "kind": "mutation", + "description": "创建弱口令字典", + "tags": [ + "弱口令检测" + ] + }, + { + "method": "WeakPasswdService.CreateWhitelist", + "service": "WeakPasswdService", + "operation": "CreateWhitelist", + "kind": "mutation", + "description": "生成白名单规则", + "tags": [ + "弱口令检测" + ] + }, + { + "method": "WeakPasswdService.Delete", + "service": "WeakPasswdService", + "operation": "Delete", + "kind": "mutation", + "description": "删除弱口令事件", + "tags": [ + "弱口令检测" + ] + }, + { + "method": "WeakPasswdService.DeleteDict", + "service": "WeakPasswdService", + "operation": "DeleteDict", + "kind": "mutation", + "description": "删除弱口令字典", + "tags": [ + "弱口令检测" + ] + }, + { + "method": "WeakPasswdService.DictList", + "service": "WeakPasswdService", + "operation": "DictList", + "kind": "mutation", + "description": "弱口令字典列表", + "tags": [ + "弱口令检测" + ] + }, + { + "method": "WeakPasswdService.EditDict", + "service": "WeakPasswdService", + "operation": "EditDict", + "kind": "mutation", + "description": "修改弱口令字典", + "tags": [ + "弱口令检测" + ] + }, + { + "method": "WeakPasswdService.EditEventComment", + "service": "WeakPasswdService", + "operation": "EditEventComment", + "kind": "mutation", + "description": "改变所选事件备注", + "tags": [ + "弱口令检测" + ] + }, + { + "method": "WeakPasswdService.EditState", + "service": "WeakPasswdService", + "operation": "EditState", + "kind": "mutation", + "description": "修改弱口令事件状态", + "tags": [ + "弱口令检测" + ] + }, + { + "method": "WeakPasswdService.EnableDict", + "service": "WeakPasswdService", + "operation": "EnableDict", + "kind": "mutation", + "description": "启用弱口令字典", + "tags": [ + "弱口令检测" + ] + }, + { + "method": "WeakPasswdService.Get", + "service": "WeakPasswdService", + "operation": "Get", + "kind": "readonly", + "description": "获取弱口令事件详情", + "tags": [ + "弱口令检测" + ] + }, + { + "method": "WeakPasswdService.GetAggEventList", + "service": "WeakPasswdService", + "operation": "GetAggEventList", + "kind": "readonly", + "description": "获取数据分组后弱口令事件列表", + "tags": [ + "弱口令检测" + ] + }, + { + "method": "WeakPasswdService.GetDict", + "service": "WeakPasswdService", + "operation": "GetDict", + "kind": "readonly", + "description": "获取弱口令字典详情", + "tags": [ + "弱口令检测" + ] + }, + { + "method": "WeakPasswdService.GetWhitelist", + "service": "WeakPasswdService", + "operation": "GetWhitelist", + "kind": "readonly", + "description": "获取白名单规则", + "tags": [ + "弱口令检测" + ] + }, + { + "method": "WeakPasswdService.HistoryList", + "service": "WeakPasswdService", + "operation": "HistoryList", + "kind": "mutation", + "description": "弱口令事件变更记录", + "tags": [ + "弱口令检测" + ] + }, + { + "method": "WeakPasswdService.List", + "service": "WeakPasswdService", + "operation": "List", + "kind": "readonly", + "description": "弱口令事件列表", + "tags": [ + "弱口令检测" + ] + }, + { + "method": "WeakPasswdService.ListByHost", + "service": "WeakPasswdService", + "operation": "ListByHost", + "kind": "readonly", + "description": "根据Host筛选条件获取弱口令事件列表", + "tags": [ + "弱口令检测" + ] + }, + { + "method": "WeakPasswdService.ListByPlan", + "service": "WeakPasswdService", + "operation": "ListByPlan", + "kind": "readonly", + "description": "根据PlanId筛选条件获取弱口令事件列表", + "tags": [ + "弱口令检测" + ] + }, + { + "method": "WeakPasswdService.ListByService", + "service": "WeakPasswdService", + "operation": "ListByService", + "kind": "readonly", + "description": "根据服务筛选条件获取弱口令事件列表", + "tags": [ + "弱口令检测" + ] + }, + { + "method": "WeakPasswdService.RetestEvent", + "service": "WeakPasswdService", + "operation": "RetestEvent", + "kind": "mutation", + "description": "复测弱口令事件", + "tags": [ + "弱口令检测" + ] + }, + { + "method": "WeakPasswdService.StatEventByHost", + "service": "WeakPasswdService", + "operation": "StatEventByHost", + "kind": "readonly", + "description": "高风险主机", + "tags": [ + "弱口令检测" + ] + }, + { + "method": "WeakPasswdService.StatEventByPassword", + "service": "WeakPasswdService", + "operation": "StatEventByPassword", + "kind": "readonly", + "description": "高风险密码", + "tags": [ + "弱口令检测" + ] + }, + { + "method": "WeakPasswdService.StatEventByService", + "service": "WeakPasswdService", + "operation": "StatEventByService", + "kind": "readonly", + "description": "弱口令服务类型分布", + "tags": [ + "弱口令检测" + ] + }, + { + "method": "WeakPasswdService.StatEventByState", + "service": "WeakPasswdService", + "operation": "StatEventByState", + "kind": "readonly", + "description": "弱口令事件状态分布", + "tags": [ + "弱口令检测" + ] + }, + { + "method": "WeakPasswdService.StatEventByTime", + "service": "WeakPasswdService", + "operation": "StatEventByTime", + "kind": "readonly", + "description": "弱口令事件发生趋势", + "tags": [ + "弱口令检测" + ] + }, + { + "method": "WeakPasswdService.StatEventByType", + "service": "WeakPasswdService", + "operation": "StatEventByType", + "kind": "readonly", + "description": "弱口令事件状态分布", + "tags": [ + "弱口令检测" + ] + }, + { + "method": "WeakPasswdService.StatEventByUsername", + "service": "WeakPasswdService", + "operation": "StatEventByUsername", + "kind": "readonly", + "description": "高风险用户名", + "tags": [ + "弱口令检测" + ] + }, + { + "method": "WebshellEventService.CreateWhitelist", + "service": "WebshellEventService", + "operation": "CreateWhitelist", + "kind": "mutation", + "description": "生成白名单规则", + "tags": [ + "WebShell" + ] + }, + { + "method": "WebshellEventService.DeleteEvent", + "service": "WebshellEventService", + "operation": "DeleteEvent", + "kind": "mutation", + "description": "删除所选事件", + "tags": [ + "WebShell" + ] + }, + { + "method": "WebshellEventService.EditEventComment", + "service": "WebshellEventService", + "operation": "EditEventComment", + "kind": "mutation", + "description": "改变所选事件备注", + "tags": [ + "WebShell" + ] + }, + { + "method": "WebshellEventService.EditEventState", + "service": "WebshellEventService", + "operation": "EditEventState", + "kind": "mutation", + "description": "改变所选事件的处置状态", + "tags": [ + "WebShell" + ] + }, + { + "method": "WebshellEventService.EditMimicryConfig", + "service": "WebshellEventService", + "operation": "EditMimicryConfig", + "kind": "mutation", + "description": "用来修改全局拟态防御的配置", + "tags": [ + "WebShell" + ] + }, + { + "method": "WebshellEventService.GetEvent", + "service": "WebshellEventService", + "operation": "GetEvent", + "kind": "readonly", + "description": "获取事件详情", + "tags": [ + "WebShell" + ] + }, + { + "method": "WebshellEventService.GetEventByHostList", + "service": "WebshellEventService", + "operation": "GetEventByHostList", + "kind": "readonly", + "description": "根据主机获取事件列表", + "tags": [ + "WebShell" + ] + }, + { + "method": "WebshellEventService.GetEventList", + "service": "WebshellEventService", + "operation": "GetEventList", + "kind": "readonly", + "description": "获取事件列表", + "tags": [ + "WebShell" + ] + }, + { + "method": "WebshellEventService.GetEventListByFileName", + "service": "WebshellEventService", + "operation": "GetEventListByFileName", + "kind": "readonly", + "description": "根据文件名获取事件列表", + "tags": [ + "WebShell" + ] + }, + { + "method": "WebshellEventService.GetEventListByFilePath", + "service": "WebshellEventService", + "operation": "GetEventListByFilePath", + "kind": "readonly", + "description": "根据文件路径获取事件列表", + "tags": [ + "WebShell" + ] + }, + { + "method": "WebshellEventService.GetEventListByWebshellType", + "service": "WebshellEventService", + "operation": "GetEventListByWebshellType", + "kind": "readonly", + "description": "根据Webshell 类型获取事件列表", + "tags": [ + "WebShell" + ] + }, + { + "method": "WebshellEventService.GetFile", + "service": "WebshellEventService", + "operation": "GetFile", + "kind": "readonly", + "description": "获取 Webshell 文件内容", + "tags": [ + "WebShell" + ] + }, + { + "method": "WebshellEventService.GetMimicryConfig", + "service": "WebshellEventService", + "operation": "GetMimicryConfig", + "kind": "readonly", + "description": "用来换取拟态防御全局配置", + "tags": [ + "WebShell" + ] + }, + { + "method": "WebshellEventService.GetOverview", + "service": "WebshellEventService", + "operation": "GetOverview", + "kind": "readonly", + "description": "获取 Webshell 概述信息", + "tags": [ + "WebShell" + ] + }, + { + "method": "WebshellEventService.GetWhitelist", + "service": "WebshellEventService", + "operation": "GetWhitelist", + "kind": "readonly", + "description": "获取白名单规则", + "tags": [ + "WebShell" + ] + }, + { + "method": "WebshellEventService.IsolateWebshell", + "service": "WebshellEventService", + "operation": "IsolateWebshell", + "kind": "readonly", + "description": "用来隔离主机上的 Webshell", + "tags": [ + "WebShell" + ] + }, + { + "method": "WebshellEventService.MarkAsRead", + "service": "WebshellEventService", + "operation": "MarkAsRead", + "kind": "mutation", + "description": "标记事件为已读", + "tags": [ + "WebShell" + ] + }, + { + "method": "WebshellEventService.RetestEvent", + "service": "WebshellEventService", + "operation": "RetestEvent", + "kind": "mutation", + "description": "触发一个探针端任务用来检测 Webshell 是否仍然存在", + "tags": [ + "WebShell" + ] + }, + { + "method": "WebshellEventService.StatEventByFileName", + "service": "WebshellEventService", + "operation": "StatEventByFileName", + "kind": "readonly", + "description": "获取按文件名聚合的统计结果", + "tags": [ + "WebShell" + ] + }, + { + "method": "WebshellEventService.StatEventByHost", + "service": "WebshellEventService", + "operation": "StatEventByHost", + "kind": "readonly", + "description": "获取按主机聚合的统计结果", + "tags": [ + "WebShell" + ] + }, + { + "method": "WebshellEventService.StatEventByIsolateState", + "service": "WebshellEventService", + "operation": "StatEventByIsolateState", + "kind": "readonly", + "description": "获取按 Webshell 隔离状态聚合的统计结果", + "tags": [ + "WebShell" + ] + }, + { + "method": "WebshellEventService.StatEventByLevel", + "service": "WebshellEventService", + "operation": "StatEventByLevel", + "kind": "readonly", + "description": "获取按风险等级与处置状态聚合的统计结果", + "tags": [ + "WebShell" + ] + }, + { + "method": "WebshellEventService.StatEventByTime", + "service": "WebshellEventService", + "operation": "StatEventByTime", + "kind": "readonly", + "description": "获取按事件发生事件聚合的统计结果", + "tags": [ + "WebShell" + ] + }, + { + "method": "WebshellEventService.StatEventByWebshellType", + "service": "WebshellEventService", + "operation": "StatEventByWebshellType", + "kind": "readonly", + "description": "获取按 Webshell 类型聚合的统计结果", + "tags": [ + "WebShell" + ] + }, + { + "method": "WebshellEventService.TestMimicryToken", + "service": "WebshellEventService", + "operation": "TestMimicryToken", + "kind": "mutation", + "description": "测试拟态防御服务平台token,如果是401说明token不正确,500说明token正确", + "tags": [ + "WebShell" + ] + }, + { + "method": "WebshellEventService.TrendWebshell", + "service": "WebshellEventService", + "operation": "TrendWebshell", + "kind": "readonly", + "description": "获取按事件发现日期(年,月,日)聚合的统计结果", + "tags": [ + "WebShell" + ] + }, + { + "method": "WebsiteAssetService.GetWebsite", + "service": "WebsiteAssetService", + "operation": "GetWebsite", + "kind": "readonly", + "description": "获取站点资产详情", + "tags": [ + "网站资产" + ] + }, + { + "method": "WebsiteAssetService.GetWebsiteList", + "service": "WebsiteAssetService", + "operation": "GetWebsiteList", + "kind": "readonly", + "description": "获取站点资产列表", + "tags": [ + "网站资产" + ] + }, + { + "method": "WhitelistService.CreateWhitelist", + "service": "WhitelistService", + "operation": "CreateWhitelist", + "kind": "mutation", + "description": "创建事件加白规则", + "tags": [ + "事件加白规则" + ] + }, + { + "method": "WhitelistService.DeleteWhitelist", + "service": "WhitelistService", + "operation": "DeleteWhitelist", + "kind": "mutation", + "description": "删除事件加白规则", + "tags": [ + "事件加白规则" + ] + }, + { + "method": "WhitelistService.EnablementWhitelist", + "service": "WhitelistService", + "operation": "EnablementWhitelist", + "kind": "mutation", + "description": "启禁用事件加白规则", + "tags": [ + "事件加白规则" + ] + }, + { + "method": "WhitelistService.ListWhitelist", + "service": "WhitelistService", + "operation": "ListWhitelist", + "kind": "readonly", + "description": "获取事件加白规则列表", + "tags": [ + "事件加白规则" + ] + }, + { + "method": "WhitelistService.UpdateWhitelist", + "service": "WhitelistService", + "operation": "UpdateWhitelist", + "kind": "mutation", + "description": "更新事件加白规则", + "tags": [ + "事件加白规则" + ] + } + ] +} diff --git a/.flocks/flockshub/plugins/tools/device/chaitin_muyun_api_v3/chaitin_muyun_api_mutation.yaml b/.flocks/flockshub/plugins/tools/device/chaitin_muyun_api_v3/chaitin_muyun_api_mutation.yaml new file mode 100644 index 000000000..b5cab6d0f --- /dev/null +++ b/.flocks/flockshub/plugins/tools/device/chaitin_muyun_api_v3/chaitin_muyun_api_mutation.yaml @@ -0,0 +1,28 @@ +name: chaitin_muyun_api_mutation +description: Chaitin Muyun documented mutation JSON-RPC caller with confirmation. +description_cn: 长亭牧云文档内变更类 JSON-RPC 调用工具。所有调用都需要确认。 +category: custom +enabled: true +requires_confirmation: true +provider: chaitin_muyun_api +version: "API 3.0" +inputSchema: + type: object + properties: + action: + type: string + enum: + - api_catalog + - rpc_call_mutation + method: + type: string + description: rpc_call_mutation 使用的 JSON-RPC 方法名,必须属于 catalog 中 kind=mutation 的条目。 + params: + type: object + description: JSON-RPC params 对象。 + required: + - action +handler: + type: script + script_file: chaitin_muyun.handler.py + function: api_mutation diff --git a/.flocks/flockshub/plugins/tools/device/chaitin_muyun_api_v3/chaitin_muyun_api_readonly.yaml b/.flocks/flockshub/plugins/tools/device/chaitin_muyun_api_v3/chaitin_muyun_api_readonly.yaml new file mode 100644 index 000000000..16b2b000f --- /dev/null +++ b/.flocks/flockshub/plugins/tools/device/chaitin_muyun_api_v3/chaitin_muyun_api_readonly.yaml @@ -0,0 +1,47 @@ +name: chaitin_muyun_api_readonly +description: Chaitin Muyun documented read-only JSON-RPC caller. +description_cn: 长亭牧云文档内只读 JSON-RPC 调用工具。使用 api_catalog 查看已收录方法,再用 rpc_call_readonly 调用 kind=readonly 的方法。 +category: custom +enabled: true +requires_confirmation: false +provider: chaitin_muyun_api +version: "API 3.0" +inputSchema: + type: object + properties: + action: + type: string + enum: + - api_catalog + - rpc_call_readonly + - product_info + - current_user + - host_count + - host_list + - host_detail + - application_list + - website_list + - process_list + - webshell_events + - malware_events + - bruteforce_events + - abnormal_login_events + - realtime_events + - vuln_list + - vuln_detail + - security_check_events + - baseline_tasks + - emergency_vulns + - test + method: + type: string + description: rpc_call_readonly 使用的 JSON-RPC 方法名,例如 HostAssetService.GetHostAssetList。 + params: + type: object + description: JSON-RPC params 对象。 + required: + - action +handler: + type: script + script_file: chaitin_muyun.handler.py + function: api_readonly diff --git a/.flocks/flockshub/plugins/tools/device/chaitin_muyun_api_v3/chaitin_muyun_assets.yaml b/.flocks/flockshub/plugins/tools/device/chaitin_muyun_api_v3/chaitin_muyun_assets.yaml new file mode 100644 index 000000000..cb8175d6d --- /dev/null +++ b/.flocks/flockshub/plugins/tools/device/chaitin_muyun_api_v3/chaitin_muyun_assets.yaml @@ -0,0 +1,42 @@ +name: chaitin_muyun_assets +description: Chaitin Muyun product, user, host, application, website, and process asset queries. +description_cn: 长亭牧云产品、用户、主机、应用、网站和进程资产查询工具。 +category: custom +enabled: true +requires_confirmation: false +provider: chaitin_muyun_api +version: "API 3.0" +inputSchema: + type: object + properties: + action: + type: string + enum: + - product_info + - current_user + - host_count + - host_list + - host_detail + - application_list + - website_list + - process_list + - test + description: | + 资产类动作: + - product_info: 获取产品信息。 + - current_user: 获取当前 Token 用户信息。 + - host_count: 获取主机资产数量。 + - host_list: 获取主机资产列表。 + - host_detail: 获取主机资产详情。 + - application_list: 获取应用/软件资产列表。 + - website_list: 获取 Web 站点资产列表。 + - process_list: 获取进程资产列表。 + params: + type: object + description: JSON-RPC params 对象;字段按牧云 API 3.0 对应方法填写。 + required: + - action +handler: + type: script + script_file: chaitin_muyun.handler.py + function: assets diff --git a/.flocks/flockshub/plugins/tools/device/chaitin_muyun_api_v3/chaitin_muyun_events.yaml b/.flocks/flockshub/plugins/tools/device/chaitin_muyun_api_v3/chaitin_muyun_events.yaml new file mode 100644 index 000000000..858ba528d --- /dev/null +++ b/.flocks/flockshub/plugins/tools/device/chaitin_muyun_api_v3/chaitin_muyun_events.yaml @@ -0,0 +1,36 @@ +name: chaitin_muyun_events +description: Chaitin Muyun security event query tool. +description_cn: 长亭牧云安全事件查询工具。 +category: custom +enabled: true +requires_confirmation: false +provider: chaitin_muyun_api +version: "API 3.0" +inputSchema: + type: object + properties: + action: + type: string + enum: + - webshell_events + - malware_events + - bruteforce_events + - abnormal_login_events + - realtime_events + - test + description: | + 事件类动作: + - webshell_events: 查询 WebShell 事件。 + - malware_events: 查询恶意文件事件。 + - bruteforce_events: 查询暴力破解事件。 + - abnormal_login_events: 查询异常登录事件。 + - realtime_events: 查询实时事件概览。 + params: + type: object + description: JSON-RPC params 对象;建议显式传分页、时间和过滤条件。 + required: + - action +handler: + type: script + script_file: chaitin_muyun.handler.py + function: events diff --git a/.flocks/flockshub/plugins/tools/device/chaitin_muyun_api_v3/chaitin_muyun_risk.yaml b/.flocks/flockshub/plugins/tools/device/chaitin_muyun_api_v3/chaitin_muyun_risk.yaml new file mode 100644 index 000000000..bf0751000 --- /dev/null +++ b/.flocks/flockshub/plugins/tools/device/chaitin_muyun_api_v3/chaitin_muyun_risk.yaml @@ -0,0 +1,36 @@ +name: chaitin_muyun_risk +description: Chaitin Muyun vulnerability, baseline, and emergency vulnerability query tool. +description_cn: 长亭牧云漏洞、安全基线和漏洞应急查询工具。 +category: custom +enabled: true +requires_confirmation: false +provider: chaitin_muyun_api +version: "API 3.0" +inputSchema: + type: object + properties: + action: + type: string + enum: + - vuln_list + - vuln_detail + - security_check_events + - baseline_tasks + - emergency_vulns + - test + description: | + 风险类动作: + - vuln_list: 查询通用漏洞事件列表。 + - vuln_detail: 查询漏洞事件详情。 + - security_check_events: 查询安全基线事件。 + - baseline_tasks: 查询核查任务列表。 + - emergency_vulns: 查询漏洞应急列表。 + params: + type: object + description: JSON-RPC params 对象;字段按牧云 API 3.0 对应方法填写。 + required: + - action +handler: + type: script + script_file: chaitin_muyun.handler.py + function: risk diff --git a/.flocks/flockshub/plugins/tools/device/chaitin_muyun_api_v3/manifest.json b/.flocks/flockshub/plugins/tools/device/chaitin_muyun_api_v3/manifest.json new file mode 100644 index 000000000..1a77cedd0 --- /dev/null +++ b/.flocks/flockshub/plugins/tools/device/chaitin_muyun_api_v3/manifest.json @@ -0,0 +1,71 @@ +{ + "schemaVersion": "hub.plugin.v1", + "id": "chaitin_muyun_api_v3", + "type": "device", + "name": "Chaitin Muyun", + "description": "Chaitin Muyun host security platform JSON-RPC API 3.0 integration.", + "descriptionCn": "长亭牧云主机安全平台 API 3.0 接入。", + "version": "3.0", + "author": "Flocks Team", + "license": "MIT", + "category": "integration", + "tags": [ + "edr", + "hids", + "vulnerability", + "integration" + ], + "useCases": [ + "integration", + "incident-response", + "vulnerability-management" + ], + "domains": [ + "security-ops" + ], + "capabilities": [ + "device-integration", + "json-rpc-api" + ], + "trust": "official", + "source": { + "kind": "bundled", + "path": "plugins/tools/device/chaitin_muyun_api_v3" + }, + "compatibility": { + "flocks": ">=0.8.0", + "os": [ + "darwin", + "linux", + "windows" + ] + }, + "dependencies": { + "skills": [], + "tools": [], + "python": [], + "external": [] + }, + "permissions": { + "tools": [], + "network": true, + "shell": false, + "filesystem": "none" + }, + "risk": { + "level": "low", + "reasons": [] + }, + "entrypoints": [ + "_provider.yaml", + "_test.yaml", + "chaitin_muyun.handler.py", + "chaitin_muyun_api_catalog.json", + "chaitin_muyun_assets.yaml", + "chaitin_muyun_events.yaml", + "chaitin_muyun_risk.yaml", + "chaitin_muyun_api_readonly.yaml", + "chaitin_muyun_api_mutation.yaml" + ], + "checksums": {} +} diff --git a/.flocks/flockshub/plugins/tools/device/chaitin_safeline_waf_v1_0_0/_provider.yaml b/.flocks/flockshub/plugins/tools/device/chaitin_safeline_waf_v1_0_0/_provider.yaml new file mode 100644 index 000000000..78bd4126d --- /dev/null +++ b/.flocks/flockshub/plugins/tools/device/chaitin_safeline_waf_v1_0_0/_provider.yaml @@ -0,0 +1,38 @@ +name: chaitin_safeline_waf +vendor: chaitin +service_id: chaitin_safeline_waf +version: "1.0" +integration_type: device +description: > + Chaitin SafeLine WAF OpenAPI integration. Configure the device URL and + API Token. Read-only calls and mutation calls are separated at tool level. +description_cn: > + 长亭雷池 WAF OpenAPI 接入。配置设备地址和 API Token;只读查询与变更调用在工具层隔离。 +auth: + type: custom + secret: chaitin_safeline_waf_api_token +credential_fields: + - key: base_url + label: 设备地址 + storage: config + config_key: base_url + input_type: url + required: true + placeholder: "https://safeline.example.com" + - key: api_token + label: API Token + storage: secret + config_key: api_token + secret_id: chaitin_safeline_waf_api_token + input_type: password + required: true +defaults: + timeout: 30 + category: custom + product_version: "1.0" + verify_ssl: false +notes: | + 认证规则来自《长亭雷池WAF》OpenAPI 文档: + - Token 在管理界面个人中心的 Open API 页面创建。 + - 所有 API 请求在 Header 中携带 API-TOKEN。 + - GET 使用 query string;POST/PUT/DELETE 使用 JSON body。 diff --git a/.flocks/flockshub/plugins/tools/device/chaitin_safeline_waf_v1_0_0/_test.yaml b/.flocks/flockshub/plugins/tools/device/chaitin_safeline_waf_v1_0_0/_test.yaml new file mode 100644 index 000000000..09957410d --- /dev/null +++ b/.flocks/flockshub/plugins/tools/device/chaitin_safeline_waf_v1_0_0/_test.yaml @@ -0,0 +1,58 @@ +schema_version: 1 +provider: chaitin_safeline_waf + +connectivity: + tool: chaitin_safeline_waf_system + params: + action: profile + +fixtures: + chaitin_safeline_waf_system: + - label: Query current profile + label_cn: 查询当前账号信息 + tags: [smoke, auth] + params: + action: profile + assert: + success: true + - label: Query security overview + label_cn: 查询安全概览 + tags: [dashboard] + params: + action: overview + + chaitin_safeline_waf_policy: + - label: List ACL templates + label_cn: 查询访问频率限制规则 + tags: [policy] + params: + action: acl_templates + count: 10 + offset: 0 + + chaitin_safeline_waf_site: + - label: List reverse proxy sites + label_cn: 查询反向代理站点 + tags: [site] + params: + action: reverse_proxy_sites + count: 10 + offset: 0 + + chaitin_safeline_waf_api_readonly: + - label: Show API catalog + label_cn: 查看 API 目录 + tags: [api] + params: + action: api_catalog + assert: + success: true + + chaitin_safeline_waf_api_mutation: + - label: Show API catalog before confirmed mutation + label_cn: 变更调用前查看 API 目录 + tags: [api, mutation] + params: + action: api_catalog + assert: + success: true diff --git a/.flocks/flockshub/plugins/tools/device/chaitin_safeline_waf_v1_0_0/chaitin_safeline_waf.handler.py b/.flocks/flockshub/plugins/tools/device/chaitin_safeline_waf_v1_0_0/chaitin_safeline_waf.handler.py new file mode 100644 index 000000000..19cb8ec15 --- /dev/null +++ b/.flocks/flockshub/plugins/tools/device/chaitin_safeline_waf_v1_0_0/chaitin_safeline_waf.handler.py @@ -0,0 +1,339 @@ +from __future__ import annotations + +import asyncio +import json +import os +from pathlib import Path +from typing import Any, Callable + +import requests + +from flocks.config.config_writer import ConfigWriter +from flocks.security import get_secret_manager +from flocks.tool.registry import ToolContext, ToolResult + + +SERVICE_ID = "chaitin_safeline_waf" +STORAGE_KEY = "chaitin_safeline_waf_api" +PRODUCT_VERSION = "1.0" +DEFAULT_TIMEOUT = 30 +DEFAULT_VERIFY_SSL = False +CATALOG_FILE = Path(__file__).with_name("chaitin_safeline_waf_api_catalog.json") + + +class ChaitinWafError(RuntimeError): + pass + + +class RuntimeConfig: + def __init__( + self, + *, + base_url: str, + api_token: str, + verify_ssl: bool, + timeout: int, + ) -> None: + self.base_url = base_url + self.api_token = api_token + self.verify_ssl = verify_ssl + self.timeout = timeout + + +def _resolve_ref(value: Any) -> str: + if value is None: + return "" + if not isinstance(value, str): + return str(value) + if value.startswith("{secret:") and value.endswith("}"): + return get_secret_manager().get(value[len("{secret:") : -1]) or "" + if value.startswith("{env:") and value.endswith("}"): + return os.getenv(value[len("{env:") : -1], "") + return value + + +def _raw_service_config() -> dict[str, Any]: + raw = ConfigWriter.get_api_service_raw(SERVICE_ID) + if not isinstance(raw, dict): + raw = ConfigWriter.get_api_service_raw(STORAGE_KEY) + return raw if isinstance(raw, dict) else {} + + +def _config_value(raw: dict[str, Any], *keys: str) -> Any: + for key in keys: + if raw.get(key) is not None: + return raw[key] + custom_settings = raw.get("custom_settings") + if isinstance(custom_settings, dict): + for key in keys: + if custom_settings.get(key) is not None: + return custom_settings[key] + return None + + +def _as_bool(value: Any, default: bool) -> bool: + if value is None: + return default + if isinstance(value, bool): + return value + if isinstance(value, str): + text = value.strip().lower() + if text in {"1", "true", "yes", "on"}: + return True + if text in {"0", "false", "no", "off"}: + return False + return bool(value) + + +def _normalize_base_url(base_url: str) -> str: + return base_url.strip().rstrip("/") + + +def resolve_config() -> RuntimeConfig: + raw = _raw_service_config() + base_url = ( + _resolve_ref(_config_value(raw, "base_url", "baseUrl")) + or os.getenv("CHAITIN_SAFELINE_WAF_BASE_URL", "") + ) + if not base_url: + raise ChaitinWafError("Chaitin SafeLine WAF base_url is not configured") + + api_token = ( + _resolve_ref(_config_value(raw, "api_token", "apiToken", "token")) + or get_secret_manager().get("chaitin_safeline_waf_api_token") + or get_secret_manager().get(f"{SERVICE_ID}_token") + or os.getenv("CHAITIN_SAFELINE_WAF_API_TOKEN", "") + ) + if not api_token: + raise ChaitinWafError("Chaitin SafeLine WAF API token is not configured") + + try: + timeout = int(_config_value(raw, "timeout") or DEFAULT_TIMEOUT) + except (TypeError, ValueError): + timeout = DEFAULT_TIMEOUT + verify_ssl = _as_bool( + _config_value(raw, "verify_ssl", "ssl_verify", "verifySsl") + if _config_value(raw, "verify_ssl", "ssl_verify", "verifySsl") is not None + else os.getenv("CHAITIN_SAFELINE_WAF_VERIFY_SSL"), + DEFAULT_VERIFY_SSL, + ) + return RuntimeConfig( + base_url=_normalize_base_url(base_url), + api_token=api_token, + verify_ssl=verify_ssl, + timeout=timeout, + ) + + +def _render_path(path: str, args: dict[str, Any]) -> str: + rendered = path + path_params = args.get("path_params") if isinstance(args.get("path_params"), dict) else {} + for key, value in {**path_params, **args}.items(): + if isinstance(key, str): + rendered = rendered.replace("{" + key + "}", str(value)) + if "{" in rendered or "}" in rendered: + raise ChaitinWafError(f"Missing path parameter for {path}") + return rendered + + +class WafClient: + def __init__(self, config: RuntimeConfig) -> None: + self.config = config + + def request( + self, + method: str, + path: str, + *, + query: dict[str, Any] | None = None, + body: Any = None, + ) -> Any: + url = f"{self.config.base_url}{path}" + headers = { + "Accept": "application/json", + "API-TOKEN": self.config.api_token, + } + if method.upper() in {"POST", "PUT", "DELETE", "PATCH"}: + headers["Content-Type"] = "application/json" + response = requests.request( + method.upper(), + url, + params={k: v for k, v in (query or {}).items() if v is not None}, + json=body if body not in (None, "") else None, + headers=headers, + timeout=self.config.timeout, + verify=self.config.verify_ssl, + ) + return _json_response(response) + + +def _json_response(response: requests.Response) -> Any: + try: + payload = response.json() + except ValueError as exc: + raise ChaitinWafError(f"Invalid JSON response: HTTP {response.status_code}") from exc + if response.status_code >= 400: + raise ChaitinWafError(f"HTTP {response.status_code}: {payload}") + if isinstance(payload, dict) and payload.get("err") not in (None, ""): + raise ChaitinWafError(str(payload.get("msg") or payload.get("err"))) + return payload + + +def _ok(data: Any, *, action: str) -> ToolResult: + return ToolResult( + success=True, + output=data, + metadata={"source": "Chaitin SafeLine WAF", "version": PRODUCT_VERSION, "action": action}, + ) + + +def get_client() -> WafClient: + return WafClient(resolve_config()) + + +def _request_args(args: dict[str, Any], default_method: str, default_path: str) -> tuple[str, str, dict[str, Any], Any]: + method = str(args.get("method") or default_method).upper() + path = _render_path(str(args.get("path") or default_path), args) + query = args.get("query") if isinstance(args.get("query"), dict) else {} + body = args.get("body") + if method == "GET" and not query: + ignored = {"action", "method", "path", "query", "body", "path_params"} + query = {k: v for k, v in args.items() if k not in ignored and v is not None} + return method, path, dict(query), body + + +def _load_api_catalog() -> list[dict[str, Any]]: + try: + data = json.loads(CATALOG_FILE.read_text(encoding="utf-8")) + except FileNotFoundError: + return [] + entries = data.get("entries") + return entries if isinstance(entries, list) else [] + + +def _catalog_pairs(kind: str) -> set[tuple[str, str]]: + return { + (str(entry.get("method", "")).upper(), str(entry.get("path", ""))) + for entry in _load_api_catalog() + if entry.get("kind") == kind and entry.get("method") and entry.get("path") + } + + +READONLY_ACTIONS: dict[str, tuple[str, str]] = { + "profile": ("GET", "/api/ProfileAPI"), + "overview": ("GET", "/api/OverviewAPI"), + "acl_rules": ("GET", "/api/ACLRuleAPI"), + "acl_templates": ("GET", "/api/ACLRuleTemplateAPI"), + "attack_logs": ("GET", "/api/FilterV2API"), + "ip_groups": ("GET", "/api/IPGroupAPI"), + "reverse_proxy_sites": ("GET", "/api/HardwareReverseProxyWebsiteAPI"), + "traffic_detection_sites": ("GET", "/api/HardwareTrafficDetectionWebsiteAPI"), + "certificates": ("GET", "/api/CertAPI"), + "traffic_learning_overview": ("GET", "/api/traffic_learning/v1/Overview"), +} + + +SYSTEM_ACTIONS = {"profile", "overview"} +POLICY_ACTIONS = {"acl_rules", "acl_templates", "ip_groups"} +SITE_ACTIONS = {"reverse_proxy_sites", "traffic_detection_sites", "certificates"} +LOG_ACTIONS = {"attack_logs", "traffic_learning_overview"} + + +def call_rest(action: str, args: dict[str, Any]) -> ToolResult: + method, path = READONLY_ACTIONS[action] + req_method, req_path, query, body = _request_args(args, method, path) + return _ok(get_client().request(req_method, req_path, query=query, body=body), action=action) + + +def api_catalog(args: dict[str, Any]) -> ToolResult: + del args + catalog = _load_api_catalog() + return _ok( + { + "catalog_counts": { + "total": len(catalog), + "readonly": sum(1 for entry in catalog if entry.get("kind") == "readonly"), + "mutation": sum(1 for entry in catalog if entry.get("kind") == "mutation"), + }, + "documented_api_catalog": catalog, + "common_actions": { + "system": sorted(SYSTEM_ACTIONS), + "policy": sorted(POLICY_ACTIONS), + "site": sorted(SITE_ACTIONS), + "logs": sorted(LOG_ACTIONS), + }, + }, + action="api_catalog", + ) + + +def rest_call_readonly(args: dict[str, Any]) -> ToolResult: + method, path, query, body = _request_args(args, "GET", "") + if (method, path) not in _catalog_pairs("readonly"): + raise ChaitinWafError("Only documented read-only REST method/path pairs are allowed") + return _ok(get_client().request(method, path, query=query, body=body), action="rest_call_readonly") + + +def rest_call_mutation(args: dict[str, Any]) -> ToolResult: + method, path, query, body = _request_args(args, "POST", "") + if (method, path) not in _catalog_pairs("mutation"): + raise ChaitinWafError("Only documented mutation REST method/path pairs are allowed") + return _ok(get_client().request(method, path, query=query, body=body), action="rest_call_mutation") + + +ACTION_HANDLERS: dict[str, Callable[[dict[str, Any]], ToolResult]] = { + "api_catalog": api_catalog, + "rest_call_readonly": rest_call_readonly, + "rest_call_mutation": rest_call_mutation, +} +for _action in READONLY_ACTIONS: + ACTION_HANDLERS[_action] = lambda args, action=_action: call_rest(action, args) + + +async def _dispatch(ctx: ToolContext, allowed: set[str], action: str, **params: Any) -> ToolResult: + del ctx + if action == "test": + action = "profile" + if action not in allowed: + return ToolResult( + success=False, + error=f"Unsupported Chaitin SafeLine WAF action: {action}. Available: {', '.join(sorted(allowed))}", + ) + try: + return await asyncio.to_thread(ACTION_HANDLERS[action], params) + except ChaitinWafError as exc: + return ToolResult( + success=False, + error=str(exc), + metadata={"source": "Chaitin SafeLine WAF", "version": PRODUCT_VERSION, "action": action}, + ) + except Exception as exc: + return ToolResult( + success=False, + error=f"Unexpected Chaitin SafeLine WAF error: {exc}", + metadata={"source": "Chaitin SafeLine WAF", "version": PRODUCT_VERSION, "action": action}, + ) + + +async def system(ctx: ToolContext, action: str, **params: Any) -> ToolResult: + return await _dispatch(ctx, SYSTEM_ACTIONS | {"test"}, action, **params) + + +async def policy(ctx: ToolContext, action: str, **params: Any) -> ToolResult: + return await _dispatch(ctx, POLICY_ACTIONS | {"test"}, action, **params) + + +async def site(ctx: ToolContext, action: str, **params: Any) -> ToolResult: + return await _dispatch(ctx, SITE_ACTIONS | {"test"}, action, **params) + + +async def logs(ctx: ToolContext, action: str, **params: Any) -> ToolResult: + return await _dispatch(ctx, LOG_ACTIONS | {"test"}, action, **params) + + +async def api_readonly(ctx: ToolContext, action: str, **params: Any) -> ToolResult: + return await _dispatch(ctx, {"api_catalog", "rest_call_readonly", *READONLY_ACTIONS.keys(), "test"}, action, **params) + + +async def api_mutation(ctx: ToolContext, action: str, **params: Any) -> ToolResult: + return await _dispatch(ctx, {"api_catalog", "rest_call_mutation"}, action, **params) diff --git a/.flocks/flockshub/plugins/tools/device/chaitin_safeline_waf_v1_0_0/chaitin_safeline_waf_api_catalog.json b/.flocks/flockshub/plugins/tools/device/chaitin_safeline_waf_v1_0_0/chaitin_safeline_waf_api_catalog.json new file mode 100644 index 000000000..1d55cf883 --- /dev/null +++ b/.flocks/flockshub/plugins/tools/device/chaitin_safeline_waf_v1_0_0/chaitin_safeline_waf_api_catalog.json @@ -0,0 +1,498 @@ +{ + "schema_version": 1, + "source": "长亭雷池WAF/长亭雷池WAF.docx", + "entries": [ + { + "method": "DELETE", + "path": "/api/ACLRuleAPI", + "kind": "mutation", + "description": "删除指定 ID 的 ACL Rule,并可通过参数指定加入白名单" + }, + { + "method": "GET", + "path": "/api/ACLRuleAPI", + "kind": "readonly", + "description": "根据 ID 查找指定 ACL Rule Template,返回其所包含的所有 ACL Rule" + }, + { + "method": "POST", + "path": "/api/ACLRuleAPI", + "kind": "mutation", + "description": "新建一条包含指定内容的 ACL Rule,并加入指定 Template" + }, + { + "method": "DELETE", + "path": "/api/ACLRuleExecutionLogAPI", + "kind": "mutation", + "description": "删除日志" + }, + { + "method": "GET", + "path": "/api/ACLRuleExecutionLogAPI", + "kind": "readonly", + "description": "查询 ACL 执行日志的统计数据,以 5 分钟为间隔,按时间顺序排列" + }, + { + "method": "DELETE", + "path": "/api/ACLRuleTemplateAPI", + "kind": "mutation", + "description": "删除频率限制规则" + }, + { + "method": "GET", + "path": "/api/ACLRuleTemplateAPI", + "kind": "readonly", + "description": "查看频率限制规则" + }, + { + "method": "POST", + "path": "/api/ACLRuleTemplateAPI", + "kind": "mutation", + "description": "新建频率限制规则" + }, + { + "method": "PUT", + "path": "/api/ACLRuleTemplateAPI", + "kind": "mutation", + "description": "编辑指定 ID 的 ACLRuleTemplate" + }, + { + "method": "DELETE", + "path": "/api/ACLWhiteListAPI", + "kind": "mutation", + "description": "删除指定 ID 对应的 ACL White List" + }, + { + "method": "GET", + "path": "/api/ACLWhiteListAPI", + "kind": "readonly", + "description": "根据参数过滤已有的 ACL White List,并返回所有符合条件的项目" + }, + { + "method": "POST", + "path": "/api/ACLWhiteListAPI", + "kind": "mutation", + "description": "新建 ACL 白名单" + }, + { + "method": "DELETE", + "path": "/api/ClearACLRuleAPI", + "kind": "mutation", + "description": "清空 ACL Rule Template 的所有用户,并可通过参数指定加入白名单" + }, + { + "method": "POST", + "path": "/api/ESDownloadIndices", + "kind": "mutation", + "description": "新建「下载索引归档」日志下载任务,需要到日志下载管理界面下载归档" + }, + { + "method": "PUT", + "path": "/api/ESIndexLifecycle", + "kind": "mutation", + "description": "修改索引生命周期" + }, + { + "method": "DELETE", + "path": "/api/ESIndices", + "kind": "mutation", + "description": "删除归档" + }, + { + "method": "GET", + "path": "/api/ESIndices", + "kind": "readonly", + "description": "查看索引信息" + }, + { + "method": "PUT", + "path": "/api/ESIndices", + "kind": "mutation", + "description": "恢复归档" + }, + { + "method": "GET", + "path": "/api/FilterV2API", + "kind": "readonly", + "description": "限制用户列表-满足条件的用户; 查询限制用户列表-已知用户; 查询访问频率限制规则; 查询攻击检测日志列表页和详情页; 查询 IP 组; 查询防护策略" + }, + { + "method": "GET", + "path": "/api/FilterHistory", + "kind": "readonly", + "description": "查看攻击检测日志筛选历史" + }, + { + "method": "DELETE", + "path": "/api/SavedFilter", + "kind": "mutation", + "description": "删除筛选器" + }, + { + "method": "GET", + "path": "/api/SavedFilter", + "kind": "readonly", + "description": "获取筛选器" + }, + { + "method": "POST", + "path": "/api/SavedFilter", + "kind": "mutation", + "description": "新建筛选器" + }, + { + "method": "PUT", + "path": "/api/SavedFilter", + "kind": "mutation", + "description": "编辑筛选器" + }, + { + "method": "DELETE", + "path": "/api/EditIPGroupItem", + "kind": "mutation", + "description": "IP 列表删除 IP" + }, + { + "method": "POST", + "path": "/api/EditIPGroupItem", + "kind": "mutation", + "description": "IP 列表添加 IP" + }, + { + "method": "DELETE", + "path": "/api/IPGroupAPI", + "kind": "mutation", + "description": "删除 IP 组" + }, + { + "method": "GET", + "path": "/api/IPGroupAPI", + "kind": "readonly", + "description": "获取 IP 组信息" + }, + { + "method": "POST", + "path": "/api/IPGroupAPI", + "kind": "mutation", + "description": "新建 IP 组" + }, + { + "method": "PUT", + "path": "/api/IPGroupAPI", + "kind": "mutation", + "description": "编辑 IP 组名称和备注" + }, + { + "method": "GET", + "path": "/api/LogFlagConfig", + "kind": "readonly", + "description": "获取标记" + }, + { + "method": "PUT", + "path": "/api/LogFlagConfig", + "kind": "mutation", + "description": "修改标记" + }, + { + "method": "GET", + "path": "/_api/DashboardConfigAPI", + "kind": "readonly", + "description": "获取配置" + }, + { + "method": "POST", + "path": "/_api/DashboardConfigAPI", + "kind": "mutation", + "description": "修改配置" + }, + { + "method": "POST", + "path": "/api/HTTPReplayAPI", + "kind": "mutation", + "description": "重放请求" + }, + { + "method": "GET", + "path": "/api/LogAggregationConfigAPI", + "kind": "readonly", + "description": "获取配置" + }, + { + "method": "PUT", + "path": "/api/LogAggregationConfigAPI", + "kind": "mutation", + "description": "修改配置" + }, + { + "method": "GET", + "path": "/api/OverviewAPI", + "kind": "readonly", + "description": "统计信息" + }, + { + "method": "POST", + "path": "/api/report/v2/ReportTask", + "kind": "mutation", + "description": "手动生成节点状态报告" + }, + { + "method": "PUT", + "path": "/api/report/v2/ReportTask", + "kind": "mutation", + "description": "编辑定时报告任务" + }, + { + "method": "POST", + "path": "/api/report/v2/SendReport", + "kind": "mutation", + "description": "报告外发" + }, + { + "method": "GET", + "path": "/api/SoftwareReverseProxyWebsiteAPI", + "kind": "readonly", + "description": "手动生成一个报告" + }, + { + "method": "GET", + "path": "/api/traffic_learning/v1/AutoIntf", + "kind": "readonly", + "description": "获取自动发现业务列表" + }, + { + "method": "POST", + "path": "/api/traffic_learning/v1/AutoIntf", + "kind": "mutation", + "description": "自动发现业务移入业务建模" + }, + { + "method": "DELETE", + "path": "/api/traffic_learning/v1/Intf", + "kind": "mutation", + "description": "编辑智学习业务信息,支持批量" + }, + { + "method": "POST", + "path": "/api/traffic_learning/v1/Intf", + "kind": "mutation", + "description": "手动创建智学习业务" + }, + { + "method": "PUT", + "path": "/api/traffic_learning/v1/Intf", + "kind": "mutation", + "description": "编辑智学习业务信息" + }, + { + "method": "GET", + "path": "/api/traffic_learning/v1/IntfConfig", + "kind": "readonly", + "description": "获取某个站点的业务配置信息" + }, + { + "method": "PUT", + "path": "/api/traffic_learning/v1/IntfConfig", + "kind": "mutation", + "description": "编辑某个站点的业务配置信息" + }, + { + "method": "GET", + "path": "/api/traffic_learning/v1/Overview", + "kind": "readonly", + "description": "获取智学习总览信息" + }, + { + "method": "POST", + "path": "/api/traffic_learning/v1/ToggleIntf", + "kind": "mutation", + "description": "修改业务模型状态,包括开始/停止检测,开始/结束/重新开始业务学习,支持批量操作" + }, + { + "method": "DELETE", + "path": "/api/CertAPI", + "kind": "mutation", + "description": "根据指定的 ID 删除证书" + }, + { + "method": "GET", + "path": "/api/CertAPI", + "kind": "readonly", + "description": "获取已经上传的所有证书" + }, + { + "method": "DELETE", + "path": "/api/HardwareReverseProxyWebsiteAPI", + "kind": "mutation", + "description": "批量删除指定 ID 对应的站点配置" + }, + { + "method": "GET", + "path": "/api/HardwareReverseProxyWebsiteAPI", + "kind": "readonly", + "description": "根据查询条件,过滤并返回已经存在的站点配置" + }, + { + "method": "POST", + "path": "/api/HardwareReverseProxyWebsiteAPI", + "kind": "mutation", + "description": "新建站点" + }, + { + "method": "PUT", + "path": "/api/HardwareReverseProxyWebsiteAPI", + "kind": "mutation", + "description": "编辑站点" + }, + { + "method": "DELETE", + "path": "/api/HardwareTrafficDetectionWebsiteAPI", + "kind": "mutation", + "description": "编辑站点" + }, + { + "method": "GET", + "path": "/api/HardwareTrafficDetectionWebsiteAPI", + "kind": "readonly", + "description": "根据查询条件,过滤并返回已经存在的站点配置,在使用工作于流量监测模式下的硬件版时调用" + }, + { + "method": "POST", + "path": "/api/HardwareTrafficDetectionWebsiteAPI", + "kind": "mutation", + "description": "新建站点" + }, + { + "method": "PUT", + "path": "/api/HardwareTrafficDetectionWebsiteAPI", + "kind": "mutation", + "description": "编辑站点" + }, + { + "method": "DELETE", + "path": "/api/HardwareTransparentBridgingWebsiteAPI", + "kind": "mutation", + "description": "批量删除指定 ID 对应的站点配置" + }, + { + "method": "GET", + "path": "/api/HardwareTransparentBridgingWebsiteAPI", + "kind": "readonly", + "description": "根据查询条件,过滤并返回已经存在的站点配置" + }, + { + "method": "POST", + "path": "/api/HardwareTransparentBridgingWebsiteAPI", + "kind": "mutation", + "description": "新建站点" + }, + { + "method": "PUT", + "path": "/api/HardwareTransparentBridgingWebsiteAPI", + "kind": "mutation", + "description": "编辑站点" + }, + { + "method": "DELETE", + "path": "/api/HardwareTransparentProxyWebsiteAPI", + "kind": "mutation", + "description": "批量删除指定 ID 对应的站点配置" + }, + { + "method": "GET", + "path": "/api/HardwareTransparentProxyWebsiteAPI", + "kind": "readonly", + "description": "根据查询条件,过滤并返回已经存在的站点配置" + }, + { + "method": "POST", + "path": "/api/HardwareTransparentProxyWebsiteAPI", + "kind": "mutation", + "description": "新建站点" + }, + { + "method": "PUT", + "path": "/api/HardwareTransparentProxyWebsiteAPI", + "kind": "mutation", + "description": "编辑站点" + }, + { + "method": "DELETE", + "path": "/api/SoftwareClusterReverseProxyWebsiteAPI", + "kind": "mutation", + "description": "批量删除指定 ID 对应的站点配置" + }, + { + "method": "GET", + "path": "/api/SoftwareClusterReverseProxyWebsiteAPI", + "kind": "readonly", + "description": "根据查询条件,过滤并返回已经存在的站点配置" + }, + { + "method": "POST", + "path": "/api/SoftwareClusterReverseProxyWebsiteAPI", + "kind": "mutation", + "description": "新建站点" + }, + { + "method": "PUT", + "path": "/api/SoftwareClusterReverseProxyWebsiteAPI", + "kind": "mutation", + "description": "编辑站点" + }, + { + "method": "DELETE", + "path": "/api/SoftwarePortMirroringWebsiteAPI", + "kind": "mutation", + "description": "批量删除指定 ID 对应的站点配置" + }, + { + "method": "GET", + "path": "/api/SoftwarePortMirroringWebsiteAPI", + "kind": "readonly", + "description": "根据查询条件,过滤并返回已经存在的站点配置" + }, + { + "method": "POST", + "path": "/api/SoftwarePortMirroringWebsiteAPI", + "kind": "mutation", + "description": "新建站点" + }, + { + "method": "PUT", + "path": "/api/SoftwarePortMirroringWebsiteAPI", + "kind": "mutation", + "description": "编辑站点" + }, + { + "method": "DELETE", + "path": "/api/SoftwareReverseProxyWebsiteAPI", + "kind": "mutation", + "description": "批量删除指定 ID 对应的站点配置" + }, + { + "method": "POST", + "path": "/api/SoftwareReverseProxyWebsiteAPI", + "kind": "mutation", + "description": "新建站点" + }, + { + "method": "PUT", + "path": "/api/SoftwareReverseProxyWebsiteAPI", + "kind": "mutation", + "description": "编辑站点" + }, + { + "method": "POST", + "path": "/api/UploadForbiddenPageAPI", + "kind": "mutation", + "description": "上传访问被拦截时,所返回响应页面" + }, + { + "method": "POST", + "path": "/api/UploadSSLCertAPI", + "kind": "mutation", + "description": "当为站点开启 SSL时,使用此 API 上传证书 - name 参数如果不传,则会使用证书的域名拼接作为名字(可能会很长) - id 参数如果不传代表新建,否则为编辑这个 ID 的证书" + } + ] +} diff --git a/.flocks/flockshub/plugins/tools/device/chaitin_safeline_waf_v1_0_0/chaitin_safeline_waf_api_mutation.yaml b/.flocks/flockshub/plugins/tools/device/chaitin_safeline_waf_v1_0_0/chaitin_safeline_waf_api_mutation.yaml new file mode 100644 index 000000000..5da0bf023 --- /dev/null +++ b/.flocks/flockshub/plugins/tools/device/chaitin_safeline_waf_v1_0_0/chaitin_safeline_waf_api_mutation.yaml @@ -0,0 +1,33 @@ +name: chaitin_safeline_waf_api_mutation +description: Chaitin SafeLine WAF documented mutation REST caller with confirmation. +description_cn: 长亭雷池 WAF 文档内变更类 REST 调用工具。所有调用都需要确认。 +category: custom +enabled: true +requires_confirmation: true +provider: chaitin_safeline_waf +version: "1.0" +inputSchema: + type: object + properties: + action: + type: string + enum: + - api_catalog + - rest_call_mutation + method: + type: string + description: HTTP 方法,必须和 catalog 中 kind=mutation 的 method/path 匹配。 + path: + type: string + description: REST 路径,例如 /api/ACLRuleAPI。 + query: + type: object + description: Query string 参数。 + body: + description: JSON body,按雷池 WAF 文档对应接口填写。 + required: + - action +handler: + type: script + script_file: chaitin_safeline_waf.handler.py + function: api_mutation diff --git a/.flocks/flockshub/plugins/tools/device/chaitin_safeline_waf_v1_0_0/chaitin_safeline_waf_api_readonly.yaml b/.flocks/flockshub/plugins/tools/device/chaitin_safeline_waf_v1_0_0/chaitin_safeline_waf_api_readonly.yaml new file mode 100644 index 000000000..ca13bb563 --- /dev/null +++ b/.flocks/flockshub/plugins/tools/device/chaitin_safeline_waf_v1_0_0/chaitin_safeline_waf_api_readonly.yaml @@ -0,0 +1,44 @@ +name: chaitin_safeline_waf_api_readonly +description: Chaitin SafeLine WAF documented read-only REST caller. +description_cn: 长亭雷池 WAF 文档内只读 REST 调用工具。使用 api_catalog 查看已收录 API,再用 rest_call_readonly 调用 kind=readonly 的 method/path。 +category: custom +enabled: true +requires_confirmation: false +provider: chaitin_safeline_waf +version: "1.0" +inputSchema: + type: object + properties: + action: + type: string + enum: + - api_catalog + - rest_call_readonly + - profile + - overview + - acl_rules + - acl_templates + - attack_logs + - ip_groups + - reverse_proxy_sites + - traffic_detection_sites + - certificates + - traffic_learning_overview + - test + method: + type: string + description: rest_call_readonly 使用的 HTTP 方法,通常为 GET。 + path: + type: string + description: REST 路径,例如 /api/ProfileAPI,必须属于 catalog 中 kind=readonly 的条目。 + query: + type: object + description: Query string 参数。 + body: + description: 请求 body;只读调用通常不需要。 + required: + - action +handler: + type: script + script_file: chaitin_safeline_waf.handler.py + function: api_readonly diff --git a/.flocks/flockshub/plugins/tools/device/chaitin_safeline_waf_v1_0_0/chaitin_safeline_waf_logs.yaml b/.flocks/flockshub/plugins/tools/device/chaitin_safeline_waf_v1_0_0/chaitin_safeline_waf_logs.yaml new file mode 100644 index 000000000..eb8d56223 --- /dev/null +++ b/.flocks/flockshub/plugins/tools/device/chaitin_safeline_waf_v1_0_0/chaitin_safeline_waf_logs.yaml @@ -0,0 +1,33 @@ +name: chaitin_safeline_waf_logs +description: Chaitin SafeLine WAF attack log and traffic-learning read-only queries. +description_cn: 长亭雷池 WAF 攻击日志与智学习只读查询工具。 +category: custom +enabled: true +requires_confirmation: false +provider: chaitin_safeline_waf +version: "1.0" +inputSchema: + type: object + properties: + action: + type: string + enum: + - attack_logs + - traffic_learning_overview + - test + description: | + - attack_logs: GET /api/FilterV2API,查询攻击检测日志列表页/详情页等筛选数据。 + - traffic_learning_overview: GET /api/traffic_learning/v1/Overview,查询智学习总览。 + query: + type: object + description: GET query 参数。 + count: + type: integer + offset: + type: integer + required: + - action +handler: + type: script + script_file: chaitin_safeline_waf.handler.py + function: logs diff --git a/.flocks/flockshub/plugins/tools/device/chaitin_safeline_waf_v1_0_0/chaitin_safeline_waf_policy.yaml b/.flocks/flockshub/plugins/tools/device/chaitin_safeline_waf_v1_0_0/chaitin_safeline_waf_policy.yaml new file mode 100644 index 000000000..d04a22476 --- /dev/null +++ b/.flocks/flockshub/plugins/tools/device/chaitin_safeline_waf_v1_0_0/chaitin_safeline_waf_policy.yaml @@ -0,0 +1,37 @@ +name: chaitin_safeline_waf_policy +description: Chaitin SafeLine WAF ACL, rate-limit, and IP group read-only queries. +description_cn: 长亭雷池 WAF 访问频率限制、ACL 和 IP 组只读查询工具。 +category: custom +enabled: true +requires_confirmation: false +provider: chaitin_safeline_waf +version: "1.0" +inputSchema: + type: object + properties: + action: + type: string + enum: + - acl_rules + - acl_templates + - ip_groups + - test + description: | + - acl_rules: GET /api/ACLRuleAPI,查询 ACL Rule。 + - acl_templates: GET /api/ACLRuleTemplateAPI,查询访问频率限制规则。 + - ip_groups: GET /api/IPGroupAPI,查询 IP 组。 + query: + type: object + description: GET query 参数。 + count: + type: integer + description: 分页数量。 + offset: + type: integer + description: 分页偏移量。 + required: + - action +handler: + type: script + script_file: chaitin_safeline_waf.handler.py + function: policy diff --git a/.flocks/flockshub/plugins/tools/device/chaitin_safeline_waf_v1_0_0/chaitin_safeline_waf_site.yaml b/.flocks/flockshub/plugins/tools/device/chaitin_safeline_waf_v1_0_0/chaitin_safeline_waf_site.yaml new file mode 100644 index 000000000..a4cc53593 --- /dev/null +++ b/.flocks/flockshub/plugins/tools/device/chaitin_safeline_waf_v1_0_0/chaitin_safeline_waf_site.yaml @@ -0,0 +1,35 @@ +name: chaitin_safeline_waf_site +description: Chaitin SafeLine WAF protected site and certificate read-only queries. +description_cn: 长亭雷池 WAF 防护站点与证书只读查询工具。 +category: custom +enabled: true +requires_confirmation: false +provider: chaitin_safeline_waf +version: "1.0" +inputSchema: + type: object + properties: + action: + type: string + enum: + - reverse_proxy_sites + - traffic_detection_sites + - certificates + - test + description: | + - reverse_proxy_sites: GET /api/HardwareReverseProxyWebsiteAPI,查询反向代理站点。 + - traffic_detection_sites: GET /api/HardwareTrafficDetectionWebsiteAPI,查询流量检测站点。 + - certificates: GET /api/CertAPI,查询证书列表。 + query: + type: object + description: GET query 参数。 + count: + type: integer + offset: + type: integer + required: + - action +handler: + type: script + script_file: chaitin_safeline_waf.handler.py + function: site diff --git a/.flocks/flockshub/plugins/tools/device/chaitin_safeline_waf_v1_0_0/chaitin_safeline_waf_system.yaml b/.flocks/flockshub/plugins/tools/device/chaitin_safeline_waf_v1_0_0/chaitin_safeline_waf_system.yaml new file mode 100644 index 000000000..16a6e0a2f --- /dev/null +++ b/.flocks/flockshub/plugins/tools/device/chaitin_safeline_waf_v1_0_0/chaitin_safeline_waf_system.yaml @@ -0,0 +1,29 @@ +name: chaitin_safeline_waf_system +description: Chaitin SafeLine WAF profile and overview queries. +description_cn: 长亭雷池 WAF 账号信息与概览查询工具。 +category: custom +enabled: true +requires_confirmation: false +provider: chaitin_safeline_waf +version: "1.0" +inputSchema: + type: object + properties: + action: + type: string + enum: + - profile + - overview + - test + description: | + - profile: GET /api/ProfileAPI,查询当前账号/API Token 可见的账号信息。 + - overview: GET /api/OverviewAPI,查询 WAF 统计概览。 + query: + type: object + description: GET query 参数;也可直接传 count、offset 等简单字段。 + required: + - action +handler: + type: script + script_file: chaitin_safeline_waf.handler.py + function: system diff --git a/.flocks/flockshub/plugins/tools/device/chaitin_safeline_waf_v1_0_0/manifest.json b/.flocks/flockshub/plugins/tools/device/chaitin_safeline_waf_v1_0_0/manifest.json new file mode 100644 index 000000000..065286060 --- /dev/null +++ b/.flocks/flockshub/plugins/tools/device/chaitin_safeline_waf_v1_0_0/manifest.json @@ -0,0 +1,71 @@ +{ + "schemaVersion": "hub.plugin.v1", + "id": "chaitin_safeline_waf_v1_0_0", + "type": "device", + "name": "Chaitin SafeLine WAF", + "description": "Chaitin SafeLine WAF OpenAPI integration.", + "descriptionCn": "长亭雷池 WAF OpenAPI 接入。", + "version": "1.0.0", + "author": "Flocks Team", + "license": "MIT", + "category": "integration", + "tags": [ + "waf", + "web-security", + "integration" + ], + "useCases": [ + "integration", + "threat-detection", + "log-analysis" + ], + "domains": [ + "security-ops" + ], + "capabilities": [ + "device-integration", + "rest-api" + ], + "trust": "official", + "source": { + "kind": "bundled", + "path": "plugins/tools/device/chaitin_safeline_waf_v1_0_0" + }, + "compatibility": { + "flocks": ">=0.8.0", + "os": [ + "darwin", + "linux", + "windows" + ] + }, + "dependencies": { + "skills": [], + "tools": [], + "python": [], + "external": [] + }, + "permissions": { + "tools": [], + "network": true, + "shell": false, + "filesystem": "none" + }, + "risk": { + "level": "low", + "reasons": [] + }, + "entrypoints": [ + "_provider.yaml", + "_test.yaml", + "chaitin_safeline_waf.handler.py", + "chaitin_safeline_waf_api_catalog.json", + "chaitin_safeline_waf_system.yaml", + "chaitin_safeline_waf_policy.yaml", + "chaitin_safeline_waf_site.yaml", + "chaitin_safeline_waf_logs.yaml", + "chaitin_safeline_waf_api_readonly.yaml", + "chaitin_safeline_waf_api_mutation.yaml" + ], + "checksums": {} +} diff --git a/.flocks/flockshub/plugins/tools/device/sangfor_atrust_v3/_provider.yaml b/.flocks/flockshub/plugins/tools/device/sangfor_atrust_v3/_provider.yaml new file mode 100644 index 000000000..5ba2d802e --- /dev/null +++ b/.flocks/flockshub/plugins/tools/device/sangfor_atrust_v3/_provider.yaml @@ -0,0 +1,65 @@ +name: sangfor_atrust +vendor: sangfor +service_id: sangfor_atrust +version: "3" +integration_type: device +description: > + Sangfor aTrust Zero Trust Access Control OpenAPI V3 integration. + Configure the aTrust controller URL, API ID, and API Secret. The handler + signs each request with HMAC-SHA256 x-ca-* headers required by aTrust. +description_cn: > + 深信服 aTrust 零信任访问控制系统 OpenAPI V3 接入。配置控制中心地址、 + API ID 和 API 密钥;handler 会按 aTrust 要求自动计算 HMAC-SHA256 + 签名并注入 x-ca-* 请求头。 +docs_url: "https://bbs.sangfor.com.cn/atrustdeveloper/openapiV3/" +auth: + type: custom + flow: hmac_sha256_header + secret: sangfor_atrust_app_id + secret_secret: sangfor_atrust_app_secret +credential_fields: + - key: base_url + label: aTrust 控制中心地址 + storage: config + config_key: base_url + input_type: url + required: true + placeholder: "https://atrust.example.com:4433" + - key: app_id + label: API ID + storage: secret + config_key: app_id + secret_id: sangfor_atrust_app_id + input_type: text + required: true + - key: app_secret + label: API 密钥 + storage: secret + config_key: app_secret + secret_id: sangfor_atrust_app_secret + input_type: password + required: true + - key: lang + label: 返回语言 + storage: config + config_key: lang + input_type: text + default: "zh-CN" + required: false +defaults: + timeout: 30 + category: custom + product_version: "3" + verify_ssl: false + locale: "zh-cn" + lang: "zh-CN" +notes: | + 深信服 aTrust OpenAPI V3 鉴权规则: + - 请求头需要携带 X-Ca-Key、X-Ca-Nonce、X-Ca-TimeStamp、X-Ca-Sign。 + - 签名串由请求 path、按 ASCII 排序后的 query 和紧凑 JSON body 组成。 + - 文档示例包含 lang 参数的 V3 接口默认携带 lang=zh-CN,可在配置或调用参数中覆盖。 + - handler 内部固定携带 locale=zh-cn Cookie,用于兼容 aTrust 中文本地化响应,无需在表单中配置。 + - 签名密钥格式为 appId={API ID}&appSecret={API密钥}×tamp={秒级时间戳}&nonce={随机数}。 + - 最终签名为 HMAC-SHA256 十六进制字符串。 + 设备侧需要先在 aTrust 控制中心创建 OpenAPI 对接设备,获取 API ID/API 密钥, + 并配置允许 Flocks 所在主机访问的接入 IP。 diff --git a/.flocks/flockshub/plugins/tools/device/sangfor_atrust_v3/_test.yaml b/.flocks/flockshub/plugins/tools/device/sangfor_atrust_v3/_test.yaml new file mode 100644 index 000000000..0c4bed7ad --- /dev/null +++ b/.flocks/flockshub/plugins/tools/device/sangfor_atrust_v3/_test.yaml @@ -0,0 +1,70 @@ +schema_version: 1 +provider: sangfor_atrust + +connectivity: + tool: sangfor_atrust_identity + params: + action: auth_server_list + +fixtures: + sangfor_atrust_monitor: + - label: Query online users + label_cn: 查询在线用户 + tags: [smoke, monitor] + params: + action: online_users + pageSize: 20 + pageIndex: 1 + assert: + success: true + sangfor_atrust_identity: + - label: List authentication servers + label_cn: 查询认证服务器列表 + tags: [smoke, identity] + params: + action: auth_server_list + assert: + success: true + - label: List user directories + label_cn: 查询用户目录列表 + tags: [identity] + params: + action: user_directory_list + + sangfor_atrust_resource: + - label: List applications + label_cn: 查询应用列表 + tags: [resource] + params: + action: resource_list + pageSize: 20 + pageIndex: 1 + assert: + success: true + + sangfor_atrust_endpoint: + - label: List endpoints + label_cn: 查询终端信息 + tags: [endpoint] + params: + action: endpoint_list + pageSize: 20 + pageIndex: 1 + + sangfor_atrust_api_readonly: + - label: Show API catalog + label_cn: 查看 API 目录 + tags: [api] + params: + action: api_catalog + assert: + success: true + + sangfor_atrust_api_mutation: + - label: Show API catalog before confirmed mutation + label_cn: 变更调用前查看 API 目录 + tags: [api, mutation] + params: + action: api_catalog + assert: + success: true diff --git a/.flocks/flockshub/plugins/tools/device/sangfor_atrust_v3/manifest.json b/.flocks/flockshub/plugins/tools/device/sangfor_atrust_v3/manifest.json new file mode 100644 index 000000000..dbbe317c1 --- /dev/null +++ b/.flocks/flockshub/plugins/tools/device/sangfor_atrust_v3/manifest.json @@ -0,0 +1,72 @@ +{ + "schemaVersion": "hub.plugin.v1", + "id": "sangfor_atrust_v3", + "type": "device", + "name": "Sangfor aTrust Zero Trust", + "description": "Sangfor aTrust Zero Trust Access Control OpenAPI V3 integration.", + "descriptionCn": "深信服 aTrust 零信任访问控制系统 OpenAPI V3 接入。", + "version": "3.0", + "author": "Flocks Team", + "license": "MIT", + "category": "integration", + "tags": [ + "iam", + "network", + "integration" + ], + "useCases": [ + "integration", + "incident-response", + "security-reporting" + ], + "domains": [ + "security-ops" + ], + "capabilities": [ + "device-integration", + "rest-api", + "hmac-auth" + ], + "trust": "official", + "source": { + "kind": "bundled", + "path": "plugins/tools/device/sangfor_atrust_v3" + }, + "compatibility": { + "flocks": ">=0.8.0", + "os": [ + "darwin", + "linux", + "windows" + ] + }, + "dependencies": { + "skills": [], + "tools": [], + "python": [], + "external": [] + }, + "permissions": { + "tools": [], + "network": true, + "shell": false, + "filesystem": "none" + }, + "risk": { + "level": "low", + "reasons": [] + }, + "entrypoints": [ + "_provider.yaml", + "_test.yaml", + "sangfor_atrust.handler.py", + "sangfor_atrust_api_catalog.json", + "sangfor_atrust_monitor.yaml", + "sangfor_atrust_identity.yaml", + "sangfor_atrust_resource.yaml", + "sangfor_atrust_endpoint.yaml", + "sangfor_atrust_api_readonly.yaml", + "sangfor_atrust_api_mutation.yaml" + ], + "checksums": {} +} diff --git a/.flocks/flockshub/plugins/tools/device/sangfor_atrust_v3/sangfor_atrust.handler.py b/.flocks/flockshub/plugins/tools/device/sangfor_atrust_v3/sangfor_atrust.handler.py new file mode 100644 index 000000000..bc7eb06ca --- /dev/null +++ b/.flocks/flockshub/plugins/tools/device/sangfor_atrust_v3/sangfor_atrust.handler.py @@ -0,0 +1,627 @@ +from __future__ import annotations + +import asyncio +import hashlib +import hmac +import json +import os +import time +import uuid +from pathlib import Path +from typing import Any, Callable +from urllib.parse import quote + +import requests + +from flocks.config.config_writer import ConfigWriter +from flocks.security import get_secret_manager +from flocks.tool.registry import ToolContext, ToolResult + + +SERVICE_ID = "sangfor_atrust" +STORAGE_KEY = "sangfor_atrust_v3" +PRODUCT_VERSION = "3" +DEFAULT_TIMEOUT = 30 +DEFAULT_VERIFY_SSL = False +DEFAULT_LOCALE = "zh-cn" +DEFAULT_QUERY_LANG = "zh-CN" +CATALOG_FILE = Path(__file__).with_name("sangfor_atrust_api_catalog.json") +DEFAULT_LANG_PATHS = { + "/api/v3/group/assignResourceByExternalId", + "/api/v3/group/assignResourceByFullPath", + "/api/v3/group/assignRoleByExternalId", + "/api/v3/group/assignRoleByFullPath", + "/api/v3/group/assignRoleById", + "/api/v3/group/bulkDeleteByExternalIdList", + "/api/v3/group/bulkDeleteByFullPathList", + "/api/v3/group/bulkDeleteByIdList", + "/api/v3/group/create", + "/api/v3/group/queryAll", + "/api/v3/group/updateByExternalId", + "/api/v3/group/updateByFullPath", + "/api/v3/group/updateById", + "/api/v3/role/assignGroupByExternalId", + "/api/v3/role/assignGroupById", + "/api/v3/role/assignGroupByName", + "/api/v3/role/assignResourceByExternalId", + "/api/v3/role/assignResourceById", + "/api/v3/role/assignResourceByName", + "/api/v3/role/assignUserByExternalId", + "/api/v3/role/assignUserById", + "/api/v3/role/assignUserByName", + "/api/v3/role/bulkDeleteByExternalIdList", + "/api/v3/role/bulkDeleteByIdList", + "/api/v3/role/bulkDeleteByNameList", + "/api/v3/role/create", + "/api/v3/role/queryAll", + "/api/v3/role/updateByExternalId", + "/api/v3/role/updateById", + "/api/v3/role/updateByName", + "/api/v3/user/assignResourceByExternalId", + "/api/v3/user/assignResourceById", + "/api/v3/user/assignResourceByName", + "/api/v3/user/assignRoleByExternalId", + "/api/v3/user/assignRoleById", + "/api/v3/user/assignRoleByName", + "/api/v3/user/bulkDeleteByExternalIdList", + "/api/v3/user/bulkDeleteByIdList", + "/api/v3/user/bulkDeleteByNameList", + "/api/v3/user/bulkUpdateByExternalIdList", + "/api/v3/user/bulkUpdateByIdList", + "/api/v3/user/bulkUpdateByNameList", + "/api/v3/user/create", + "/api/v3/user/queryAll", + "/api/v3/user/queryById", + "/api/v3/user/updateByExternalId", + "/api/v3/user/updateById", + "/api/v3/user/updateByName", +} + + +class ATrustError(RuntimeError): + pass + + +class RuntimeConfig: + def __init__( + self, + *, + base_url: str, + app_id: str, + app_secret: str, + verify_ssl: bool, + timeout: int, + locale: str, + default_lang: str, + ) -> None: + self.base_url = base_url + self.app_id = app_id + self.app_secret = app_secret + self.verify_ssl = verify_ssl + self.timeout = timeout + self.locale = locale + self.default_lang = default_lang + + +def _resolve_ref(value: Any) -> str: + if value is None: + return "" + if not isinstance(value, str): + return str(value) + if value.startswith("{secret:") and value.endswith("}"): + return get_secret_manager().get(value[len("{secret:") : -1]) or "" + if value.startswith("{env:") and value.endswith("}"): + return os.getenv(value[len("{env:") : -1], "") + return value + + +def _raw_service_config() -> dict[str, Any]: + raw = ConfigWriter.get_api_service_raw(SERVICE_ID) + if not isinstance(raw, dict): + raw = ConfigWriter.get_api_service_raw(STORAGE_KEY) + return raw if isinstance(raw, dict) else {} + + +def _config_value(raw: dict[str, Any], *keys: str) -> Any: + for key in keys: + if raw.get(key) is not None: + return raw[key] + custom_settings = raw.get("custom_settings") + if isinstance(custom_settings, dict): + for key in keys: + if custom_settings.get(key) is not None: + return custom_settings[key] + return None + + +def _as_bool(value: Any, default: bool) -> bool: + if value is None: + return default + if isinstance(value, bool): + return value + if isinstance(value, str): + text = value.strip().lower() + if text in {"1", "true", "yes", "on"}: + return True + if text in {"0", "false", "no", "off"}: + return False + return bool(value) + + +def _normalize_base_url(base_url: str) -> str: + text = base_url.strip().rstrip("/") + for suffix in ("/api/v3", "/api/v1", "/api"): + if text.endswith(suffix): + text = text[: -len(suffix)] + break + return text.rstrip("/") + + +def resolve_config() -> RuntimeConfig: + raw = _raw_service_config() + base_url = ( + _resolve_ref(_config_value(raw, "base_url", "baseUrl")) + or os.getenv("SANGFOR_ATRUST_BASE_URL", "") + ) + if not base_url: + raise ATrustError("Sangfor aTrust base_url is not configured") + + app_id = ( + _resolve_ref(_config_value(raw, "app_id", "appId", "api_id", "apiId")) + or get_secret_manager().get("sangfor_atrust_app_id") + or get_secret_manager().get(f"{SERVICE_ID}_app_id") + or os.getenv("SANGFOR_ATRUST_APP_ID", "") + ) + app_secret = ( + _resolve_ref(_config_value(raw, "app_secret", "appSecret", "api_secret", "apiSecret")) + or get_secret_manager().get("sangfor_atrust_app_secret") + or get_secret_manager().get(f"{SERVICE_ID}_app_secret") + or os.getenv("SANGFOR_ATRUST_APP_SECRET", "") + ) + if not app_id or not app_secret: + raise ATrustError("Sangfor aTrust API ID/API Secret is not configured") + + try: + timeout = int(_config_value(raw, "timeout") or DEFAULT_TIMEOUT) + except (TypeError, ValueError): + timeout = DEFAULT_TIMEOUT + verify_ssl = _as_bool( + _config_value(raw, "verify_ssl", "ssl_verify", "verifySsl") + if _config_value(raw, "verify_ssl", "ssl_verify", "verifySsl") is not None + else os.getenv("SANGFOR_ATRUST_VERIFY_SSL"), + DEFAULT_VERIFY_SSL, + ) + locale = ( + _resolve_ref(_config_value(raw, "locale", "locale_cookie")) + or os.getenv("SANGFOR_ATRUST_LOCALE", DEFAULT_LOCALE) + ) + default_lang = ( + _resolve_ref(_config_value(raw, "lang", "language", "default_lang")) + or os.getenv("SANGFOR_ATRUST_LANG", DEFAULT_QUERY_LANG) + ) + return RuntimeConfig( + base_url=_normalize_base_url(base_url), + app_id=app_id, + app_secret=app_secret, + verify_ssl=verify_ssl, + timeout=timeout, + locale=locale, + default_lang=default_lang, + ) + + +def _scalar(value: Any) -> str: + if isinstance(value, bool): + return "true" if value else "false" + return str(value) + + +def _query_pairs(query: dict[str, Any] | None) -> list[tuple[str, str]]: + if not query: + return [] + pairs: list[tuple[str, str]] = [] + for key in sorted(query, key=lambda item: str(item)): + value = query[key] + if value is None: + continue + key_text = str(key) + if isinstance(value, (list, tuple)): + for item in value: + if item is not None: + pairs.append((key_text, _scalar(item))) + else: + pairs.append((key_text, _scalar(value))) + return pairs + + +def _query_string(query: dict[str, Any] | None) -> str: + return "&".join(f"{key}={value}" for key, value in _query_pairs(query)) + + +def _url_query_string(query: dict[str, Any] | None) -> str: + return "&".join( + f"{quote(key, safe='')}={quote(value, safe='/')}" + for key, value in _query_pairs(query) + ) + + +def _body_text(body: Any) -> str: + if body is None: + return "" + return json.dumps(body, ensure_ascii=False, separators=(",", ":")) + + +def _signature_string(path: str, query_string: str, body_text: str) -> str: + if query_string and body_text: + return f"{path}?{query_string}&{body_text}" + if query_string: + return f"{path}?{query_string}" + if body_text: + return f"{path}?{body_text}" + return path + + +def _signature_headers(config: RuntimeConfig, path: str, query_string: str, body_text: str) -> dict[str, str]: + timestamp = str(int(time.time())) + nonce = str(uuid.uuid4()) + sign_key = ( + f"appId={config.app_id}&appSecret={config.app_secret}" + f"×tamp={timestamp}&nonce={nonce}" + ) + sign_text = _signature_string(path, query_string, body_text) + signature = hmac.new( + sign_key.encode("utf-8"), + sign_text.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + return { + "X-Ca-Key": config.app_id, + "X-Ca-Nonce": nonce, + "X-Ca-TimeStamp": timestamp, + "X-Ca-Sign": signature, + } + + +def _with_default_query_params( + path: str, + query: dict[str, Any] | None, + default_lang: str, +) -> dict[str, Any]: + merged = dict(query or {}) + if default_lang and path in DEFAULT_LANG_PATHS and "lang" not in merged: + merged["lang"] = default_lang + return merged + + +def _load_api_catalog() -> list[dict[str, Any]]: + try: + data = json.loads(CATALOG_FILE.read_text(encoding="utf-8")) + except FileNotFoundError: + return [] + entries = data.get("entries") + return entries if isinstance(entries, list) else [] + + +def _catalog_keys(kind: str) -> set[tuple[str, str]]: + return { + (str(entry.get("method", "")).upper(), str(entry.get("path", ""))) + for entry in _load_api_catalog() + if entry.get("kind") == kind and entry.get("method") and entry.get("path") + } + + +def _ok(data: Any, *, action: str) -> ToolResult: + return ToolResult( + success=True, + output=data, + metadata={"source": "Sangfor aTrust", "version": PRODUCT_VERSION, "action": action}, + ) + + +def _error_message(payload: Any, fallback: str) -> str: + if isinstance(payload, dict): + message = payload.get("msg") or payload.get("message") or payload.get("error") + code = payload.get("code") + if message and code is not None: + return f"aTrust API error (code={code}): {message}" + if message: + return str(message) + if code not in (None, "OK", 0, "0"): + return f"aTrust API error (code={code})" + return fallback + + +def _is_success(payload: Any) -> bool: + if not isinstance(payload, dict): + return True + code = payload.get("code") + return code in (None, "OK", 0, "0") + + +def _result(action: str, response: requests.Response) -> ToolResult: + try: + payload = response.json() + except ValueError: + text = response.text[:500] + if response.status_code >= 400: + return ToolResult(success=False, error=f"HTTP {response.status_code}: {text}") + return _ok(text, action=action) + + metadata = { + "source": "Sangfor aTrust", + "version": PRODUCT_VERSION, + "action": action, + } + if isinstance(payload, dict) and payload.get("traceId"): + metadata["traceId"] = payload["traceId"] + if response.status_code >= 400 or not _is_success(payload): + return ToolResult( + success=False, + error=_error_message(payload, fallback=f"HTTP {response.status_code}: {payload}"), + metadata=metadata, + ) + output = payload.get("data", payload) if isinstance(payload, dict) else payload + return ToolResult(success=True, output=output, metadata=metadata) + + +class ATrustClient: + def __init__(self, config: RuntimeConfig) -> None: + self.config = config + self.session = requests.Session() + + def request( + self, + method: str, + path: str, + *, + query: dict[str, Any] | None = None, + body: Any = None, + action: str = "", + ) -> ToolResult: + if not path.startswith("/"): + raise ATrustError("path must start with /") + query = _with_default_query_params(path, query, self.config.default_lang) + query_string = _query_string(query) + url_query_string = _url_query_string(query) + body_text = _body_text(body) + url = f"{self.config.base_url}{path}" + if url_query_string: + url = f"{url}?{url_query_string}" + headers = { + "Content-Type": "application/json;charset=UTF-8", + **_signature_headers(self.config, path, query_string, body_text), + } + if self.config.locale: + headers["Cookie"] = f"locale={self.config.locale}" + response = self.session.request( + method.upper(), + url, + data=body_text.encode("utf-8") if body_text else None, + headers=headers, + timeout=self.config.timeout, + verify=self.config.verify_ssl, + ) + return _result(action or path.rsplit("/", 1)[-1], response) + + +def get_client() -> ATrustClient: + return ATrustClient(resolve_config()) + + +def _nested_dict(args: dict[str, Any], key: str) -> dict[str, Any]: + value = args.get(key) + return dict(value) if isinstance(value, dict) else {} + + +def _extras(args: dict[str, Any]) -> dict[str, Any]: + ignored = {"action", "method", "path", "query", "body"} + return {key: value for key, value in args.items() if key not in ignored and value is not None} + + +def _query_from_args(args: dict[str, Any]) -> dict[str, Any]: + query = _nested_dict(args, "query") + for key, value in _extras(args).items(): + query.setdefault(key, value) + return query + + +def _body_from_args(args: dict[str, Any]) -> Any: + if isinstance(args.get("body"), dict): + return dict(args["body"]) + extras = _extras(args) + return extras or None + + +def _call_rest(method: str, path: str, args: dict[str, Any], *, action: str) -> ToolResult: + method = method.upper() + explicit_query = _nested_dict(args, "query") + if method == "GET": + query = explicit_query + for key, value in _extras(args).items(): + query.setdefault(key, value) + body = args.get("body") if args.get("body") is not None else None + else: + query = explicit_query + body = _body_from_args(args) + return get_client().request(method, path, query=query, body=body, action=action) + + +READONLY_ACTIONS: dict[str, tuple[str, str]] = { + "online_users": ("GET", "/api/v1/monitor/getUserStatus"), + "user_list": ("POST", "/api/v3/user/queryAll"), + "user_by_id": ("GET", "/api/v3/user/queryById"), + "user_by_name": ("GET", "/api/v3/user/queryByName"), + "user_by_external_id": ("GET", "/api/v3/user/queryByExternalId"), + "group_list": ("POST", "/api/v3/group/queryAll"), + "group_by_id": ("GET", "/api/v3/group/queryById"), + "group_by_full_path": ("GET", "/api/v3/group/queryByFullPath"), + "group_by_external_id": ("GET", "/api/v3/group/queryByExternalId"), + "role_list": ("POST", "/api/v3/role/queryAll"), + "role_by_id": ("GET", "/api/v3/role/queryById"), + "role_by_name": ("GET", "/api/v3/role/queryByName"), + "role_by_external_id": ("GET", "/api/v3/role/queryByExternalId"), + "resource_list": ("GET", "/api/v3/resource/queryAll"), + "resource_by_id": ("GET", "/api/v3/resource/queryById"), + "resource_by_name": ("GET", "/api/v3/resource/queryByName"), + "resource_group_list": ("GET", "/api/v3/resourceGroup/queryAll"), + "resource_assignment_by_id": ("POST", "/api/v3/resourceAssign/queryById"), + "resource_assignment_by_name": ("POST", "/api/v3/resourceAssign/queryByName"), + "resource_group_assignment_by_id": ("POST", "/api/v3/resourceGroupAssign/queryById"), + "resource_group_assignment_by_name": ("POST", "/api/v3/resourceGroupAssign/queryByName"), + "node_group_list": ("GET", "/api/v1/nodeGroup/queryAll"), + "auth_server_list": ("GET", "/api/v1/authServer/queryAll"), + "auth_server_detail": ("GET", "/api/v1/authServer/query"), + "user_directory_list": ("GET", "/api/v1/userDirectory/queryAll"), + "user_directory_detail": ("GET", "/api/v1/userDirectory/query"), + "endpoint_list": ("POST", "/api/v1/device/queryAll"), + "endpoint_detail": ("GET", "/api/v1/device/query"), +} + + +MONITOR_ACTIONS = {"online_users"} +IDENTITY_ACTIONS = { + "user_list", + "user_by_id", + "user_by_name", + "user_by_external_id", + "group_list", + "group_by_id", + "group_by_full_path", + "group_by_external_id", + "role_list", + "role_by_id", + "role_by_name", + "role_by_external_id", + "auth_server_list", + "auth_server_detail", + "user_directory_list", + "user_directory_detail", +} +RESOURCE_ACTIONS = { + "resource_list", + "resource_by_id", + "resource_by_name", + "resource_group_list", + "resource_assignment_by_id", + "resource_assignment_by_name", + "resource_group_assignment_by_id", + "resource_group_assignment_by_name", + "node_group_list", +} +ENDPOINT_ACTIONS = {"endpoint_list", "endpoint_detail"} + + +def api_catalog(args: dict[str, Any]) -> ToolResult: + del args + catalog = _load_api_catalog() + return _ok( + { + "catalog_counts": { + "total": len(catalog), + "readonly": sum(1 for entry in catalog if entry.get("kind") == "readonly"), + "mutation": sum(1 for entry in catalog if entry.get("kind") == "mutation"), + }, + "documented_api_catalog": catalog, + "common_actions": { + "monitor": sorted(MONITOR_ACTIONS), + "identity": sorted(IDENTITY_ACTIONS), + "resource": sorted(RESOURCE_ACTIONS), + "endpoint": sorted(ENDPOINT_ACTIONS), + }, + }, + action="api_catalog", + ) + + +def rest_call_readonly(args: dict[str, Any]) -> ToolResult: + method = str(args.get("method") or "GET").upper() + path = str(args.get("path") or "").strip() + if not path: + raise ATrustError("path is required") + if (method, path) not in _catalog_keys("readonly"): + raise ATrustError("Only documented read-only aTrust OpenAPI paths are allowed") + return _call_rest(method, path, args, action="rest_call_readonly") + + +def rest_call_mutation(args: dict[str, Any]) -> ToolResult: + method = str(args.get("method") or "POST").upper() + path = str(args.get("path") or "").strip() + if not path: + raise ATrustError("path is required") + if (method, path) not in _catalog_keys("mutation"): + raise ATrustError("Only documented mutation aTrust OpenAPI paths are allowed") + return _call_rest(method, path, args, action="rest_call_mutation") + + +ACTION_HANDLERS: dict[str, Callable[[dict[str, Any]], ToolResult]] = { + "api_catalog": api_catalog, + "rest_call_readonly": rest_call_readonly, + "rest_call_mutation": rest_call_mutation, +} +for _action, (_method, _path) in READONLY_ACTIONS.items(): + ACTION_HANDLERS[_action] = ( + lambda args, method=_method, path=_path, action=_action: _call_rest(method, path, args, action=action) + ) + + +async def _dispatch(ctx: ToolContext, allowed: set[str], action: str, **params: Any) -> ToolResult: + del ctx + if action == "test": + for fallback in ("auth_server_list", "online_users", "resource_list", "endpoint_list"): + if fallback in allowed: + action = fallback + break + if action not in allowed: + return ToolResult( + success=False, + error=f"Unsupported Sangfor aTrust action: {action}. Available: {', '.join(sorted(allowed))}", + ) + try: + return await asyncio.to_thread(ACTION_HANDLERS[action], params) + except ATrustError as exc: + return ToolResult( + success=False, + error=str(exc), + metadata={"source": "Sangfor aTrust", "version": PRODUCT_VERSION, "action": action}, + ) + except requests.RequestException as exc: + return ToolResult( + success=False, + error=f"aTrust request failed: {exc}", + metadata={"source": "Sangfor aTrust", "version": PRODUCT_VERSION, "action": action}, + ) + except Exception as exc: + return ToolResult( + success=False, + error=f"Unexpected Sangfor aTrust error: {exc}", + metadata={"source": "Sangfor aTrust", "version": PRODUCT_VERSION, "action": action}, + ) + + +async def monitor(ctx: ToolContext, action: str, **params: Any) -> ToolResult: + return await _dispatch(ctx, MONITOR_ACTIONS | {"test"}, action, **params) + + +async def identity(ctx: ToolContext, action: str, **params: Any) -> ToolResult: + return await _dispatch(ctx, IDENTITY_ACTIONS | {"test"}, action, **params) + + +async def resource(ctx: ToolContext, action: str, **params: Any) -> ToolResult: + return await _dispatch(ctx, RESOURCE_ACTIONS | {"test"}, action, **params) + + +async def endpoint(ctx: ToolContext, action: str, **params: Any) -> ToolResult: + return await _dispatch(ctx, ENDPOINT_ACTIONS | {"test"}, action, **params) + + +async def api_readonly(ctx: ToolContext, action: str, **params: Any) -> ToolResult: + return await _dispatch( + ctx, + {"api_catalog", "rest_call_readonly", *READONLY_ACTIONS.keys(), "test"}, + action, + **params, + ) + + +async def api_mutation(ctx: ToolContext, action: str, **params: Any) -> ToolResult: + return await _dispatch(ctx, {"api_catalog", "rest_call_mutation"}, action, **params) diff --git a/.flocks/flockshub/plugins/tools/device/sangfor_atrust_v3/sangfor_atrust_api_catalog.json b/.flocks/flockshub/plugins/tools/device/sangfor_atrust_v3/sangfor_atrust_api_catalog.json new file mode 100644 index 000000000..066afd9dd --- /dev/null +++ b/.flocks/flockshub/plugins/tools/device/sangfor_atrust_v3/sangfor_atrust_api_catalog.json @@ -0,0 +1,582 @@ +{ + "schema_version": 1, + "source": "Sangfor aTrust OpenAPI V3 documentation", + "entries": [ + { + "method": "GET", + "path": "/api/v1/monitor/getUserStatus", + "kind": "readonly", + "description": "4.1.1 查询在线用户" + }, + { + "method": "POST", + "path": "/api/v1/monitor/kickoutUsers", + "kind": "mutation", + "description": "4.1.2 踢出在线用户" + }, + { + "method": "POST", + "path": "/api/v1/spa/sendSpaCode", + "kind": "mutation", + "description": "4.10.1 申请发送SPA安全码" + }, + { + "method": "POST", + "path": "/api/v3/user/create", + "kind": "mutation", + "description": "4.2.1.1 新增用户" + }, + { + "method": "POST", + "path": "/api/v3/user/updateById", + "kind": "mutation", + "description": "4.2.2.1 编辑用户-基于id" + }, + { + "method": "POST", + "path": "/api/v3/user/updateByName", + "kind": "mutation", + "description": "4.2.2.2 编辑用户-基于名称" + }, + { + "method": "POST", + "path": "/api/v3/user/updateByExternalId", + "kind": "mutation", + "description": "4.2.2.3 编辑用户-基于外部id" + }, + { + "method": "POST", + "path": "/api/v3/user/bulkUpdateByIdList", + "kind": "mutation", + "description": "4.2.2.4 批量编辑用户-基于id" + }, + { + "method": "POST", + "path": "/api/v3/user/bulkUpdateByNameList", + "kind": "mutation", + "description": "4.2.2.5 批量编辑用户-基于名称" + }, + { + "method": "POST", + "path": "/api/v3/user/bulkUpdateByExternalIdList", + "kind": "mutation", + "description": "4.2.2.6 批量编辑用户-基于外部id" + }, + { + "method": "POST", + "path": "/api/v3/user/bulkDeleteByIdList", + "kind": "mutation", + "description": "4.2.3.1 批量删除用户-基于id" + }, + { + "method": "POST", + "path": "/api/v3/user/bulkDeleteByNameList", + "kind": "mutation", + "description": "4.2.3.2 批量删除用户-基于名称" + }, + { + "method": "POST", + "path": "/api/v3/user/bulkDeleteByExternalIdList", + "kind": "mutation", + "description": "4.2.3.3 批量删除用户-基于外部id" + }, + { + "method": "GET", + "path": "/api/v3/user/queryById", + "kind": "readonly", + "description": "4.2.4.1 查询用户详情-基于id" + }, + { + "method": "GET", + "path": "/api/v3/user/queryByName", + "kind": "readonly", + "description": "4.2.4.2 查询用户详情-基于名称" + }, + { + "method": "GET", + "path": "/api/v3/user/queryByExternalId", + "kind": "readonly", + "description": "4.2.4.3 查询用户详情-基于外部id" + }, + { + "method": "POST", + "path": "/api/v3/user/queryAll", + "kind": "readonly", + "description": "4.2.4.4 查询用户列表" + }, + { + "method": "POST", + "path": "/api/v3/user/assignRoleById", + "kind": "mutation", + "description": "4.2.5.1 关联角色-基于id" + }, + { + "method": "POST", + "path": "/api/v3/user/assignRoleByName", + "kind": "mutation", + "description": "4.2.5.2 关联角色-基于名称" + }, + { + "method": "POST", + "path": "/api/v3/user/assignRoleByExternalId", + "kind": "mutation", + "description": "4.2.5.3 关联角色-基于外部id" + }, + { + "method": "POST", + "path": "/api/v3/user/assignResourceById", + "kind": "mutation", + "description": "4.2.6.1 授权应用-基于id" + }, + { + "method": "POST", + "path": "/api/v3/user/assignResourceByName", + "kind": "mutation", + "description": "4.2.6.2 授权应用-基于名称" + }, + { + "method": "POST", + "path": "/api/v3/user/assignResourceByExternalId", + "kind": "mutation", + "description": "4.2.6.3 授权应用-基于外部id" + }, + { + "method": "POST", + "path": "/api/v3/user/reportStatus", + "kind": "mutation", + "description": "4.2.7.1 上报自定义用户状态" + }, + { + "method": "POST", + "path": "/api/v3/group/create", + "kind": "mutation", + "description": "4.3.1.1 新增组织架构" + }, + { + "method": "POST", + "path": "/api/v3/group/updateById", + "kind": "mutation", + "description": "4.3.2.1 编辑组织架构-基于id" + }, + { + "method": "POST", + "path": "/api/v3/group/updateByFullPath", + "kind": "mutation", + "description": "4.3.2.2 编辑组织架构-基于路径" + }, + { + "method": "POST", + "path": "/api/v3/group/updateByExternalId", + "kind": "mutation", + "description": "4.3.2.3 编辑组织架构-基于外部id" + }, + { + "method": "POST", + "path": "/api/v3/group/bulkDeleteByIdList", + "kind": "mutation", + "description": "4.3.3.1 删除组织架构-基于id" + }, + { + "method": "POST", + "path": "/api/v3/group/bulkDeleteByFullPathList", + "kind": "mutation", + "description": "4.3.3.2 删除组织架构-基于路径" + }, + { + "method": "POST", + "path": "/api/v3/group/bulkDeleteByExternalIdList", + "kind": "mutation", + "description": "4.3.3.3 删除组织架构-基于外部id" + }, + { + "method": "GET", + "path": "/api/v3/group/queryById", + "kind": "readonly", + "description": "4.3.4.1 查询组织架构详情-基于id" + }, + { + "method": "GET", + "path": "/api/v3/group/queryByFullPath", + "kind": "readonly", + "description": "4.3.4.2 查询组织架构详情-基于路径" + }, + { + "method": "GET", + "path": "/api/v3/group/queryByExternalId", + "kind": "readonly", + "description": "4.3.4.3 查询组织架构详情-基于外部id" + }, + { + "method": "POST", + "path": "/api/v3/group/queryAll", + "kind": "readonly", + "description": "4.3.4.4 查询组织架构列表" + }, + { + "method": "POST", + "path": "/api/v3/group/assignRoleById", + "kind": "mutation", + "description": "4.3.5.1 关联角色-基于id" + }, + { + "method": "POST", + "path": "/api/v3/group/assignRoleByFullPath", + "kind": "mutation", + "description": "4.3.5.2 关联角色-基于路径" + }, + { + "method": "POST", + "path": "/api/v3/group/assignRoleByExternalId", + "kind": "mutation", + "description": "4.3.5.3 关联角色-基于外部id" + }, + { + "method": "POST", + "path": "/api/v3/group/assignResourceById", + "kind": "mutation", + "description": "4.3.6.1 授权应用-基于id" + }, + { + "method": "POST", + "path": "/api/v3/group/assignResourceByFullPath", + "kind": "mutation", + "description": "4.3.6.2 授权应用-基于路径" + }, + { + "method": "POST", + "path": "/api/v3/group/assignResourceByExternalId", + "kind": "mutation", + "description": "4.3.6.3 授权应用-基于外部id" + }, + { + "method": "POST", + "path": "/api/v3/role/create", + "kind": "mutation", + "description": "4.4.1.1 新增角色" + }, + { + "method": "POST", + "path": "/api/v3/role/updateById", + "kind": "mutation", + "description": "4.4.2.1 编辑角色-基于id" + }, + { + "method": "POST", + "path": "/api/v3/role/updateByName", + "kind": "mutation", + "description": "4.4.2.2 编辑角色-基于名称" + }, + { + "method": "POST", + "path": "/api/v3/role/updateByExternalId", + "kind": "mutation", + "description": "4.4.2.3 编辑角色-基于外部id" + }, + { + "method": "POST", + "path": "/api/v3/role/bulkDeleteByIdList", + "kind": "mutation", + "description": "4.4.3.1 批量删除角色-基于id" + }, + { + "method": "POST", + "path": "/api/v3/role/bulkDeleteByNameList", + "kind": "mutation", + "description": "4.4.3.2 批量删除角色-基于名称" + }, + { + "method": "POST", + "path": "/api/v3/role/bulkDeleteByExternalIdList", + "kind": "mutation", + "description": "4.4.3.3 批量删除角色-基于外部id" + }, + { + "method": "GET", + "path": "/api/v3/role/queryById", + "kind": "readonly", + "description": "4.4.4.1 查询角色详情-基于id" + }, + { + "method": "GET", + "path": "/api/v3/role/queryByName", + "kind": "readonly", + "description": "4.4.4.2 查询角色详情-基于名称" + }, + { + "method": "GET", + "path": "/api/v3/role/queryByExternalId", + "kind": "readonly", + "description": "4.4.4.3 查询角色详情-基于外部id" + }, + { + "method": "POST", + "path": "/api/v3/role/queryAll", + "kind": "readonly", + "description": "4.4.4.4 查询角色列表" + }, + { + "method": "POST", + "path": "/api/v3/role/assignUserById", + "kind": "mutation", + "description": "4.4.5.1 关联用户-基于id" + }, + { + "method": "POST", + "path": "/api/v3/role/assignUserByName", + "kind": "mutation", + "description": "4.4.5.2 关联用户-基于名称" + }, + { + "method": "POST", + "path": "/api/v3/role/assignUserByExternalId", + "kind": "mutation", + "description": "4.4.5.3 关联用户-基于外部id" + }, + { + "method": "POST", + "path": "/api/v3/role/assignGroupById", + "kind": "mutation", + "description": "4.4.6.1 关联组织架构-基于id" + }, + { + "method": "POST", + "path": "/api/v3/role/assignGroupByName", + "kind": "mutation", + "description": "4.4.6.2 关联组织架构-基于名称" + }, + { + "method": "POST", + "path": "/api/v3/role/assignGroupByExternalId", + "kind": "mutation", + "description": "4.4.6.3 关联组织架构-基于外部id" + }, + { + "method": "POST", + "path": "/api/v3/role/assignResourceById", + "kind": "mutation", + "description": "4.4.7.1 授权应用-基于id" + }, + { + "method": "POST", + "path": "/api/v3/role/assignResourceByName", + "kind": "mutation", + "description": "4.4.7.2 授权应用-基于名称" + }, + { + "method": "POST", + "path": "/api/v3/role/assignResourceByExternalId", + "kind": "mutation", + "description": "4.4.7.3 授权应用-基于外部id" + }, + { + "method": "POST", + "path": "/api/v1/resource/createResource", + "kind": "mutation", + "description": "4.5.1 新增WEB或隧道应用" + }, + { + "method": "POST", + "path": "/api/v1/resource/updateResource", + "kind": "mutation", + "description": "4.5.2 编辑WEB或隧道应用" + }, + { + "method": "POST", + "path": "/api/v1/resource/deleteResource", + "kind": "mutation", + "description": "4.5.3.1 批量删除应用" + }, + { + "method": "GET", + "path": "/api/v3/resource/queryAll", + "kind": "readonly", + "description": "4.5.4.1 查询应用列表" + }, + { + "method": "GET", + "path": "/api/v3/resource/queryById", + "kind": "readonly", + "description": "4.5.4.2 查询应用详情-基于id" + }, + { + "method": "GET", + "path": "/api/v3/resource/queryByName", + "kind": "readonly", + "description": "4.5.4.3 查询应用详情-基于名称" + }, + { + "method": "POST", + "path": "/api/v3/resource/assignById", + "kind": "mutation", + "description": "4.5.5.1 应用授权-基于id" + }, + { + "method": "POST", + "path": "/api/v3/resource/assignByName", + "kind": "mutation", + "description": "4.5.5.2 应用授权-基于名称" + }, + { + "method": "POST", + "path": "/api/v3/resourceAssign/queryById", + "kind": "readonly", + "description": "4.5.5.3 查询应用授权-基于id" + }, + { + "method": "POST", + "path": "/api/v3/resourceAssign/queryByName", + "kind": "readonly", + "description": "4.5.5.4 查询应用授权-基于名称" + }, + { + "method": "GET", + "path": "/api/v1/nodeGroup/queryAll", + "kind": "readonly", + "description": "4.5.6.1 查询代理网关节点区域" + }, + { + "method": "POST", + "path": "/api/v1/resource/createResourceGroup", + "kind": "mutation", + "description": "4.6.1.1 新增应用分类" + }, + { + "method": "POST", + "path": "/api/v1/resource/updateResourceGroup", + "kind": "mutation", + "description": "4.6.2.1 修改应用分类" + }, + { + "method": "POST", + "path": "/api/v1/resource/deleteResourceGroup", + "kind": "mutation", + "description": "4.6.3.1 批量删除应用分类" + }, + { + "method": "GET", + "path": "/api/v3/resourceGroup/queryAll", + "kind": "readonly", + "description": "4.6.4.1 查询应用分类列表" + }, + { + "method": "POST", + "path": "/api/v3/resourceGroup/assignById", + "kind": "mutation", + "description": "4.6.5.1 应用分类授权-基于id" + }, + { + "method": "POST", + "path": "/api/v3/resourceGroup/assignByName", + "kind": "mutation", + "description": "4.6.5.2 应用分类授权-基于名称" + }, + { + "method": "POST", + "path": "/api/v3/resourceGroupAssign/queryById", + "kind": "readonly", + "description": "4.6.5.3 查询应用分类授权-基于id" + }, + { + "method": "POST", + "path": "/api/v3/resourceGroupAssign/queryByName", + "kind": "readonly", + "description": "4.6.5.4 查询应用分类授权-基于名称" + }, + { + "method": "GET", + "path": "/api/v1/authServer/queryAll", + "kind": "readonly", + "description": "4.7.1 查询认证服务器列表" + }, + { + "method": "GET", + "path": "/api/v1/authServer/query", + "kind": "readonly", + "description": "4.7.2 查询认证服务器详情" + }, + { + "method": "GET", + "path": "/api/v1/userDirectory/queryAll", + "kind": "readonly", + "description": "4.8.1 查询用户目录列表" + }, + { + "method": "POST", + "path": "/api/v1/userDirectory/create", + "kind": "mutation", + "description": "4.8.2 新增用户目录" + }, + { + "method": "GET", + "path": "/api/v1/userDirectory/query", + "kind": "readonly", + "description": "4.8.3 查询用户目录详情" + }, + { + "method": "POST", + "path": "/api/v1/userDirectory/update", + "kind": "mutation", + "description": "4.8.4 修改用户目录" + }, + { + "method": "POST", + "path": "/api/v1/userDirectory/delete", + "kind": "mutation", + "description": "4.8.5 删除用户目录" + }, + { + "method": "POST", + "path": "/api/v1/device/create", + "kind": "mutation", + "description": "4.9.1 新增终端信息" + }, + { + "method": "POST", + "path": "/api/v1/device/update", + "kind": "mutation", + "description": "4.9.2 修改终端信息" + }, + { + "method": "POST", + "path": "/api/v1/device/delete", + "kind": "mutation", + "description": "4.9.3 删除终端信息" + }, + { + "method": "GET", + "path": "/api/v1/device/query", + "kind": "readonly", + "description": "4.9.4 查询单个终端信息" + }, + { + "method": "POST", + "path": "/api/v1/device/queryAll", + "kind": "readonly", + "description": "4.9.5 查询全量终端信息" + }, + { + "method": "POST", + "path": "/api/v1/device/assignUser", + "kind": "mutation", + "description": "4.9.6 终端绑定用户" + }, + { + "method": "POST", + "path": "/api/v1/device/unassignUser", + "kind": "mutation", + "description": "4.9.7 终端解绑用户" + }, + { + "method": "POST", + "path": "/api/v1/device/setTag", + "kind": "mutation", + "description": "4.9.8 终端设置标签" + }, + { + "method": "POST", + "path": "/api/v1/device/unsetTag", + "kind": "mutation", + "description": "4.9.9 终端取消标签" + } + ] +} diff --git a/.flocks/flockshub/plugins/tools/device/sangfor_atrust_v3/sangfor_atrust_api_mutation.yaml b/.flocks/flockshub/plugins/tools/device/sangfor_atrust_v3/sangfor_atrust_api_mutation.yaml new file mode 100644 index 000000000..6e917d50d --- /dev/null +++ b/.flocks/flockshub/plugins/tools/device/sangfor_atrust_v3/sangfor_atrust_api_mutation.yaml @@ -0,0 +1,34 @@ +name: sangfor_atrust_api_mutation +description: Sangfor aTrust OpenAPI V3 documented mutation REST caller with confirmation. +description_cn: 深信服 aTrust OpenAPI V3 文档内变更类 REST 调用工具。所有调用都需要确认。 +category: custom +enabled: true +requires_confirmation: true +provider: sangfor_atrust +version: "3" +inputSchema: + type: object + properties: + action: + type: string + enum: + - api_catalog + - rest_call_mutation + method: + type: string + description: HTTP 方法,必须和 catalog 中 kind=mutation 的 method/path 匹配。 + path: + type: string + description: REST 路径,例如 /api/v3/user/create。 + query: + type: object + description: Query string 参数。 + body: + type: object + description: JSON body,按 aTrust OpenAPI V3 文档对应接口填写。 + required: + - action +handler: + type: script + script_file: sangfor_atrust.handler.py + function: api_mutation diff --git a/.flocks/flockshub/plugins/tools/device/sangfor_atrust_v3/sangfor_atrust_api_readonly.yaml b/.flocks/flockshub/plugins/tools/device/sangfor_atrust_v3/sangfor_atrust_api_readonly.yaml new file mode 100644 index 000000000..90ab6c5c3 --- /dev/null +++ b/.flocks/flockshub/plugins/tools/device/sangfor_atrust_v3/sangfor_atrust_api_readonly.yaml @@ -0,0 +1,40 @@ +name: sangfor_atrust_api_readonly +description: Sangfor aTrust OpenAPI V3 documented read-only REST caller. +description_cn: 深信服 aTrust OpenAPI V3 文档内只读 REST 调用工具。使用 api_catalog 查看已收录 API,再用 rest_call_readonly 调用 kind=readonly 的 method/path。 +category: custom +enabled: true +requires_confirmation: false +provider: sangfor_atrust +version: "3" +inputSchema: + type: object + properties: + action: + type: string + enum: + - api_catalog + - rest_call_readonly + - online_users + - auth_server_list + - user_directory_list + - resource_list + - endpoint_list + - test + method: + type: string + description: HTTP 方法,必须和 catalog 中 kind=readonly 的 method/path 匹配。 + path: + type: string + description: REST 路径,例如 /api/v3/resource/queryAll。 + query: + type: object + description: Query string 参数。 + body: + type: object + description: JSON body;部分只读查询接口使用 POST body。 + required: + - action +handler: + type: script + script_file: sangfor_atrust.handler.py + function: api_readonly diff --git a/.flocks/flockshub/plugins/tools/device/sangfor_atrust_v3/sangfor_atrust_endpoint.yaml b/.flocks/flockshub/plugins/tools/device/sangfor_atrust_v3/sangfor_atrust_endpoint.yaml new file mode 100644 index 000000000..7c3179f90 --- /dev/null +++ b/.flocks/flockshub/plugins/tools/device/sangfor_atrust_v3/sangfor_atrust_endpoint.yaml @@ -0,0 +1,47 @@ +name: sangfor_atrust_endpoint +description: Sangfor aTrust OpenAPI V3 endpoint device read-only queries. +description_cn: 深信服 aTrust OpenAPI V3 终端信息只读查询工具。 +category: custom +enabled: true +requires_confirmation: false +provider: sangfor_atrust +version: "3" +inputSchema: + type: object + properties: + action: + type: string + enum: + - endpoint_list + - endpoint_detail + - test + description: | + - endpoint_list: POST /api/v1/device/queryAll,查询全量终端信息。 + - endpoint_detail: GET /api/v1/device/query,查询单个终端信息。 + id: + type: string + description: 终端 ID。 + name: + type: string + description: 终端名称。 + userId: + type: string + description: 用户 ID。 + pageSize: + type: integer + description: 分页大小。 + pageIndex: + type: integer + description: 分页页码。 + query: + type: object + description: GET query string 参数。 + body: + type: object + description: POST JSON body;传入后会优先使用该对象。 + required: + - action +handler: + type: script + script_file: sangfor_atrust.handler.py + function: endpoint diff --git a/.flocks/flockshub/plugins/tools/device/sangfor_atrust_v3/sangfor_atrust_identity.yaml b/.flocks/flockshub/plugins/tools/device/sangfor_atrust_v3/sangfor_atrust_identity.yaml new file mode 100644 index 000000000..b19e82880 --- /dev/null +++ b/.flocks/flockshub/plugins/tools/device/sangfor_atrust_v3/sangfor_atrust_identity.yaml @@ -0,0 +1,83 @@ +name: sangfor_atrust_identity +description: Sangfor aTrust OpenAPI V3 identity, organization, role, auth-server, and directory read-only queries. +description_cn: 深信服 aTrust OpenAPI V3 用户、组织架构、角色、认证服务器和用户目录只读查询工具。 +category: custom +enabled: true +requires_confirmation: false +provider: sangfor_atrust +version: "3" +inputSchema: + type: object + properties: + action: + type: string + enum: + - user_list + - user_by_id + - user_by_name + - user_by_external_id + - group_list + - group_by_id + - group_by_full_path + - group_by_external_id + - role_list + - role_by_id + - role_by_name + - role_by_external_id + - auth_server_list + - auth_server_detail + - user_directory_list + - user_directory_detail + - test + description: | + 身份类只读动作: + - user_list: POST /api/v3/user/queryAll,查询用户列表。 + - user_by_id/name/external_id: GET /api/v3/user/queryBy*,查询用户详情。 + - group_list: POST /api/v3/group/queryAll,查询组织架构列表。 + - group_by_id/full_path/external_id: GET /api/v3/group/queryBy*,查询组织架构详情。 + - role_list: POST /api/v3/role/queryAll,查询角色列表。 + - role_by_id/name/external_id: GET /api/v3/role/queryBy*,查询角色详情。 + - auth_server_list/detail: GET /api/v1/authServer/queryAll 或 /query。 + - user_directory_list/detail: GET /api/v1/userDirectory/queryAll 或 /query。 + id: + type: string + description: 用户、组织架构、角色、认证服务器或用户目录 ID。 + name: + type: string + description: 名称。 + externalId: + type: string + description: 外部 ID。 + fullPath: + type: string + description: 组织架构完整路径。 + directoryDomain: + type: string + description: 用户目录唯一标识,本地用户目录通常为 local。 + pageSize: + type: integer + description: 分页大小。 + pageIndex: + type: integer + description: 分页页码,从 1 开始。 + fuzzyMatch: + type: string + description: 用户列表模糊匹配关键词。 + searchByPath: + type: string + description: 根据组织架构路径精确搜索用户。 + recursive: + type: integer + description: 根据组织架构搜索时是否递归,1 递归,0 不递归。 + query: + type: object + description: GET query string 参数。 + body: + type: object + description: POST JSON body;传入后会优先使用该对象。 + required: + - action +handler: + type: script + script_file: sangfor_atrust.handler.py + function: identity diff --git a/.flocks/flockshub/plugins/tools/device/sangfor_atrust_v3/sangfor_atrust_monitor.yaml b/.flocks/flockshub/plugins/tools/device/sangfor_atrust_v3/sangfor_atrust_monitor.yaml new file mode 100644 index 000000000..dfa17ea2e --- /dev/null +++ b/.flocks/flockshub/plugins/tools/device/sangfor_atrust_v3/sangfor_atrust_monitor.yaml @@ -0,0 +1,45 @@ +name: sangfor_atrust_monitor +description: Sangfor aTrust OpenAPI V3 online monitoring queries. +description_cn: 深信服 aTrust OpenAPI V3 在线监控查询工具。 +category: custom +enabled: true +requires_confirmation: false +provider: sangfor_atrust +version: "3" +inputSchema: + type: object + properties: + action: + type: string + enum: + - online_users + - test + description: | + - online_users: GET /api/v1/monitor/getUserStatus,查询在线用户。 + pageSize: + type: integer + description: 每页大小,默认由 aTrust 服务端决定。 + pageIndex: + type: integer + description: 当前页,从 1 开始。 + filter: + type: string + description: 过滤条件,如 all、name、displayName、groupPath、remoteIp、vip、os、browser。 + searchValue: + type: string + description: 搜索值。 + sortBy: + type: string + description: 排序字段。 + asc: + type: integer + description: 是否升序,1 为升序,0 为降序。 + query: + type: object + description: 额外 query string 参数,会与上方简单字段合并。 + required: + - action +handler: + type: script + script_file: sangfor_atrust.handler.py + function: monitor diff --git a/.flocks/flockshub/plugins/tools/device/sangfor_atrust_v3/sangfor_atrust_resource.yaml b/.flocks/flockshub/plugins/tools/device/sangfor_atrust_v3/sangfor_atrust_resource.yaml new file mode 100644 index 000000000..56ddbf44f --- /dev/null +++ b/.flocks/flockshub/plugins/tools/device/sangfor_atrust_v3/sangfor_atrust_resource.yaml @@ -0,0 +1,82 @@ +name: sangfor_atrust_resource +description: Sangfor aTrust OpenAPI V3 application, application-group, and authorization read-only queries. +description_cn: 深信服 aTrust OpenAPI V3 应用、应用分类和授权关系只读查询工具。 +category: custom +enabled: true +requires_confirmation: false +provider: sangfor_atrust +version: "3" +inputSchema: + type: object + properties: + action: + type: string + enum: + - resource_list + - resource_by_id + - resource_by_name + - resource_group_list + - resource_assignment_by_id + - resource_assignment_by_name + - resource_group_assignment_by_id + - resource_group_assignment_by_name + - node_group_list + - test + description: | + 应用类只读动作: + - resource_list: GET /api/v3/resource/queryAll,查询应用列表。 + - resource_by_id/name: GET /api/v3/resource/queryBy*,查询应用详情。 + - resource_group_list: GET /api/v3/resourceGroup/queryAll,查询应用分类列表。 + - resource_assignment_by_id/name: POST /api/v3/resourceAssign/queryBy*,查询应用授权。 + - resource_group_assignment_by_id/name: POST /api/v3/resourceGroupAssign/queryBy*,查询应用分类授权。 + - node_group_list: GET /api/v1/nodeGroup/queryAll,查询代理网关节点区域。 + id: + type: string + description: 应用、应用分类或授权对象 ID。 + name: + type: string + description: 应用或应用分类名称。 + groupId: + type: string + description: 应用分类 ID。 + groupName: + type: string + description: 应用分类名称。 + pageSize: + type: integer + description: 分页大小。 + pageIndex: + type: integer + description: 分页页码。 + filter: + type: string + description: 过滤字段。 + searchValue: + type: string + description: 搜索关键字。 + sortBy: + type: string + description: 排序字段。 + asc: + type: integer + description: 是否升序,1 为升序,0 为降序。 + isPaged: + type: integer + description: 是否分页,1 分页,0 不分页。 + entityType: + type: array + items: + type: string + description: 授权对象类型,如 user、group、band。 + query: + type: object + description: GET query string 参数。 + body: + type: object + description: POST JSON body;传入后会优先使用该对象。 + required: + - action +handler: + type: script + script_file: sangfor_atrust.handler.py + function: resource diff --git a/.flocks/flockshub/plugins/tools/python/soc_workspace_query/manifest.json b/.flocks/flockshub/plugins/tools/python/soc_workspace_query/manifest.json new file mode 100644 index 000000000..a1ca1acf3 --- /dev/null +++ b/.flocks/flockshub/plugins/tools/python/soc_workspace_query/manifest.json @@ -0,0 +1,63 @@ +{ + "schemaVersion": "hub.plugin.v1", + "id": "soc_workspace_query", + "type": "tool", + "name": "Flocks SOC Workspace Query", + "description": "Query information from Flocks own SOC workspace pages, including dashboard, overview, alert lists, alert details, triage reports, and workflow status.", + "descriptionCn": "查询 Flocks 自身 SOC 工作区里的信息,包括 dashboard、overview、告警调查列表、告警详情、研判报告和工作流状态。", + "version": "1.0.0", + "author": "Flocks Team", + "license": "MIT", + "homepage": "", + "category": "integration", + "tags": [ + "siem", + "ndr", + "integration" + ], + "useCases": [ + "alert-triage", + "log-analysis", + "security-reporting" + ], + "domains": [ + "security-ops" + ], + "capabilities": [ + "file-analysis" + ], + "trust": "official", + "source": { + "kind": "bundled", + "path": "plugins/tools/python/soc_workspace_query" + }, + "compatibility": { + "flocks": ">=0.8.0", + "os": [ + "darwin", + "linux", + "windows" + ] + }, + "dependencies": { + "skills": [], + "tools": [], + "python": [], + "external": [] + }, + "permissions": { + "tools": [], + "network": false, + "shell": false, + "filesystem": "read" + }, + "risk": { + "level": "low", + "reasons": [] + }, + "entrypoints": [ + "soc_workspace_query.py" + ], + "components": [], + "checksums": {} +} diff --git a/.flocks/flockshub/plugins/tools/python/soc_workspace_query/soc_workspace_query.py b/.flocks/flockshub/plugins/tools/python/soc_workspace_query/soc_workspace_query.py new file mode 100644 index 000000000..bf874145b --- /dev/null +++ b/.flocks/flockshub/plugins/tools/python/soc_workspace_query/soc_workspace_query.py @@ -0,0 +1,773 @@ +from __future__ import annotations + +import importlib.util +import json +import re +import sqlite3 +import sys +from collections import Counter +from datetime import datetime, time as dt_time +from pathlib import Path +from typing import Any, Optional +from zoneinfo import ZoneInfo + +from flocks.tool.registry import ( + ParameterType, + ToolCategory, + ToolContext, + ToolParameter, + ToolRegistry, + ToolResult, +) + + +SOC_DB = Path.home() / ".flocks" / "data" / "soc.db" +WORKFLOW_DB = Path.home() / ".flocks" / "data" / "workflow.db" +SOC_UI_ROOT = Path.home() / ".flocks" / "plugins" / "contracts" / "webui" / "soc_ui" +SOC_TZ = ZoneInfo("Asia/Shanghai") + +ALERT_FIELDS = { + "row_id": "row_id", + "record_id": "record_id", + "source_type": "COALESCE(source_type, json_extract(record_json, '$._source_type'), json_extract(record_json, '$.source_type'))", + "threat_name": "COALESCE(threat_name, json_extract(record_json, '$.threat_name'))", + "threat_type": "json_extract(record_json, '$.threat_type')", + "threat_phase": "json_extract(record_json, '$.threat_phase')", + "attack_result": "COALESCE(json_extract(record_json, '$.attach_result'), json_extract(record_json, '$.threat_result'), json_extract(record_json, '$.attack_verdict'))", + "attack_verdict": "json_extract(record_json, '$.attack_verdict')", + "direction": "json_extract(record_json, '$.direction')", + "sip": "json_extract(record_json, '$.sip')", + "dip": "json_extract(record_json, '$.dip')", + "sport": "json_extract(record_json, '$.sport')", + "dport": "json_extract(record_json, '$.dport')", + "req_host": "json_extract(record_json, '$.req_host')", + "req_http_url": "json_extract(record_json, '$.req_http_url')", + "rsp_status_code": "json_extract(record_json, '$.rsp_status_code')", + "threat_rule_id": "json_extract(record_json, '$.threat_rule_id')", +} + +PAGE_VALUES = ["all", "dashboard", "overview", "alerts", "alert_detail", "workflow", "schema"] + + +class _Request: + def __init__(self, query_params: dict[str, Any]) -> None: + self.query_params = query_params + + +def _parameters() -> list[ToolParameter]: + return [ + ToolParameter( + name="page", + type=ParameterType.STRING, + description=( + "SOC workspace page to query. Use all for a compact workspace snapshot, " + "dashboard for SOC dashboard stats, overview for SOC overview stats, " + "alerts for the investigation list, alert_detail for one alert record, " + "workflow for workflow execution status, or schema for available fields." + ), + required=False, + default="all", + enum=PAGE_VALUES, + ), + ToolParameter( + name="filters", + type=ParameterType.OBJECT, + description=( + "Optional query filters. Supported keys include date, startDate, endDate, " + "startTime, endTime, keyword, record_id, row_id, source_type, threat_name, " + "threat_type, threat_phase, attack_result, attack_verdict, direction, sip, " + "dip, req_host, req_http_url, rsp_status_code, include_duplicates, and " + "workflow_id. startTime/endTime may be epoch seconds, epoch milliseconds, " + "or YYYY-MM-DD HH:MM:SS strings." + ), + required=False, + default=None, + ), + ToolParameter( + name="limit", + type=ParameterType.INTEGER, + description="Maximum alert list or workflow execution rows to return. Default 50, max 500.", + required=False, + default=50, + ), + ToolParameter( + name="offset", + type=ParameterType.INTEGER, + description="Alert list offset for pagination.", + required=False, + default=0, + ), + ToolParameter( + name="order", + type=ParameterType.STRING, + description="Alert ordering by event time. desc matches the SOC list default.", + required=False, + default="desc", + enum=["desc", "asc"], + ), + ToolParameter( + name="include_raw", + type=ParameterType.BOOLEAN, + description="Include the raw record_json payload for alert rows and detail results.", + required=False, + default=False, + ), + ToolParameter( + name="include_reports", + type=ParameterType.BOOLEAN, + description="Include triage_report/final_report and report title fields when present.", + required=False, + default=True, + ), + ] + + +@ToolRegistry.register_function( + name="soc_workspace_query", + description=( + "Query information rendered by Flocks' own SOC workspace pages: dashboard, overview, " + "alert investigation list, alert detail/triage report, and workflow status. This tool " + "is for inspecting the local Flocks SOC workspace itself, not for querying an external " + "SOC product. It reads only local Flocks databases: ~/.flocks/data/soc.db and " + "~/.flocks/data/workflow.db." + ), + description_cn=( + "查询 Flocks 自身 SOC 工作区页面信息,包括 dashboard、overview、告警调查列表、" + "单条告警详情、研判报告和工作流状态。这个工具用于查看本机 Flocks 自身的 SOC " + "工作区数据,不是查询外部 SOC 系统;只读取 ~/.flocks/data/soc.db 和 " + "~/.flocks/data/workflow.db。" + ), + category=ToolCategory.CUSTOM, + parameters=_parameters(), + tags=["soc", "flocks", "workspace", "dashboard", "overview", "alerts", "sqlite"], +) +async def soc_workspace_query( + ctx: ToolContext, + page: str = "all", + filters: Optional[dict[str, Any]] = None, + limit: int = 50, + offset: int = 0, + order: str = "desc", + include_raw: bool = False, + include_reports: bool = True, +) -> ToolResult: + page = (page or "all").strip().lower() + if page not in PAGE_VALUES: + return ToolResult(success=False, error=f"Unsupported SOC page: {page!r}") + + safe_filters = filters if isinstance(filters, dict) else {} + safe_limit = max(1, min(int(limit or 50), 500)) + safe_offset = max(0, int(offset or 0)) + safe_order = "asc" if str(order).lower() == "asc" else "desc" + + try: + output = _query_page( + page=page, + filters=safe_filters, + limit=safe_limit, + offset=safe_offset, + order=safe_order, + include_raw=bool(include_raw), + include_reports=bool(include_reports), + ) + except Exception as exc: + return ToolResult( + success=False, + error=f"Failed to query Flocks SOC workspace: {exc}", + metadata={"session_id": ctx.session_id, "page": page}, + ) + + return ToolResult( + success=True, + output=output, + title=f"Flocks SOC workspace: {page}", + metadata={ + "session_id": ctx.session_id, + "page": page, + "soc_db": _display_path(SOC_DB), + "workflow_db": _display_path(WORKFLOW_DB), + }, + ) + + +def _query_page( + *, + page: str, + filters: dict[str, Any], + limit: int, + offset: int, + order: str, + include_raw: bool, + include_reports: bool, +) -> dict[str, Any]: + if page == "schema": + return _schema() + if page == "dashboard": + return _with_source("dashboard", _page_stats("soc_dashboard", filters)) + if page == "overview": + return _with_source("overview", _page_stats("soc_overview", filters)) + if page == "alerts": + return _query_alerts(filters, limit, offset, order, include_raw, include_reports) + if page == "alert_detail": + return _query_alert_detail(filters, include_raw, include_reports) + if page == "workflow": + return _query_workflow(filters, limit) + + alerts = _query_alerts(filters, min(limit, 20), offset, order, include_raw, include_reports) + return { + "page": "all", + "generatedAt": _now(), + "source": _source_info(), + "dashboard": _safe_page_stats("soc_dashboard", filters), + "overview": _safe_page_stats("soc_overview", filters), + "alerts": alerts, + "workflow": _query_workflow(filters, min(limit, 20)), + } + + +def _schema() -> dict[str, Any]: + return { + "page": "schema", + "generatedAt": _now(), + "source": _source_info(), + "pages": PAGE_VALUES, + "alertFilterFields": sorted(ALERT_FIELDS), + "alertRecordFields": _record_field_sample(), + "notes": [ + "dashboard and overview reuse the installed SOC WebUI API handlers.", + "alerts and alert_detail read alert_records from ~/.flocks/data/soc.db.", + "workflow reads workflow_stats and workflow_executions from ~/.flocks/data/workflow.db.", + ], + } + + +def _page_stats(page_dir_name: str, filters: dict[str, Any]) -> dict[str, Any]: + handler_path = SOC_UI_ROOT / page_dir_name / "api" / "handlers.py" + if not handler_path.is_file(): + raise FileNotFoundError(f"SOC page handler not found: {handler_path}") + + module_name = f"_flocks_soc_{page_dir_name}_handler" + spec = importlib.util.spec_from_file_location(module_name, handler_path) + if spec is None or spec.loader is None: + raise RuntimeError(f"Cannot load SOC page handler: {handler_path}") + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + + query = _page_query_params(filters) + return module.get_stats(None, _Request(query)) + + +def _safe_page_stats(page_dir_name: str, filters: dict[str, Any]) -> dict[str, Any]: + try: + return _with_source(page_dir_name.replace("soc_", ""), _page_stats(page_dir_name, filters)) + except Exception as exc: + return {"page": page_dir_name.replace("soc_", ""), "error": str(exc), "source": _source_info()} + + +def _page_query_params(filters: dict[str, Any]) -> dict[str, Any]: + mapping = { + "date": "date", + "startDate": "startDate", + "endDate": "endDate", + "start_date": "startDate", + "end_date": "endDate", + "startTime": "startTime", + "endTime": "endTime", + "start_time": "startTime", + "end_time": "endTime", + } + query: dict[str, Any] = {} + for source, target in mapping.items(): + value = filters.get(source) + if value not in (None, ""): + query[target] = value + return query + + +def _with_source(page: str, data: dict[str, Any]) -> dict[str, Any]: + result = dict(data) + result.setdefault("page", page) + result.setdefault("source", _source_info()) + return result + + +def _query_alerts( + filters: dict[str, Any], + limit: int, + offset: int, + order: str, + include_raw: bool, + include_reports: bool, +) -> dict[str, Any]: + _require_db(SOC_DB, "SOC alert database") + where, params, applied = _alert_where(filters) + order_sql = "ASC" if order == "asc" else "DESC" + query = f""" + SELECT row_id, record_id, asset_date, source_file, line_number, event_time, + source_type, threat_name, is_duplicate, record_json + FROM alert_records + WHERE {where} + ORDER BY event_time {order_sql}, row_id {order_sql} + LIMIT ? OFFSET ? + """ + count_query = f"SELECT COUNT(*) FROM alert_records WHERE {where}" + + with sqlite3.connect(SOC_DB) as conn: + conn.row_factory = sqlite3.Row + total = int(conn.execute(count_query, params).fetchone()[0]) + rows = conn.execute(query, [*params, limit, offset]).fetchall() + + items = [_normalize_alert_row(row, include_raw, include_reports) for row in rows] + return { + "page": "alerts", + "generatedAt": _now(), + "source": _source_info(), + "summary": { + "total": total, + "limit": limit, + "offset": offset, + "returned": len(items), + "order": order, + "latestDate": _latest_asset_date(), + }, + "filtersApplied": applied, + "facets": _alert_facets(where, params), + "items": items, + } + + +def _query_alert_detail(filters: dict[str, Any], include_raw: bool, include_reports: bool) -> dict[str, Any]: + detail_filters = dict(filters) + if not any(detail_filters.get(key) for key in ("row_id", "record_id", "id")): + raise ValueError("alert_detail requires filters.row_id, filters.record_id, or filters.id") + if detail_filters.get("id") and not detail_filters.get("record_id"): + detail_filters["record_id"] = detail_filters["id"] + + result = _query_alerts(detail_filters, 1, 0, "desc", include_raw=True, include_reports=include_reports) + if not result["items"]: + return { + "page": "alert_detail", + "generatedAt": _now(), + "source": _source_info(), + "found": False, + "filtersApplied": result["filtersApplied"], + } + + item = result["items"][0] + raw = item.get("raw") if isinstance(item.get("raw"), dict) else {} + detail = { + "page": "alert_detail", + "generatedAt": _now(), + "source": _source_info(), + "found": True, + "summary": item["summary"], + "network": item["network"], + "http": item["http"], + "triage": item.get("triage", {}), + "reports": item.get("reports", {}), + "raw": raw if include_raw else None, + } + if not include_raw: + detail.pop("raw", None) + return detail + + +def _query_workflow(filters: dict[str, Any], limit: int) -> dict[str, Any]: + _require_db(WORKFLOW_DB, "workflow database") + workflow_id = _clean(filters.get("workflow_id") or "") + limit = max(1, min(limit, 100)) + stats_params: list[Any] = [] + stats_where = "" + if workflow_id: + stats_where = "WHERE workflow_id = ?" + stats_params.append(workflow_id) + + exec_where = [] + exec_params: list[Any] = [] + if workflow_id: + exec_where.append("workflow_id = ?") + exec_params.append(workflow_id) + start_ms, end_ms = _workflow_time_range(filters) + if start_ms is not None: + exec_where.append("started_at >= ?") + exec_params.append(start_ms) + if end_ms is not None: + exec_where.append("started_at <= ?") + exec_params.append(end_ms) + exec_where_sql = " AND ".join(exec_where) if exec_where else "1=1" + + with sqlite3.connect(WORKFLOW_DB) as conn: + conn.row_factory = sqlite3.Row + stats_rows = conn.execute( + f""" + SELECT workflow_id, call_count, success_count, error_count, total_runtime, + avg_runtime, thumbs_up, thumbs_down, updated_at + FROM workflow_stats + {stats_where} + ORDER BY updated_at DESC + """, + stats_params, + ).fetchall() + execution_rows = conn.execute( + f""" + SELECT id, workflow_id, status, current_phase, current_node_id, + current_node_type, current_step_index, step_count, + error_message, trigger_id, trigger_type, started_at, + finished_at, duration, updated_at + FROM workflow_executions + WHERE {exec_where_sql} + ORDER BY started_at DESC + LIMIT ? + """, + [*exec_params, limit], + ).fetchall() + + return { + "page": "workflow", + "generatedAt": _now(), + "source": _source_info(), + "summary": { + "statsRows": len(stats_rows), + "executionRows": len(execution_rows), + "workflowId": workflow_id or "all", + }, + "stats": [_normalize_workflow_stat(row) for row in stats_rows], + "executions": [_normalize_workflow_execution(row) for row in execution_rows], + } + + +def _alert_where(filters: dict[str, Any]) -> tuple[str, list[Any], list[dict[str, Any]]]: + clauses = [] + params: list[Any] = [] + applied: list[dict[str, Any]] = [] + + if not _truthy(filters.get("include_duplicates")): + clauses.append("is_duplicate = 0") + applied.append({"field": "include_duplicates", "value": False}) + + date_value = filters.get("date") + start_date = filters.get("startDate") or filters.get("start_date") + end_date = filters.get("endDate") or filters.get("end_date") + if date_value and not start_date and not end_date: + start_date = end_date = date_value + if start_date: + clauses.append("asset_date >= ?") + params.append(str(start_date)) + applied.append({"field": "startDate", "value": str(start_date)}) + if end_date: + clauses.append("asset_date <= ?") + params.append(str(end_date)) + applied.append({"field": "endDate", "value": str(end_date)}) + + start_time = _to_epoch_seconds(filters.get("startTime", filters.get("start_time"))) + end_time = _to_epoch_seconds(filters.get("endTime", filters.get("end_time"))) + if start_time is not None: + clauses.append("event_time >= ?") + params.append(start_time) + applied.append({"field": "startTime", "value": start_time}) + if end_time is not None: + clauses.append("event_time <= ?") + params.append(end_time) + applied.append({"field": "endTime", "value": end_time}) + + keyword = _clean(filters.get("keyword")) + if keyword: + clauses.append("(record_json LIKE ? OR threat_name LIKE ? OR record_id LIKE ? OR row_id LIKE ?)") + like = f"%{keyword}%" + params.extend([like, like, like, like]) + applied.append({"field": "keyword", "value": keyword}) + + for key, expression in ALERT_FIELDS.items(): + if key in {"row_id", "record_id"}: + value = filters.get(key) + else: + value = filters.get(key) + if value in (None, "", []): + continue + if isinstance(value, (list, tuple, set)): + cleaned = [_clean(item) for item in value if _clean(item)] + if not cleaned: + continue + placeholders = ", ".join("?" for _ in cleaned) + clauses.append(f"{expression} IN ({placeholders})") + params.extend(cleaned) + applied.append({"field": key, "value": cleaned}) + else: + clauses.append(f"{expression} = ?") + params.append(_clean(value)) + applied.append({"field": key, "value": _clean(value)}) + + return " AND ".join(clauses) if clauses else "1=1", params, applied + + +def _alert_facets(where: str, params: list[Any]) -> dict[str, list[dict[str, Any]]]: + facets: dict[str, list[dict[str, Any]]] = {} + facet_fields = { + "source_type": "COALESCE(source_type, json_extract(record_json, '$._source_type'), json_extract(record_json, '$.source_type'))", + "threat_name": "COALESCE(threat_name, json_extract(record_json, '$.threat_name'))", + "threat_type": "json_extract(record_json, '$.threat_type')", + "threat_phase": "json_extract(record_json, '$.threat_phase')", + "attack_result": "COALESCE(json_extract(record_json, '$.attach_result'), json_extract(record_json, '$.threat_result'), json_extract(record_json, '$.attack_verdict'))", + "direction": "json_extract(record_json, '$.direction')", + } + with sqlite3.connect(SOC_DB) as conn: + for key, expression in facet_fields.items(): + rows = conn.execute( + f""" + SELECT {expression} AS value, COUNT(*) AS count + FROM alert_records + WHERE {where} + GROUP BY value + ORDER BY count DESC + LIMIT 20 + """, + params, + ).fetchall() + facets[key] = [ + {"value": _clean(value) or "unknown", "count": int(count or 0)} + for value, count in rows + if value not in (None, "") + ] + return facets + + +def _normalize_alert_row(row: sqlite3.Row, include_raw: bool, include_reports: bool) -> dict[str, Any]: + record = _loads(row["record_json"]) + event_time = _safe_int(record.get("time"), _safe_int(row["event_time"])) + threat_name = _clean(record.get("threat_name") or row["threat_name"] or "未知告警") + attack_result = _attack_result(record) + normalized = { + "rowId": row["row_id"], + "recordId": row["record_id"] or record.get("id"), + "summary": { + "eventTime": event_time, + "eventTimeText": _format_epoch(event_time), + "assetDate": row["asset_date"], + "sourceType": _clean(row["source_type"] or record.get("_source_type") or record.get("source_type")), + "threatName": threat_name, + "threatMessage": _clean(record.get("threat_msg")), + "threatType": _clean(record.get("threat_type")), + "threatPhase": _clean(record.get("threat_phase")), + "attackBehavior": _clean(record.get("attack_verdict") or record.get("triage_status") or "unknown"), + "attackResult": attack_result, + "riskLevel": _clean(record.get("risk_level") or record.get("threat_level") or record.get("threat_severity")), + "isDuplicate": bool(row["is_duplicate"]), + }, + "network": { + "direction": _clean(record.get("direction")), + "sourceIp": _clean(record.get("sip")), + "sourcePort": _safe_int(record.get("sport")), + "destinationIp": _clean(record.get("dip")), + "destinationPort": _safe_int(record.get("dport")), + "protocol": _clean(record.get("net_type") or record.get("net_app_proto")), + }, + "http": { + "host": _clean(record.get("req_host")), + "url": _clean(record.get("req_http_url")), + "requestLine": _clean(record.get("req_line")), + "responseStatus": _safe_int(record.get("rsp_status_code")), + "userAgent": _clean(record.get("req_user_agent")), + }, + "triage": { + "ruleId": _clean(record.get("threat_rule_id")), + "reportTitle": _clean(record.get("report_title")), + "hasTriageReport": bool(_clean(record.get("triage_report"))), + "hasFinalReport": bool(_clean(record.get("final_report"))), + }, + } + if include_reports: + normalized["reports"] = { + "reportTitle": _clean(record.get("report_title")), + "triageReport": _clean(record.get("triage_report")), + "finalReport": _clean(record.get("final_report")), + } + if include_raw: + normalized["raw"] = record + return normalized + + +def _normalize_workflow_stat(row: sqlite3.Row) -> dict[str, Any]: + updated_at = _safe_int(row["updated_at"]) + return { + "workflowId": row["workflow_id"], + "callCount": _safe_int(row["call_count"]), + "successCount": _safe_int(row["success_count"]), + "errorCount": _safe_int(row["error_count"]), + "totalRuntime": float(row["total_runtime"] or 0), + "avgRuntime": float(row["avg_runtime"] or 0), + "thumbsUp": _safe_int(row["thumbs_up"]), + "thumbsDown": _safe_int(row["thumbs_down"]), + "updatedAt": updated_at, + "updatedAtText": _format_epoch_ms(updated_at), + } + + +def _normalize_workflow_execution(row: sqlite3.Row) -> dict[str, Any]: + started_at = _safe_int(row["started_at"]) + finished_at = _safe_int(row["finished_at"]) + return { + "id": row["id"], + "workflowId": row["workflow_id"], + "status": row["status"], + "currentPhase": row["current_phase"], + "currentNodeId": row["current_node_id"], + "currentNodeType": row["current_node_type"], + "currentStepIndex": _safe_int(row["current_step_index"]), + "stepCount": _safe_int(row["step_count"]), + "errorMessage": row["error_message"], + "triggerId": row["trigger_id"], + "triggerType": row["trigger_type"], + "startedAt": started_at, + "startedAtText": _format_epoch_ms(started_at), + "finishedAt": finished_at, + "finishedAtText": _format_epoch_ms(finished_at), + "duration": float(row["duration"] or 0), + "updatedAt": _safe_int(row["updated_at"]), + } + + +def _record_field_sample() -> list[str]: + if not SOC_DB.is_file(): + return [] + try: + with sqlite3.connect(SOC_DB) as conn: + row = conn.execute("SELECT record_json FROM alert_records LIMIT 1").fetchone() + if not row: + return [] + value = _loads(row[0]) + return sorted(str(key) for key in value.keys()) + except Exception: + return [] + + +def _latest_asset_date() -> str: + if not SOC_DB.is_file(): + return "" + try: + with sqlite3.connect(SOC_DB) as conn: + row = conn.execute("SELECT MAX(asset_date) FROM alert_records").fetchone() + return str(row[0] or "") + except Exception: + return "" + + +def _source_info() -> dict[str, Any]: + return { + "type": "flocks-local-soc-workspace", + "socDb": _display_path(SOC_DB), + "socDbExists": SOC_DB.is_file(), + "workflowDb": _display_path(WORKFLOW_DB), + "workflowDbExists": WORKFLOW_DB.is_file(), + "jsonlEnabled": False, + "externalSoc": False, + } + + +def _workflow_time_range(filters: dict[str, Any]) -> tuple[int | None, int | None]: + start = _to_epoch_seconds(filters.get("startTime", filters.get("start_time"))) + end = _to_epoch_seconds(filters.get("endTime", filters.get("end_time"))) + if start is None and filters.get("startDate"): + start = _to_epoch_seconds(f"{filters['startDate']} 00:00:00") + if end is None and filters.get("endDate"): + end = _to_epoch_seconds(f"{filters['endDate']} 23:59:59") + return (start * 1000 if start is not None else None, end * 1000 if end is not None else None) + + +def _to_epoch_seconds(value: Any) -> int | None: + if value in (None, ""): + return None + if isinstance(value, (int, float)): + number = int(value) + return number // 1000 if number > 10_000_000_000 else number + text = str(value).strip() + if not text: + return None + if re.fullmatch(r"\d+(\.\d+)?", text): + number = int(float(text)) + return number // 1000 if number > 10_000_000_000 else number + for fmt in ("%Y-%m-%d %H:%M:%S", "%Y/%m/%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d"): + try: + parsed = datetime.strptime(text[:19] if "T" in fmt else text, fmt) + if fmt == "%Y-%m-%d": + parsed = datetime.combine(parsed.date(), dt_time.min) + return int(parsed.replace(tzinfo=SOC_TZ).timestamp()) + except ValueError: + continue + return None + + +def _attack_result(record: dict[str, Any]) -> str: + for key in ("attach_result", "threat_result", "attack_success", "attack_verdict"): + value = _clean(record.get(key)) + if value: + return value + status = _safe_int(record.get("rsp_status_code")) + if status in {401, 403, 404, 405, 406, 410}: + return "failed" + return "unknown" + + +def _loads(value: Any) -> dict[str, Any]: + if isinstance(value, dict): + return value + if not isinstance(value, str) or not value.strip(): + return {} + try: + parsed = json.loads(value) + except Exception: + return {} + return parsed if isinstance(parsed, dict) else {} + + +def _safe_int(value: Any, default: int = 0) -> int: + if value in (None, ""): + return default + try: + return int(float(value)) + except (TypeError, ValueError): + return default + + +def _clean(value: Any) -> str: + if value is None: + return "" + return str(value).strip() + + +def _truthy(value: Any) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, (int, float)): + return value != 0 + if isinstance(value, str): + return value.strip().lower() in {"1", "true", "yes", "y", "on"} + return bool(value) + + +def _format_epoch(value: int) -> str: + if not value: + return "" + return datetime.fromtimestamp(value, SOC_TZ).strftime("%Y-%m-%d %H:%M:%S") + + +def _format_epoch_ms(value: int) -> str: + if not value: + return "" + return _format_epoch(value // 1000 if value > 10_000_000_000 else value) + + +def _now() -> str: + return datetime.now(SOC_TZ).isoformat(timespec="seconds") + + +def _display_path(path: Path) -> str: + try: + return str(path.expanduser()) + except Exception: + return str(path) + + +def _require_db(path: Path, label: str) -> None: + if not path.is_file(): + raise FileNotFoundError(f"{label} does not exist: {path}") diff --git a/.flocks/flockshub/plugins/webuis/soc_ui/access/soc_alerts_operations.py b/.flocks/flockshub/plugins/webuis/soc_ui/access/soc_alerts_operations.py new file mode 100644 index 000000000..463212ca4 --- /dev/null +++ b/.flocks/flockshub/plugins/webuis/soc_ui/access/soc_alerts_operations.py @@ -0,0 +1,513 @@ +"""SOC alert investigation WebUI access contract.""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +from datetime import datetime +from pathlib import Path +from typing import Any + +from flocks.contracts.access.models import ( + Binding, + Contract, + ContractOperation, + DriverResult, + InternalDataRow, + RuntimeContext, + WebUIContractPlugin, +) +from flocks.contracts.access.pipeline import OverlayStore + + +PAGE_ID = "soc-alerts" +CONTRACT_ID = "soc.alerts.operations" +CONTRACT_VERSION = "1.0" +SOURCE_PAGE_ID = "soc-alerts" +DEFAULT_SQLITE_DB = Path.home() / ".flocks" / "data" / "soc.db" +DEFAULT_SQLITE_TABLE = "alert_records" +DEFAULT_SQLITE_RECORD_COLUMN = "record_json" +DEFAULT_SQLITE_DATE_COLUMN = "asset_date" +DEFAULT_SQLITE_EVENT_TIME_COLUMN = "event_time" +SQL_IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") + +DISPLAY_FIELDS = ( + "id", + "time", + "direction", + "sip", + "sport", + "dip", + "dport", + "net_type", + "net_app_proto", + "req_host", + "req_http_url", + "req_user_agent", + "req_line", + "req_header", + "req_body", + "req_body_len", + "rsp_status_code", + "rsp_line", + "rsp_header", + "rsp_body", + "rsp_body_len", + "threat_rule_id", + "threat_name", + "threat_msg", + "threat_level", + "threat_severity", + "threat_phase", + "threat_type", + "threat_result", + "attack_verdict", + "attack_success", + "risk_level", + "triage_report", + "report_title", + "asset_group_name", + "asset_name", + "_source_type", + "_process_type", + "_threat_type", + "_lsh_cluster_id", + "dedup_key", + "is_duplicate", + "_syslog_meta", +) +DRIVER_FIELDS = frozenset(DISPLAY_FIELDS) +FILTER_FIELDS = frozenset( + { + "_source_type", + "net_type", + "direction", + "threat_name", + "threat_type", + "threat_phase", + "threat_result", + "rsp_status_code", + "sip", + "dport", + "dip", + "req_host", + "threat_rule_id", + } +) + +TABLE_COLUMNS = ( + {"key": "time", "label": "Event Time"}, + {"key": "threat_name", "label": "Threat Name"}, + {"key": "threat_type", "label": "Threat Type"}, + {"key": "threat_phase", "label": "Attack Stage"}, + {"key": "threat_result", "label": "Attack Result"}, + {"key": "direction", "label": "Direction"}, + {"key": "sip", "label": "Source IP"}, + {"key": "sport", "label": "Source Port"}, + {"key": "dip", "label": "Destination IP"}, + {"key": "dport", "label": "Destination Port"}, + {"key": "req_http_url", "label": "Request URL"}, +) + + +def _contract() -> Contract: + return Contract( + contract_id=CONTRACT_ID, + version=CONTRACT_VERSION, + page_id=PAGE_ID, + operations={ + "list": ContractOperation( + name="list", + operation_type="query", + adapter_required_fields=DRIVER_FIELDS, + identity_fields=frozenset({"id"}), + public_fields=frozenset({"source", "summary", "tableColumns", "incidents"}), + filter_fields=FILTER_FIELDS, + filter_param_fields={field: field for field in FILTER_FIELDS}, + cursor_fields=frozenset({"time", "id"}), + sort_fields=frozenset({"time", "id"}), + default_limit=10000, + max_limit=10000, + ), + }, + ) + + +class _BindingResolver: + def resolve(self, *, page_id: str, slot_id: str, contract_id: str, contract_version: str) -> Binding: + settings = _sqlite_settings() + db_path = settings["db_path"] + return Binding( + binding_id="soc-alerts-sqlite", + binding_version=1, + page_id=page_id, + slot_id=slot_id, + contract_id=contract_id, + contract_version=contract_version, + adapter_kind="builtin-sqlite-json", + source_page_id=SOURCE_PAGE_ID, + source_root=db_path, + driver_available_fields=DRIVER_FIELDS, + driver_allowlist_roots=(db_path.parent,), + driver_options={ + "table": settings["table"], + "recordColumn": settings["record_column"], + "dateColumn": settings["date_column"], + "eventTimeColumn": settings["event_time_column"], + }, + capabilities=frozenset({"query"}), + ) + + +class _Adapter: + def normalize(self, driver_result: DriverResult) -> list[InternalDataRow]: + rows: list[InternalDataRow] = [] + for record in driver_result.rows: + record_id = _record_id(record) + rows.append( + InternalDataRow( + raw=record, + identity={"entityType": "soc-alert", "entityId": f"soc-alert:{record_id}"}, + ) + ) + return rows + + +class _ResponsePipeline: + def __init__(self, overlay_store: OverlayStore) -> None: + self._overlay_store = overlay_store + + def run_query( + self, + *, + context: RuntimeContext, + binding_source_page_id: str, + driver_result: DriverResult, + rows: list[InternalDataRow], + filter_stages_applied: list[dict[str, str]], + ) -> dict[str, Any]: + merged_rows = self._overlay_store.merge(rows, context) + incidents = [_incident_from_row(row) for row in merged_rows] + buckets = [_verdict_bucket(incident.get("_record", {})) for incident in incidents] + dates = [date for incident in incidents if (date := _date_part(_text(incident.get("observedAt"))))] + for incident in incidents: + incident.pop("_record", None) + + return { + "schemaVersion": "soc.alerts.v1", + "generatedAt": datetime.now().isoformat(timespec="seconds"), + "source": { + "label": "SOC Contract SQLite", + "pageId": binding_source_page_id, + "sampleMode": False, + "dataSource": "sqlite", + }, + "summary": { + "sourcePageId": binding_source_page_id, + "sourceAssetDate": max(dates) if dates else "-", + "sourceAssetFile": _source_file_label(driver_result.source_files), + "totalRaw": driver_result.total_raw, + "totalUnique": driver_result.total_unique, + "duplicates": driver_result.duplicates, + "attackSuccess": buckets.count("success"), + "attack": buckets.count("attack"), + "attackFailed": buckets.count("failed"), + "benign": buckets.count("benign"), + "unknown": buckets.count("unknown"), + "representativeCount": driver_result.filtered_unique, + "filterStagesApplied": filter_stages_applied, + }, + "tableColumns": list(TABLE_COLUMNS), + "incidents": incidents, + } + + +def _incident_from_row(row: InternalDataRow) -> dict[str, Any]: + record = row.raw + record_id = _record_id(record) + observed_at = _observed_at(record) + threat_name = _first_text(record, "threat_name", "_threat_type", "report_title") or "SOC alert" + threat_msg = _first_text(record, "threat_msg", "report_title", "threat_type") + verdict = _verdict_bucket(record) + table_cells = _table_cells(record) + triage_report = _text(record.get("triage_report")) + report_title = _first_text(record, "report_title") or _report_title_from_markdown(triage_report) + + return { + "id": record_id, + "sourceRecordId": record_id, + "observedAt": observed_at, + "rawAlerts": 1, + "priority": "P1" if verdict == "success" else "P2", + "reportTitle": report_title, + "reason": threat_msg, + "owner": "", + "srcIp": _text(record.get("sip")), + "ndrRule": _text(record.get("threat_rule_id")), + "request": { + "method": _request_method(record), + "host": _text(record.get("req_host")), + "uri": _text(record.get("req_http_url")), + "payload": _text(record.get("req_body")), + "evidence": [_text(record.get("req_line"))] if _text(record.get("req_line")) else [], + }, + "response": { + "statusCode": _int_or_none(record.get("rsp_status_code")), + "sample": _text(record.get("rsp_body")), + "evidence": [_text(record.get("rsp_line"))] if _text(record.get("rsp_line")) else [], + }, + "asset": { + "name": _text(record.get("asset_name")), + "business": _text(record.get("asset_group_name")), + }, + "conclusion": { + "verdict": _verdict_label(verdict), + "summary": threat_msg or threat_name, + "recommendation": "", + }, + "actions": [], + "title": threat_name, + "triageReport": triage_report, + "tableCells": table_cells, + "overlayVersion": _int_or_none(record.get("_overlay_version")) or 0, + "_record": record, + } + + +def _table_cells(record: dict[str, Any]) -> dict[str, dict[str, str]]: + cells: dict[str, dict[str, str]] = {} + for key in DISPLAY_FIELDS: + if key == "triage_report": + continue + value = _display_value(record.get(key), key) + if value: + cells[key] = {"value": value} + if "time" not in cells and (observed := _observed_at(record)): + cells["time"] = {"value": observed} + return cells + + +def _display_value(value: Any, key: str) -> str: + if key == "time": + return _observed_at({"time": value}) + if value is None: + return "" + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, (int, float)): + return str(int(value)) if isinstance(value, float) and value.is_integer() else str(value) + if isinstance(value, str): + return "" if value == "none" else value + try: + return json.dumps(value, ensure_ascii=False) + except TypeError: + return str(value) + + +def _sqlite_settings() -> dict[str, Any]: + config = _load_config().get("sqlite", {}) + if not isinstance(config, dict): + config = {} + return { + "db_path": _resolve_config_path( + _read_config_string(os.environ.get("FLOCKS_SOC_ALERTS_SQLITE_DB"), config.get("dbPath")), + DEFAULT_SQLITE_DB, + ), + "table": _sql_identifier(config.get("table"), DEFAULT_SQLITE_TABLE), + "record_column": _sql_identifier(config.get("recordColumn"), DEFAULT_SQLITE_RECORD_COLUMN), + "date_column": _sql_identifier(config.get("dateColumn"), DEFAULT_SQLITE_DATE_COLUMN), + "event_time_column": _sql_identifier(config.get("eventTimeColumn"), DEFAULT_SQLITE_EVENT_TIME_COLUMN), + } + + +def _load_config() -> dict[str, Any]: + path = _config_path() + if not path.is_file(): + return {} + try: + value = json.loads(path.read_text(encoding="utf-8")) + except Exception: + return {} + return value if isinstance(value, dict) else {} + + +def _config_path() -> Path: + override = os.environ.get("FLOCKS_SOC_ALERTS_CONFIG") + if override: + return Path(override).expanduser() + current = Path(__file__).resolve() + for parent in current.parents: + if parent.name == "access": + return parent / "soc_alerts.json" + return Path.home() / ".flocks" / "plugins" / "contracts" / "access" / "soc_alerts.json" + + +def _read_config_string(*values: Any) -> str: + for value in values: + if isinstance(value, str) and value.strip(): + return value.strip() + return "" + + +def _resolve_config_path(value: str, fallback: Path) -> Path: + text = _read_config_string(value, str(fallback)) + path = Path(text).expanduser() + if path.is_absolute(): + return path + return (_config_path().parent / path).resolve() + + +def _sql_identifier(value: Any, fallback: str) -> str: + text = _read_config_string(value, fallback) + return text if SQL_IDENTIFIER_RE.fullmatch(text) else fallback + + +def _record_id(record: dict[str, Any]) -> str: + value = _first_text(record, "id", "record_id", "dedup_key") + if value: + return value + digest = hashlib.sha256(json.dumps(record, sort_keys=True, default=str).encode("utf-8")).hexdigest() + return digest[:16] + + +def _observed_at(record: dict[str, Any]) -> str: + value = record.get("time") + parsed = _datetime_from_value(value) + if parsed is None: + meta = record.get("_syslog_meta") + if isinstance(meta, dict): + parsed = _datetime_from_value(meta.get("timestamp")) + if parsed is None: + return _text(value) + return parsed.strftime("%Y-%m-%d %H:%M:%S") + + +def _datetime_from_value(value: Any) -> datetime | None: + if value is None or isinstance(value, bool): + return None + if isinstance(value, (int, float)): + seconds = float(value) + if seconds > 10_000_000_000: + seconds /= 1000 + try: + return datetime.fromtimestamp(seconds) + except (OSError, ValueError): + return None + text = str(value).strip() + if not text: + return None + try: + seconds = float(text) + except ValueError: + try: + return datetime.fromisoformat(text.replace("Z", "+00:00")).replace(tzinfo=None) + except ValueError: + return None + if seconds > 10_000_000_000: + seconds /= 1000 + try: + return datetime.fromtimestamp(seconds) + except (OSError, ValueError): + return None + + +def _request_method(record: dict[str, Any]) -> str: + line = _text(record.get("req_line")) + if line: + return line.split(" ", 1)[0] + return "" + + +def _verdict_bucket(record: dict[str, Any]) -> str: + if record.get("attack_success") is True: + return "success" + raw = " ".join( + _text(record.get(key)).lower() + for key in ("attack_verdict", "threat_result", "risk_level", "threat_level") + ) + if any(marker in raw for marker in ("success", "attack_success", "succeeded")): + return "success" + if any(marker in raw for marker in ("failed", "blocked", "attack_failed")): + return "failed" + if any(marker in raw for marker in ("benign", "normal", "safe")): + return "benign" + if "attack" in raw: + return "attack" + return "unknown" + + +def _verdict_label(bucket: str) -> str: + return { + "success": "success", + "failed": "failed", + "benign": "benign", + "attack": "attack", + }.get(bucket, "unknown") + + +def _source_file_label(paths: tuple[Path, ...]) -> str: + if not paths: + return "" + first = paths[0] + return str(first) if len(paths) == 1 else f"{first} (+{len(paths) - 1})" + + +def _date_part(value: str) -> str: + return value[:10] if re.fullmatch(r"\d{4}-\d{2}-\d{2}.*", value) else "" + + +def _report_title_from_markdown(value: str) -> str: + for line in value.splitlines(): + stripped = line.strip() + if stripped.startswith("# "): + return stripped[2:].strip() + return "" + + +def _first_text(record: dict[str, Any], *keys: str) -> str: + for key in keys: + value = _text(record.get(key)) + if value: + return value + return "" + + +def _text(value: Any) -> str: + if value is None: + return "" + if isinstance(value, str): + text = value.strip() + return "" if text.lower() == "none" else text + if isinstance(value, bool): + return "true" if value else "false" + return str(value) + + +def _int_or_none(value: Any) -> int | None: + if isinstance(value, bool) or value is None: + return None + if isinstance(value, int): + return value + if isinstance(value, float): + return int(value) + try: + return int(str(value).strip()) + except (TypeError, ValueError): + return None + + +_OVERLAY_STORE = OverlayStore() + +CONTRACTS = ( + WebUIContractPlugin( + plugin_id="soc-alerts-operations", + contracts=(_contract(),), + binding_resolver=_BindingResolver(), + adapter=_Adapter(), + response_pipeline=_ResponsePipeline(_OVERLAY_STORE), + overlay_store=_OVERLAY_STORE, + version=CONTRACT_VERSION, + ), +) diff --git a/.flocks/flockshub/plugins/webuis/soc_ui/manifest.json b/.flocks/flockshub/plugins/webuis/soc_ui/manifest.json new file mode 100644 index 000000000..c0f50cefc --- /dev/null +++ b/.flocks/flockshub/plugins/webuis/soc_ui/manifest.json @@ -0,0 +1,63 @@ +{ + "schemaVersion": "hub.plugin.v1", + "id": "soc_ui", + "type": "webui", + "name": "SOC Workspace WebUI", + "description": "SOC workspace pages for posture, overview, and alert investigation.", + "descriptionCn": "SOC 工作区页面,包含态势、SOC 总览和告警调查。", + "version": "1.0.0", + "author": "Flocks Team", + "license": "MIT", + "homepage": "", + "category": "workflow-automation", + "tags": [ + "siem", + "ndr", + "integration" + ], + "useCases": [ + "alert-triage", + "log-analysis", + "security-reporting" + ], + "domains": [ + "security-ops" + ], + "capabilities": [ + "workflow" + ], + "trust": "official", + "source": { + "kind": "bundled", + "path": "plugins/webuis/soc_ui" + }, + "compatibility": { + "flocks": ">=0.8.0", + "os": [ + "darwin", + "linux", + "windows" + ] + }, + "dependencies": { + "skills": [], + "tools": [], + "python": [], + "external": [] + }, + "permissions": { + "tools": [], + "network": false, + "shell": false, + "filesystem": "read" + }, + "risk": { + "level": "low", + "reasons": [] + }, + "entrypoints": [ + "workspace.json" + ], + "components": [], + "checksums": {} +} diff --git a/.flocks/flockshub/plugins/webuis/soc_ui/soc_alerts/manifest.json b/.flocks/flockshub/plugins/webuis/soc_ui/soc_alerts/manifest.json new file mode 100644 index 000000000..dcad2aba7 --- /dev/null +++ b/.flocks/flockshub/plugins/webuis/soc_ui/soc_alerts/manifest.json @@ -0,0 +1,12 @@ +{ + "id": "soc-alerts", + "title": "告警调查", + "titleEn": "Alert Investigation", + "route": "/contracts/webui/soc-alerts", + "icon": "AlertTriangle", + "order": 20, + "enabled": true, + "placement": "home.after", + "entry": "src/index.tsx", + "updatedAt": 1782797817110 +} diff --git a/.flocks/flockshub/plugins/webuis/soc_ui/soc_alerts/src/index.tsx b/.flocks/flockshub/plugins/webuis/soc_ui/soc_alerts/src/index.tsx new file mode 100644 index 000000000..ab12870ab --- /dev/null +++ b/.flocks/flockshub/plugins/webuis/soc_ui/soc_alerts/src/index.tsx @@ -0,0 +1,2508 @@ +import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { api } from '@flocks/webui-contract-sdk'; + +type Tone = 'red' | 'orange' | 'blue' | 'green' | 'purple' | 'slate'; +type FilterKey = '_source_type' | 'net_type' | 'direction' | 'threat_name' | 'threat_type' | 'threat_phase' | 'threat_result' | 'rsp_status_code' | 'sip' | 'dport' | 'dip' | 'req_host' | 'threat_rule_id'; +type TimeRangeKey = '15m' | '1h' | '2h' | '24h' | 'today' | '7d' | '30d'; +type TimeFilterMode = 'relative' | 'custom'; +type TimePanelTab = 'auto' | 'custom'; +type RefreshKey = 'off' | '5s' | '15s' | '1m' | '5m' | '1h'; +type Translate = (text: string) => string; + +interface FilterConfig { + key: FilterKey; + label: string; +} + +interface ChoiceOption { + value: T; + label: string; +} + +interface TimeFilterState { + mode: TimeFilterMode; + range: TimeRangeKey; + start: string; + end: string; +} + + +interface AlertTableColumn { + key: string; + label: string; + description?: string; + widthClass?: string; + mono?: boolean; +} + +interface AlertTableCell { + value: string; + detail?: string; + tone?: Tone; + mono?: boolean; +} + +interface IncidentCluster { + id: string; + sourceRecordId?: string; + observedAt?: string; + rawAlerts?: number; + confidence?: number; + priority?: 'P1' | 'P2'; + reportTitle?: string; + reason?: string; + owner?: string; + srcIp?: string; + ndrRule?: string; + request?: { + method?: string; + host?: string; + uri?: string; + payload?: string; + llmAnalysis?: string; + evidence?: string[]; + }; + response?: { + statusCode?: number; + llmAnalysis?: string; + evidence?: string[]; + sample?: string; + }; + srcIntel?: { + verdict?: string; + location?: string; + tags?: string[]; + summary?: string; + }; + asset?: { + name?: string; + business?: string; + exposure?: string; + owner?: string; + criticality?: string; + context?: string; + }; + conclusion?: { + verdict?: string; + summary?: string; + recommendation?: string; + }; + actions?: string[]; + title: string; + triageReport?: string; + tableCells?: Record; + overlayVersion?: number; + manualVerdict?: string; + analystNote?: string; +} + +interface AlertOperationsData { + schemaVersion?: string; + generatedAt?: string; + source?: { + label?: string; + pageId?: string; + sampleMode?: boolean; + dataSource?: string; + }; + summary?: { + sourcePageId?: string; + sourceAssetDate?: string; + sourceAssetFile?: string; + totalRaw?: number; + totalUnique?: number; + duplicates?: number; + attackSuccess?: number; + attack?: number; + attackFailed?: number; + benign?: number; + unknown?: number; + representativeCount?: number; + }; + tableColumns?: AlertTableColumn[]; + incidents?: IncidentCluster[]; +} + +interface TaggedReport { + title: string; + stepCount: number; + sections: Record; +} + +interface MarkdownReport { + title: string; + sections: Array<[string, string]>; + steps: Array<[string, string]>; +} + +interface TimelineBucket { + label: string; + total: number; + success: number; + failed: number; + unknown: number; +} + +const REPORT_TAGS = [ + 'report_title', + 'report_meta', + 'analysis_steps', + 'triage_conclusion', + 'attack_payload', + 'payload_explanation', + 'response_evidence', + 'key_evidence', + 'disposal_recommendation', +] as const; + +const MARKDOWN_ANALYSIS_STEP_TITLES = [ + '日志类型分析', + '测绘信息', + '关联漏洞分析', + '漏洞详情', + '攻击负载分析', + '攻击分析结果', + '威胁情报', + '情报信息', +]; + +const MARKDOWN_STEP_ORDER = [ + '日志类型分析', + '情报信息', + '测绘信息', + '告警关联漏洞情报', + '攻击负载分析', + '攻击分析结果', +]; + +const EMPTY_DATA: Required> = { + source: { label: 'SOC Contract SQLite', pageId: 'soc-alerts', sampleMode: false, dataSource: 'sqlite' }, + summary: { + sourcePageId: 'soc-alerts', + sourceAssetDate: '-', + sourceAssetFile: '', + totalRaw: 0, + totalUnique: 0, + duplicates: 0, + attackSuccess: 0, + attack: 0, + attackFailed: 0, + benign: 0, + unknown: 0, + representativeCount: 0, + }, + tableColumns: [], + incidents: [], +}; + +const ALL_FILTER_VALUE = '__all__'; + +const EN_TEXT: Record = { + '数据源': 'Data Source', + '协议类型': 'Protocol', + '流量方向': 'Traffic Direction', + '威胁名称': 'Threat Name', + '威胁类型': 'Threat Type', + '攻击阶段': 'Attack Stage', + '攻击结果': 'Attack Result', + '攻击行为': 'Attack Behavior', + '攻击判定': 'Attack Verdict', + '响应状态': 'Response Status', + '源地址': 'Source Address', + '源端口': 'Source Port', + '目标地址': 'Destination Address', + '目标端口': 'Destination Port', + '规则 ID': 'Rule ID', + 'HTTP Host': 'HTTP Host', + '请求 URL': 'Request URL', + '响应码': 'Response Code', + '事件时间': 'Event Time', + '全部': 'All', + '已选 {count} 项': '{count} selected', + '未知响应': 'Unknown response', + '空值': 'Empty', + '最近15分钟': 'Last 15m', + '最近1小时': 'Last 1h', + '最近2小时': 'Last 2h', + '最近24小时': 'Last 24h', + '今天': 'Today', + '最近7天': 'Last 7d', + '最近30天': 'Last 30d', + '5秒': '5s', + '15秒': '15s', + '1分钟': '1m', + '5分钟': '5m', + '1小时': '1h', + '24小时': '24h', + '关闭': 'Off', + '自动刷新': 'Auto Refresh', + '精确时间': 'Exact Time', + '时间范围': 'Time Range', + '刷新频率': 'Refresh Rate', + '开始时间': 'Start Time', + '结束时间': 'End Time', + '取消': 'Cancel', + '确定': 'Apply', + '至': 'to', + '日志调查': 'Log Investigation', + '关闭筛选菜单': 'Close filter menu', + '查找选择条件': 'Search options', + '暂无可选值': 'No options', + '全选': 'Select All', + '清空': 'Clear', + '查询': 'Search', + '重置': 'Reset', + '请输入源地址、目标地址、HTTP Host、URL、规则 ID 或威胁名称': 'Search source address, destination address, HTTP Host, URL, rule ID, or threat name', + '收起更多筛选': 'Collapse More Filters', + '更多筛选条件': 'More Filters', + '收起查询': 'Collapse Query', + '展开查询': 'Expand Query', + '折叠趋势图': 'Collapse timeline', + '展开趋势图': 'Expand timeline', + '攻击成功': 'Attack Success', + '攻击失败': 'Attack Failed', + '未知': 'Unknown', + '暂无可展示的告警数据。': 'No alerts to display.', + '显示 {start}-{end} / {total} 条,每页 {pageSize} 条': 'Showing {start}-{end} / {total}, {pageSize} per page', + '首页': 'First', + '上一页': 'Previous', + '下一页': 'Next', + '末页': 'Last', + '待确认': 'Pending', + '收起': 'Collapse', + '关闭详情': 'Close details', + '告警': 'Alert', + '告警摘要': 'Alert Summary', + '基础信息': 'Basic Info', + '请求链路': 'Request Path', + '关键字段': 'Key Fields', + '请求信息': 'Request Info', + '响应信息': 'Response Info', + '规则与结论': 'Rule & Conclusion', + '威胁描述': 'Threat Description', + '原始请求': 'Raw Request', + '原始响应': 'Raw Response', + '源信息': 'Source Info', + '访问信息': 'Access Info', + '目标信息': 'Destination Info', + 'HTTP 请求': 'HTTP Request', + 'HTTP 响应': 'HTTP Response', + '请求行': 'Request Line', + '响应行': 'Response Line', + '请求体长度': 'Request Body Length', + '响应体长度': 'Response Body Length', + 'User-Agent': 'User-Agent', + '源': 'Source', + '访问': 'Access', + '目标': 'Destination', + '当前状态': 'Current Status', + '详细信息': 'Details', + '研判结果': 'Triage Result', + '日志信息': 'Log Info', + '网络访问': 'Network Access', + '端口': 'Port', + '收起分析步骤': 'Collapse analysis steps', + '展开 {count} 个步骤': 'Expand {count} steps', + '展开查看': 'View details', + '{count} 个步骤': '{count} steps', + '分析步骤': 'Analysis Steps', + '分析详情': 'Analysis Details', + '分析报告': 'Analysis Report', + '报告摘要': 'Report Summary', + '日志类型分析': 'Log Type Analysis', + '测绘信息': 'Asset Mapping', + '关联漏洞分析': 'Related Vulnerability Analysis', + '漏洞详情': 'Vulnerability Details', + '攻击负载分析': 'Attack Payload Analysis', + '攻击 Payload 分析': 'Attack Payload Analysis', + '攻击分析结果': 'Attack Analysis Result', + '威胁情报': 'Threat Intelligence', + '情报信息': 'Intelligence', + '告警关联漏洞情报': 'Related Vulnerability Intelligence', + '攻击payload': 'Attack Payload', + '重要证据': 'Key Evidence', + '暂无研判摘要。': 'No triage summary.', + '研判结论': 'Triage Conclusion', + '请求证据': 'Request Evidence', + 'Payload 解释': 'Payload Explanation', + '响应分析': 'Response Analysis', + '关键证据': 'Key Evidence', + '处置建议': 'Disposition', + '人工备注': 'Analyst Note', + '缺少研判结论。': 'Triage conclusion is missing.', + '复制': 'Copy', + '下载': 'Download', + '复制报告': 'Copy report', + '下载报告': 'Download report', + '复制成功': 'Copied', + 'SOC 告警数据源请求失败': 'Failed to request SOC alert data source', + ':': ':', +}; + +const identityTr: Translate = (text) => text; + +function isEnglishLocale() { + if (typeof window === 'undefined') return false; + const stored = window.localStorage?.getItem('flocks-language') || ''; + const locale = stored || window.navigator?.language || ''; + return Boolean(locale) && !locale.toLowerCase().replace('_', '-').startsWith('zh'); +} + +function interpolate(template: string, values: Record) { + return template.replace(/\{(\w+)\}/g, (_, key) => String(values[key] ?? '')); +} + +const BASE_FILTER_CONFIGS: FilterConfig[] = [ + { key: '_source_type', label: '数据源' }, + { key: 'net_type', label: '协议类型' }, + { key: 'direction', label: '流量方向' }, + { key: 'threat_name', label: '威胁名称' }, +]; + +const MORE_FILTER_CONFIGS: FilterConfig[] = [ + { key: 'threat_type', label: '威胁类型' }, + { key: 'threat_phase', label: '攻击阶段' }, + { key: 'threat_result', label: '攻击结果' }, + { key: 'rsp_status_code', label: '响应状态' }, + { key: 'sip', label: '源地址' }, + { key: 'dport', label: '目标端口' }, + { key: 'dip', label: '目标地址' }, + { key: 'req_host', label: 'HTTP Host' }, + { key: 'threat_rule_id', label: '规则 ID' }, +]; + +const FILTER_CONFIGS = [...BASE_FILTER_CONFIGS, ...MORE_FILTER_CONFIGS]; + +const DEFAULT_FILTER_VALUES: Record = { + _source_type: ['tdp'], + net_type: ['http'], + direction: [], + threat_name: [], + threat_type: [], + threat_phase: [], + threat_result: [], + rsp_status_code: [], + sip: [], + dport: [], + dip: [], + req_host: [], + threat_rule_id: [], +}; + +const TIME_RANGE_OPTIONS: ChoiceOption[] = [ + { value: '15m', label: '最近15分钟' }, + { value: '2h', label: '最近2小时' }, + { value: '24h', label: '最近24小时' }, + { value: 'today', label: '今天' }, + { value: '7d', label: '最近7天' }, + { value: '30d', label: '最近30天' }, +]; + +const REFRESH_OPTIONS: ChoiceOption[] = [ + { value: '5s', label: '5秒' }, + { value: '15s', label: '15秒' }, + { value: '1m', label: '1分钟' }, + { value: '5m', label: '5分钟' }, + { value: '1h', label: '1小时' }, + { value: 'off', label: '关闭' }, +]; + +const REFRESH_INTERVAL_MS: Record = { + off: 0, + '5s': 5_000, + '15s': 15_000, + '1m': 60_000, + '5m': 300_000, + '1h': 3_600_000, +}; + +const DEFAULT_TIME_RANGE: TimeRangeKey = '7d'; +const PAGE_SIZE = 50; +const QUERY_LIMIT = 10000; +const ALERT_TABLE_COLUMN_WIDTH = 220; +const TIMELINE_HOUR_MS = 60 * 60 * 1000; + +function numberValue(value: unknown) { + return typeof value === 'number' && Number.isFinite(value) ? value : 0; +} + +function textValue(value: unknown, fallback = '') { + return typeof value === 'string' && value.trim() ? value : fallback; +} + +function formatNumber(value: number) { + return value.toLocaleString('zh-CN'); +} + +function formatTimelineHourLabel(value: number) { + const tick = new Date(Math.round(value / TIMELINE_HOUR_MS) * TIMELINE_HOUR_MS); + return `${tick.getMonth() + 1}-${tick.getDate()} ${String(tick.getHours()).padStart(2, '0')}:00`; +} + +function pad2(value: number) { + return String(value).padStart(2, '0'); +} + +function toLocalInputValue(date: Date) { + return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}T${pad2(date.getHours())}:${pad2(date.getMinutes())}`; +} + +function parseLocalInputValue(value: string) { + if (!value) return null; + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? null : parsed; +} + +function startOfToday(now: Date) { + return new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0); +} + +function createRelativeTimeFilter(range: TimeRangeKey = DEFAULT_TIME_RANGE): TimeFilterState { + const [start, end] = resolveRelativeWindow(range); + return { mode: 'relative', range, start: toLocalInputValue(start), end: toLocalInputValue(end) }; +} + +function createCustomTimeFilter(start: Date, end: Date, range: TimeRangeKey = DEFAULT_TIME_RANGE): TimeFilterState { + return { mode: 'custom', range, start: toLocalInputValue(start), end: toLocalInputValue(end) }; +} + +function resolveRelativeWindow(range: TimeRangeKey, now = new Date()): [Date, Date] { + const end = new Date(now); + if (range === 'today') return [startOfToday(now), end]; + const spans: Record, number> = { + '15m': 15 * 60 * 1000, + '1h': 60 * 60 * 1000, + '2h': 2 * 60 * 60 * 1000, + '24h': 24 * 60 * 60 * 1000, + '7d': 7 * 24 * 60 * 60 * 1000, + '30d': 30 * 24 * 60 * 60 * 1000, + }; + return [new Date(end.getTime() - spans[range]), end]; +} + +function resolveTimeWindow(filter: TimeFilterState, now = new Date()): [Date, Date] | null { + if (filter.mode === 'relative') return resolveRelativeWindow(filter.range, now); + const start = parseLocalInputValue(filter.start); + const end = parseLocalInputValue(filter.end); + if (!start || !end) return null; + return start <= end ? [start, end] : [end, start]; +} + +function timeFilterParams(filter: TimeFilterState) { + const window = resolveTimeWindow(filter); + if (!window) return {}; + const [start, end] = window; + return { + startTime: Math.floor(start.getTime() / 1000), + endTime: Math.floor(end.getTime() / 1000), + }; +} + +function contractFilterParams(filters: Record) { + const activeFilters = Object.fromEntries( + FILTER_CONFIGS + .map((config) => [ + config.key, + (filters[config.key] || []).filter((value) => value && value !== ALL_FILTER_VALUE), + ] as const) + .filter(([, values]) => values.length > 0), + ); + return Object.keys(activeFilters).length ? { filters: activeFilters } : {}; +} + +function timeFilterLabel(filter: TimeFilterState, tr: Translate = identityTr) { + if (filter.mode === 'relative') { + if (filter.range === '1h') return tr('最近1小时'); + return tr(TIME_RANGE_OPTIONS.find((option) => option.value === filter.range)?.label || '最近7天'); + } + const window = resolveTimeWindow(filter); + if (!window) return tr('精确时间'); + const [start, end] = window; + const format = (date: Date) => `${date.getFullYear()}/${pad2(date.getMonth() + 1)}/${pad2(date.getDate())} ${pad2(date.getHours())}:${pad2(date.getMinutes())}`; + return `${format(start)} ${tr('至')} ${format(end)}`; +} + +function refreshLabel(value: RefreshKey, tr: Translate = identityTr) { + return tr(REFRESH_OPTIONS.find((option) => option.value === value)?.label || '关闭'); +} + +function normalizeData(payload: AlertOperationsData): typeof EMPTY_DATA { + return { + source: { + label: textValue(payload.source?.label, EMPTY_DATA.source.label), + pageId: textValue(payload.source?.pageId, EMPTY_DATA.source.pageId), + sampleMode: Boolean(payload.source?.sampleMode), + dataSource: textValue(payload.source?.dataSource, EMPTY_DATA.source.dataSource), + }, + summary: { + sourcePageId: textValue(payload.summary?.sourcePageId, EMPTY_DATA.summary.sourcePageId), + sourceAssetDate: textValue(payload.summary?.sourceAssetDate, EMPTY_DATA.summary.sourceAssetDate), + sourceAssetFile: textValue(payload.summary?.sourceAssetFile, ''), + totalRaw: numberValue(payload.summary?.totalRaw), + totalUnique: numberValue(payload.summary?.totalUnique), + duplicates: numberValue(payload.summary?.duplicates), + attackSuccess: numberValue(payload.summary?.attackSuccess), + attack: numberValue(payload.summary?.attack), + attackFailed: numberValue(payload.summary?.attackFailed), + benign: numberValue(payload.summary?.benign), + unknown: numberValue(payload.summary?.unknown), + representativeCount: numberValue(payload.summary?.representativeCount), + }, + tableColumns: Array.isArray(payload.tableColumns) ? payload.tableColumns : [], + incidents: Array.isArray(payload.incidents) ? payload.incidents : [], + }; +} + +function parseTaggedReport(markdown?: string): TaggedReport | null { + if (!markdown) return null; + const root = markdown.match(/]*version=["']soc\.triage\.markdown\.v1["'][^>]*>([\s\S]*?)<\/triage_report>/i); + if (!root) return null; + const body = root[1]; + const sections: Record = {}; + for (const tag of REPORT_TAGS) { + const match = body.match(new RegExp(`<${tag}\\b[^>]*>([\\s\\S]*?)`, 'i')); + if (!match) return null; + sections[tag] = match[1].trim(); + } + const title = sections.report_title.match(/^#\s+(.+)$/m)?.[1]?.trim() || 'Web日志分析'; + const stepCount = sections.analysis_steps.match(/^###\s+/gm)?.length || (sections.analysis_steps.trim() ? 1 : 0); + return { title, stepCount, sections }; +} + +function normalizeMarkdownReportHeading(heading: string) { + const title = heading.replace(/^\d+[.、]\s*/, '').trim(); + if (/^攻击\s*Payload\s*分析$/i.test(title)) return '攻击负载分析'; + return title; +} + +function isMarkdownAnalysisStepTitle(title: string) { + return MARKDOWN_ANALYSIS_STEP_TITLES.includes(normalizeMarkdownReportHeading(title)); +} + +function pushMarkdownReportPair(pairs: Array<[string, string]>, title: string, content: string) { + const cleanContent = content.trim(); + if (!cleanContent) return; + const existing = pairs.find(([existingTitle]) => existingTitle === title); + if (existing) { + existing[1] = [existing[1], cleanContent].filter(Boolean).join('\n\n'); + return; + } + pairs.push([title, cleanContent]); +} + +function orderedMarkdownAnalysisSteps(rawSteps: Array<[string, string]>) { + const stepMap = new Map(rawSteps); + const vulnerabilityContent = [ + stepMap.get('漏洞详情') ? `### 漏洞详情\n${stepMap.get('漏洞详情')}` : '', + stepMap.get('关联漏洞分析') ? `### 关联漏洞分析\n${stepMap.get('关联漏洞分析')}` : '', + ].filter(Boolean).join('\n\n'); + const orderedContent = new Map([ + ['日志类型分析', stepMap.get('日志类型分析') || ''], + ['情报信息', stepMap.get('情报信息') || stepMap.get('威胁情报') || ''], + ['测绘信息', stepMap.get('测绘信息') || ''], + ['告警关联漏洞情报', vulnerabilityContent], + ['攻击负载分析', stepMap.get('攻击负载分析') || ''], + ['攻击分析结果', stepMap.get('攻击分析结果') || ''], + ]); + return MARKDOWN_STEP_ORDER + .map((title) => [title, orderedContent.get(title) || ''] as [string, string]) + .filter(([, content]) => content.trim()); +} + +function parseMarkdownReport(markdown?: string): MarkdownReport | null { + if (!markdown || / = []; + const steps: Array<[string, string]> = []; + const intro = withoutTitle.slice(0, matches[0].index ?? 0).replace(/^---+$/gm, '').trim(); + if (intro) sections.push(['报告摘要', intro]); + + matches.forEach((match, index) => { + const start = (match.index ?? 0) + match[0].length; + const end = index + 1 < matches.length ? matches[index + 1].index ?? withoutTitle.length : withoutTitle.length; + const heading = normalizeMarkdownReportHeading(match[1]); + const content = withoutTitle.slice(start, end).replace(/^---+$/gm, '').trim(); + if (!content) return; + if (isMarkdownAnalysisStepTitle(heading)) { + pushMarkdownReportPair(steps, heading || '分析详情', content); + return; + } + pushMarkdownReportPair(sections, heading || '分析详情', content); + }); + + const orderedSteps = orderedMarkdownAnalysisSteps(steps); + return sections.length || orderedSteps.length ? { title, sections, steps: orderedSteps } : null; +} + +function xmlEscape(value: string) { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function markdownToPlainText(content: string) { + return content + .replace(/```[\s\S]*?```/g, (block) => block.replace(/^```[^\n]*\n?/, '').replace(/```$/, '')) + .replace(/\*\*([^*]+)\*\*/g, '$1') + .replace(/`([^`]+)`/g, '$1') + .replace(/^#{1,6}\s+/gm, '') + .replace(/^---+$/gm, '') + .trim(); +} + +function sanitizeFileName(value: string) { + return (value || '研判报告') + .replace(/[\\/:*?"<>|]/g, '_') + .replace(/\s+/g, '') + .slice(0, 80) || '研判报告'; +} + +function formatFileTime(value: string) { + const digits = (value || '').replace(/\D/g, ''); + if (digits.length >= 12) return digits.slice(0, 12); + const now = new Date(); + const pad = (num: number) => String(num).padStart(2, '0'); + return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}${pad(now.getHours())}${pad(now.getMinutes())}`; +} + +function exportFileName(title: string, observedAt: string) { + return `${sanitizeFileName(title)}_${formatFileTime(observedAt)}.docx`; +} + +function reportTextLines({ + title, + observedAt, + fields, + summary, + steps, + sections, +}: { + title: string; + observedAt: string; + fields: string[][]; + summary: string; + steps: Array<[string, string]>; + sections: Array<[string, string]>; +}) { + const lines: string[] = [title, observedAt, '', '研判结果']; + fields.forEach(([label, value]) => lines.push(`${label}: ${value || '-'}`)); + lines.push(`研判结论: ${summary || '-'}`); + if (steps.length) { + lines.push('', `分析步骤 (${steps.length} 个步骤)`); + steps.forEach(([stepTitle, content], index) => { + lines.push(`${index + 1}. ${stepTitle}`); + lines.push(markdownToPlainText(content) || '-'); + }); + } + lines.push('', '分析报告'); + sections.forEach(([sectionTitle, content]) => { + lines.push(sectionTitle); + lines.push(markdownToPlainText(content) || '-'); + }); + return lines; +} + +function reportPlainText(payload: Parameters[0]) { + return reportTextLines(payload).join('\n'); +} + +function docxParagraph(text: string, style?: 'Title' | 'Heading1' | 'Heading2') { + const lines = (text || '-').split(/\r?\n/); + const styleXml = style ? `` : ''; + const runs = lines.map((line, index) => { + const br = index === 0 ? '' : ''; + return `${br}${xmlEscape(line)}`; + }).join(''); + return `${styleXml}${runs}`; +} + +function buildDocxDocumentXml(payload: Parameters[0]) { + const parts: string[] = [ + docxParagraph(payload.title, 'Title'), + docxParagraph(payload.observedAt), + docxParagraph('研判结果', 'Heading1'), + ]; + payload.fields.forEach(([label, value]) => parts.push(docxParagraph(`${label}: ${value || '-'}`))); + parts.push(docxParagraph(`研判结论: ${payload.summary || '-'}`)); + if (payload.steps.length) { + parts.push(docxParagraph(`分析步骤 (${payload.steps.length} 个步骤)`, 'Heading1')); + payload.steps.forEach(([title, content], index) => { + parts.push(docxParagraph(`${index + 1}. ${title}`, 'Heading2')); + markdownToPlainText(content).split(/\n{2,}/).forEach((paragraph) => parts.push(docxParagraph(paragraph))); + }); + } + parts.push(docxParagraph('分析报告', 'Heading1')); + payload.sections.forEach(([title, content]) => { + parts.push(docxParagraph(title, 'Heading2')); + markdownToPlainText(content).split(/\n{2,}/).forEach((paragraph) => parts.push(docxParagraph(paragraph))); + }); + return ` + + + ${parts.join('\n')} + + + + + +`; +} + +function buildDocxStylesXml() { + return ` + + + + + + + + + + + + + + + + + + + + + +`; +} + +function crc32(bytes: Uint8Array) { + let crc = 0xffffffff; + for (let i = 0; i < bytes.length; i += 1) { + crc ^= bytes[i]; + for (let bit = 0; bit < 8; bit += 1) { + crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0); + } + } + return (crc ^ 0xffffffff) >>> 0; +} + +function u16(value: number) { + return new Uint8Array([value & 0xff, (value >>> 8) & 0xff]); +} + +function u32(value: number) { + return new Uint8Array([value & 0xff, (value >>> 8) & 0xff, (value >>> 16) & 0xff, (value >>> 24) & 0xff]); +} + +function concatBytes(chunks: Uint8Array[]) { + const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0); + const output = new Uint8Array(total); + let offset = 0; + chunks.forEach((chunk) => { + output.set(chunk, offset); + offset += chunk.length; + }); + return output; +} + +function zipFiles(files: Array<{ name: string; content: string }>) { + const encoder = new TextEncoder(); + const localParts: Uint8Array[] = []; + const centralParts: Uint8Array[] = []; + let offset = 0; + files.forEach((file) => { + const nameBytes = encoder.encode(file.name); + const data = encoder.encode(file.content); + const crc = crc32(data); + const localHeader = concatBytes([ + u32(0x04034b50), u16(20), u16(0x0800), u16(0), u16(0), u16(0), u32(crc), u32(data.length), u32(data.length), u16(nameBytes.length), u16(0), nameBytes, + ]); + localParts.push(localHeader, data); + const centralHeader = concatBytes([ + u32(0x02014b50), u16(20), u16(20), u16(0x0800), u16(0), u16(0), u16(0), u32(crc), u32(data.length), u32(data.length), u16(nameBytes.length), u16(0), u16(0), u16(0), u16(0), u32(0), u32(offset), nameBytes, + ]); + centralParts.push(centralHeader); + offset += localHeader.length + data.length; + }); + const centralDirectory = concatBytes(centralParts); + const localData = concatBytes(localParts); + const end = concatBytes([ + u32(0x06054b50), u16(0), u16(0), u16(files.length), u16(files.length), u32(centralDirectory.length), u32(localData.length), u16(0), + ]); + return concatBytes([localData, centralDirectory, end]); +} + +function buildDocxBlob(payload: Parameters[0]) { + const files = [ + { name: '[Content_Types].xml', content: '' }, + { name: '_rels/.rels', content: '' }, + { name: 'word/_rels/document.xml.rels', content: '' }, + { name: 'word/document.xml', content: buildDocxDocumentXml(payload) }, + { name: 'word/styles.xml', content: buildDocxStylesXml() }, + ]; + return new Blob([zipFiles(files)], { type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' }); +} + +function downloadBlob(blob: Blob, fileName: string) { + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = fileName; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); +} + +async function copyTextToClipboard(text: string) { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + return; + } + const textarea = document.createElement('textarea'); + textarea.value = text; + textarea.style.position = 'fixed'; + textarea.style.left = '-9999px'; + document.body.appendChild(textarea); + textarea.focus(); + textarea.select(); + document.execCommand('copy'); + textarea.remove(); +} + +function cellValue(incident: IncidentCluster, key: string, fallback = '') { + return textValue(incident.tableCells?.[key]?.value, fallback); +} + +function rawAttackResultValue(incident: IncidentCluster) { + return cellValue(incident, 'attach_result') || cellValue(incident, 'attack_result') || cellValue(incident, 'threat_result'); +} + +function verdictBucket(incident: IncidentCluster): 'success' | 'failed' | 'unknown' { + const rawResult = rawAttackResultValue(incident).toLowerCase(); + const verdict = incident.conclusion?.verdict || ''; + if (rawResult === 'success' || rawResult === 'succeeded' || verdict.includes('成功')) return 'success'; + if (rawResult === 'failed' || rawResult === 'blocked' || verdict.includes('失败')) return 'failed'; + return 'unknown'; +} + +function attackResultLabel(incident: IncidentCluster, tr: Translate) { + const bucket = verdictBucket(incident); + if (bucket === 'success') return tr('攻击成功'); + if (bucket === 'failed') return tr('攻击失败'); + return tr('未知'); +} + +function attackResultTone(incident: IncidentCluster): Tone { + const bucket = verdictBucket(incident); + if (bucket === 'success') return 'red'; + if (bucket === 'failed') return 'green'; + return 'slate'; +} + +function severityTone(incident: IncidentCluster): Tone { + if (incident.priority === 'P1' || incident.conclusion?.verdict?.includes('成功')) return 'red'; + if (incident.conclusion?.verdict?.includes('失败')) return 'green'; + return 'orange'; +} + +function dateFromIncident(incident: IncidentCluster) { + const value = incident.observedAt || cellValue(incident, 'time'); + if (!value) return null; + const parsed = new Date(value.replace(' ', 'T')); + return Number.isNaN(parsed.getTime()) ? null : parsed; +} + +function incidentTimeValue(incident: IncidentCluster) { + return dateFromIncident(incident)?.getTime() ?? 0; +} + +function sortIncidentsByTimeDesc(incidents: IncidentCluster[]) { + return [...incidents].sort((left, right) => incidentTimeValue(right) - incidentTimeValue(left)); +} + +function displayThreatName(incident: IncidentCluster) { + const raw = cellValue(incident, 'threat_name', incident.title).trim() || incident.title; + return raw + .replace(/^检测到/, '') + .replace(/工具流量[。.]?$/, '') + .replace(/[。.]$/, ''); +} + +function buildTimeline(incidents: IncidentCluster[], timeWindow: [Date, Date] | null = null): TimelineBucket[] { + const dates = incidents.map(dateFromIncident).filter(Boolean) as Date[]; + if (!dates.length && !timeWindow) { + return Array.from({ length: 36 }, (_, index) => ({ label: `${String(index).padStart(2, '0')}:00`, total: 0, success: 0, failed: 0, unknown: 0 })); + } + const rawStart = timeWindow ? timeWindow[0].getTime() : Math.min(...dates.map((date) => date.getTime())); + const rawEnd = timeWindow ? timeWindow[1].getTime() : Math.max(...dates.map((date) => date.getTime())); + const bucketCount = 42; + const start = Math.floor(rawStart / TIMELINE_HOUR_MS) * TIMELINE_HOUR_MS; + const minEnd = rawStart + bucketCount * 60 * 1000; + const end = Math.max(Math.ceil(Math.max(rawEnd, minEnd) / TIMELINE_HOUR_MS) * TIMELINE_HOUR_MS, start + 4 * TIMELINE_HOUR_MS); + const span = end - start; + const buckets = Array.from({ length: bucketCount }, (_, index) => { + return { + label: formatTimelineHourLabel(start + (span * index) / Math.max(bucketCount - 1, 1)), + total: 0, + success: 0, + failed: 0, + unknown: 0, + }; + }); + incidents.forEach((incident) => { + const date = dateFromIncident(incident); + if (!date) return; + const index = Math.min(bucketCount - 1, Math.max(0, Math.floor(((date.getTime() - start) / span) * bucketCount))); + const category = verdictBucket(incident); + buckets[index].total += 1; + buckets[index][category] += 1; + }); + return buckets; +} + +function niceAxisMax(value: number) { + const safeValue = Math.max(1, Math.ceil(value)); + const targetStep = Math.max(1, safeValue / 2); + const magnitude = 10 ** Math.floor(Math.log10(targetStep)); + const normalized = targetStep / magnitude; + const factor = [1, 2, 2.5, 5, 10].find((candidate) => normalized <= candidate) || 10; + const step = Math.max(1, Math.ceil(factor * magnitude)); + return step * 2; +} + +function readFilterValue(incident: IncidentCluster, key: FilterKey) { + return cellValue(incident, key); +} + +function optionText(key: FilterKey, value: string, tr: Translate = identityTr) { + if (key === 'rsp_status_code') return value || tr('未知响应'); + if (key === '_source_type' || key === 'net_type') return value || 'unknown'; + return value || tr('空值'); +} + +function optionLabel(key: FilterKey, value: string | string[], tr: Translate = identityTr) { + if (Array.isArray(value)) { + if (!value.length) return tr('全部'); + if (value.length === 1) return optionText(key, value[0], tr); + return interpolate(tr('已选 {count} 项'), { count: value.length }); + } + if (value === ALL_FILTER_VALUE) return tr('全部'); + return optionText(key, value, tr); +} + +function normalized(value: string) { + return value.trim().toLowerCase(); +} + +function buildFilterOptions(incidents: IncidentCluster[], key: FilterKey) { + const counts = new Map(); + incidents.forEach((incident) => { + const value = readFilterValue(incident, key).trim(); + if (!value) return; + counts.set(value, (counts.get(value) || 0) + 1); + }); + return [...counts.entries()] + .sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0])) + .slice(0, 80) + .map(([value]) => value); +} + +function matchesSelectedFilters(incident: IncidentCluster, filters: Record) { + return FILTER_CONFIGS.every((config) => { + const expected = filters[config.key] || []; + if (!expected.length) return true; + const actual = normalized(readFilterValue(incident, config.key)); + return expected.some((value) => normalized(value) === actual); + }); +} + +function cloneFilters(filters: Record) { + return Object.fromEntries( + FILTER_CONFIGS.map((config) => [config.key, [...(filters[config.key] || [])]]), + ) as Record; +} + +function matchesTimeFilter(incident: IncidentCluster, filter: TimeFilterState) { + const window = resolveTimeWindow(filter); + if (!window) return true; + const date = dateFromIncident(incident); + if (!date) return false; + const [start, end] = window; + return start <= date && date <= end; +} + +function filterIncident( + incident: IncidentCluster, + keyword: string, + filters: Record, + timeFilter: TimeFilterState, +) { + if (!matchesTimeFilter(incident, timeFilter)) return false; + if (!matchesSelectedFilters(incident, filters)) return false; + if (!keyword.trim()) return true; + const haystack = [ + incident.title, + incident.reason, + incident.srcIp, + incident.ndrRule, + incident.request?.host, + incident.request?.uri, + incident.asset?.name, + incident.conclusion?.verdict, + cellValue(incident, 'sip'), + cellValue(incident, 'dip'), + cellValue(incident, 'req_host'), + cellValue(incident, 'req_http_url'), + cellValue(incident, 'threat_name'), + cellValue(incident, 'threat_type'), + cellValue(incident, 'threat_phase'), + cellValue(incident, 'threat_rule_id'), + ].filter(Boolean).join(' ').toLowerCase(); + return haystack.includes(keyword.trim().toLowerCase()); +} + +function Badge({ children, tone = 'slate' }: { children: React.ReactNode; tone?: Tone }) { + const classes: Record = { + red: 'border-red-200 bg-red-50 text-red-700', + orange: 'border-orange-200 bg-orange-50 text-orange-700', + blue: 'border-blue-200 bg-blue-50 text-blue-700', + green: 'border-green-200 bg-green-50 text-green-700', + purple: 'border-purple-200 bg-purple-50 text-purple-700', + slate: 'border-slate-200 bg-slate-50 text-slate-700', + }; + return {children}; +} + +function FilterDropdown({ + config, + value, + options, + open, + onToggle, + onApply, + onQuery, + tr, +}: { + config: FilterConfig; + value: string[]; + options: string[]; + open: boolean; + onToggle: () => void; + onApply: (value: string[]) => void; + onQuery: (value: string[]) => void; + tr: Translate; +}) { + const [search, setSearch] = useState(''); + const [draft, setDraft] = useState(value); + + useEffect(() => { + if (open) { + setDraft(value); + setSearch(''); + } + }, [open, value]); + + const choices = options.filter((option) => option && option !== ALL_FILTER_VALUE); + const visibleChoices = choices.filter((choice) => optionText(config.key, choice, tr).toLowerCase().includes(search.trim().toLowerCase())); + const selected = new Set(draft.map(normalized)); + + const toggleChoice = (choice: string) => { + const choiceKey = normalized(choice); + setDraft((current) => current.some((item) => normalized(item) === choiceKey) + ? current.filter((item) => normalized(item) !== choiceKey) + : [...current, choice]); + }; + + return ( +
+ + {open && ( +
+
+ +
+ {visibleChoices.map((choice) => { + const checked = selected.has(normalized(choice)); + return ( + + ); + })} + {!visibleChoices.length &&
{tr('暂无可选值')}
} +
+
+
+
+ + +
+
+ + +
+
+
+ )} +
+ ); +} + +function ChoiceDropdown({ + label, + value, + options, + open, + onToggle, + onChange, +}: { + label: string; + value: T; + options: ChoiceOption[]; + open: boolean; + onToggle: () => void; + onChange: (value: T) => void; +}) { + const selected = options.find((option) => option.value === value)?.label || value; + return ( +
+ + {open && ( +
+ {options.map((option) => ( + + ))} +
+ )} +
+ ); +} + +function TimeRefreshPopover({ + value, + refreshValue, + open, + onToggle, + onApply, + onClose, + tr, +}: { + value: TimeFilterState; + refreshValue: RefreshKey; + open: boolean; + onToggle: () => void; + onApply: (timeFilter: TimeFilterState, refresh: RefreshKey) => void; + onClose: () => void; + tr: Translate; +}) { + const [tab, setTab] = useState(value.mode === 'custom' ? 'custom' : 'auto'); + const [range, setRange] = useState(value.range); + const [refresh, setRefresh] = useState(refreshValue); + const [start, setStart] = useState(value.start); + const [end, setEnd] = useState(value.end); + + useEffect(() => { + if (!open) return; + const window = resolveTimeWindow(value) || resolveRelativeWindow(value.range); + setTab(value.mode === 'custom' ? 'custom' : 'auto'); + setRange(value.range); + setRefresh(refreshValue); + setStart(toLocalInputValue(window[0])); + setEnd(toLocalInputValue(window[1])); + }, [open, refreshValue, value]); + + const chooseRange = (next: TimeRangeKey) => { + const window = resolveRelativeWindow(next); + setRange(next); + setStart(toLocalInputValue(window[0])); + setEnd(toLocalInputValue(window[1])); + }; + + const confirm = () => { + const nextTimeFilter: TimeFilterState = tab === 'custom' + ? { mode: 'custom', range, start, end } + : createRelativeTimeFilter(range); + onApply(nextTimeFilter, refresh); + }; + const currentTimeLabel = timeFilterLabel(value, tr); + + const panelStyle = { + top: 'calc(100% + 8px)', + width: 'min(360px, calc(100vw - 32px))', + maxHeight: 'calc(100vh - 160px)', + }; + + const optionClass = (selected: boolean) => ( + `h-7 whitespace-nowrap rounded border px-2 text-xs font-medium transition focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-100 ${ + selected + ? 'border-blue-600 bg-blue-600 text-white' + : 'border-transparent bg-slate-50 text-slate-600 hover:bg-slate-100 hover:text-slate-900' + }` + ); + const shortcutOptionClass = 'h-7 whitespace-nowrap rounded border border-transparent bg-slate-50 px-1 text-xs font-medium text-slate-600 transition hover:bg-slate-100 hover:text-slate-900 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-100'; + + return ( +
+ + {open && ( +
+
+
+ {[ + ['auto', '自动刷新'], + ['custom', '精确时间'], + ].map(([key, label]) => ( + + ))} +
+
+
+ {tab === 'auto' ? ( + <> +
+
{tr('时间范围')}
+
+ {TIME_RANGE_OPTIONS.map((option) => ( + + ))} +
+
+
+
{tr('刷新频率')}
+
+ {REFRESH_OPTIONS.map((option) => ( + + ))} +
+
+ + ) : ( + <> +
+ + +
+
+ {[ + ['1h', '1小时'], + ['24h', '24小时'], + ['today', '今天'], + ['7d', '最近7天'], + ['30d', '最近30天'], + ].map(([key, label]) => ( + + ))} +
+ + )} +
+
+ + +
+
+ )} +
+ ); +} + +export default function SocAlertsPage() { + const english = isEnglishLocale(); + const tr = useCallback((text: string) => (english ? EN_TEXT[text] || text : text), [english]); + const [data, setData] = useState(EMPTY_DATA); + const [selectedIncident, setSelectedIncident] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [keyword, setKeyword] = useState(''); + const [filtersOpen, setFiltersOpen] = useState(true); + const [page, setPage] = useState(1); + const [selectedFilters, setSelectedFilters] = useState>(cloneFilters(DEFAULT_FILTER_VALUES)); + const [appliedKeyword, setAppliedKeyword] = useState(''); + const [appliedFilters, setAppliedFilters] = useState>(cloneFilters(DEFAULT_FILTER_VALUES)); + const [timeFilter, setTimeFilter] = useState(() => createRelativeTimeFilter(DEFAULT_TIME_RANGE)); + const [appliedTimeFilter, setAppliedTimeFilter] = useState(() => createRelativeTimeFilter(DEFAULT_TIME_RANGE)); + const [refreshKey, setRefreshKey] = useState('off'); + const [openMenu, setOpenMenu] = useState(null); + const [showMoreFilters, setShowMoreFilters] = useState(false); + const [timelineOpen, setTimelineOpen] = useState(true); + const loadSeqRef = useRef(0); + + const load = useCallback(async (activeTimeFilter: TimeFilterState, activeFilters: Record) => { + const loadSeq = loadSeqRef.current + 1; + loadSeqRef.current = loadSeq; + setLoading(true); + setError(''); + try { + const response = await api + .contract('soc-alerts', 'soc.alerts.operations') + .operation('list', { params: { limit: QUERY_LIMIT, ...timeFilterParams(activeTimeFilter), ...contractFilterParams(activeFilters) } }); + if (loadSeq !== loadSeqRef.current) return; + setData(normalizeData(response.data)); + } catch (err) { + if (loadSeq !== loadSeqRef.current) return; + setError(err instanceof Error ? err.message : tr('SOC 告警数据源请求失败')); + setData(EMPTY_DATA); + } finally { + if (loadSeq === loadSeqRef.current) setLoading(false); + } + }, [tr]); + + useEffect(() => { + void load(appliedTimeFilter, appliedFilters); + }, [appliedFilters, appliedTimeFilter, load]); + + const filterOptions = useMemo(() => ( + Object.fromEntries(FILTER_CONFIGS.map((config) => [config.key, buildFilterOptions(data.incidents, config.key)])) as Record + ), [data.incidents]); + + const visibleFilterConfigs = showMoreFilters ? FILTER_CONFIGS : BASE_FILTER_CONFIGS; + const openFilterKey = openMenu?.startsWith('filter:') ? openMenu.slice('filter:'.length) as FilterKey : null; + + const filteredIncidents = useMemo( + () => data.incidents.filter((incident) => filterIncident(incident, appliedKeyword, appliedFilters, appliedTimeFilter)), + [appliedFilters, appliedKeyword, appliedTimeFilter, data.incidents], + ); + const sortedIncidents = useMemo(() => sortIncidentsByTimeDesc(filteredIncidents), [filteredIncidents]); + + useEffect(() => { + setPage(1); + }, [appliedFilters, appliedKeyword, appliedTimeFilter, data.incidents.length]); + + useEffect(() => { + const intervalMs = REFRESH_INTERVAL_MS[refreshKey]; + if (!intervalMs) return undefined; + const timer = window.setInterval(() => { + void load(appliedTimeFilter, appliedFilters); + }, intervalMs); + return () => window.clearInterval(timer); + }, [appliedFilters, appliedTimeFilter, load, refreshKey]); + + useEffect(() => { + if (!openMenu) return undefined; + const closeOnOutsidePress = (event: MouseEvent | TouchEvent) => { + const target = event.target; + if (target instanceof Element && target.closest('[data-soc-menu-root="true"]')) return; + setOpenMenu(null); + }; + document.addEventListener('mousedown', closeOnOutsidePress, true); + document.addEventListener('touchstart', closeOnOutsidePress, true); + return () => { + document.removeEventListener('mousedown', closeOnOutsidePress, true); + document.removeEventListener('touchstart', closeOnOutsidePress, true); + }; + }, [openMenu]); + + const runQuery = useCallback(() => { + const nextFilters = cloneFilters(selectedFilters); + setAppliedKeyword(keyword); + setAppliedFilters(nextFilters); + setAppliedTimeFilter(timeFilter); + setOpenMenu(null); + setPage(1); + }, [keyword, selectedFilters, timeFilter]); + + const applyTimeRefresh = useCallback((nextTimeFilter: TimeFilterState, nextRefreshKey: RefreshKey) => { + setTimeFilter(nextTimeFilter); + setAppliedTimeFilter(nextTimeFilter); + setRefreshKey(nextRefreshKey); + setOpenMenu(null); + setPage(1); + }, []); + + const resetQuery = useCallback(() => { + const defaults = cloneFilters(DEFAULT_FILTER_VALUES); + const defaultTimeFilter = createRelativeTimeFilter(DEFAULT_TIME_RANGE); + setKeyword(''); + setSelectedFilters(defaults); + setAppliedKeyword(''); + setAppliedFilters(cloneFilters(defaults)); + setTimeFilter(defaultTimeFilter); + setAppliedTimeFilter(defaultTimeFilter); + setOpenMenu(null); + setPage(1); + }, []); + + const pageCount = Math.max(1, Math.ceil(sortedIncidents.length / PAGE_SIZE)); + const currentPage = Math.min(page, pageCount); + const pagedIncidents = sortedIncidents.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE); + + const timeline = useMemo(() => buildTimeline(filteredIncidents, resolveTimeWindow(appliedTimeFilter)), [appliedTimeFilter, filteredIncidents]); + return ( +
+ {openMenu && +
+ +
+ + +
+ + )} + +
+ +
+ + + +
+ {timelineOpen && } +
+
+ +
+ +
+
+
+ {tr('全部')} ({formatNumber(filteredIncidents.length)}) +
+
+ + {error && ( +
+ {error} +
+ )} + + setSelectedIncident((current) => (current?.id === incident.id ? null : incident))} + onCloseSelected={() => setSelectedIncident(null)} + tr={tr} + /> +
+ + ); +} + +function TimelineChart({ buckets, tr }: { buckets: TimelineBucket[]; tr: Translate }) { + const chartRef = useRef(null); + const [chartWidth, setChartWidth] = useState(1000); + const [activeBucket, setActiveBucket] = useState<{ bucket: TimelineBucket; x: number; y: number } | null>(null); + const maxValue = niceAxisMax(Math.max(1, ...buckets.map((bucket) => bucket.total))); + const midValue = maxValue / 2; + const labelIndexes = Array.from(new Set([0, 0.25, 0.5, 0.75, 1].map((ratio) => Math.round((buckets.length - 1) * ratio)))); + const plotLeft = 42; + const plotRight = 20; + const plotTop = 24; + const plotMiddle = 100; + const plotBottom = 176; + const chartHeight = 200; + const plotHeight = plotBottom - plotTop; + const plotWidth = Math.max(1, chartWidth - plotLeft - plotRight); + const chartStep = plotWidth / Math.max(buckets.length - 1, 1); + const xForIndex = (index: number) => plotLeft + index * chartStep; + + useEffect(() => { + const element = chartRef.current; + if (!element) return undefined; + + const setMeasuredWidth = (width: number) => { + const nextWidth = Math.max(560, Math.floor(width)); + setChartWidth((current) => (Math.abs(current - nextWidth) > 1 ? nextWidth : current)); + }; + + setMeasuredWidth(element.getBoundingClientRect().width); + + if (typeof ResizeObserver === 'undefined') { + const handleResize = () => setMeasuredWidth(element.getBoundingClientRect().width); + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + } + + const observer = new ResizeObserver((entries) => { + const entry = entries[0]; + if (entry) setMeasuredWidth(entry.contentRect.width); + }); + observer.observe(element); + return () => observer.disconnect(); + }, []); + + const showBucket = (bucket: TimelineBucket, event: any) => { + if (!bucket.total) { + setActiveBucket(null); + return; + } + const rect = event.currentTarget.ownerSVGElement.getBoundingClientRect(); + const maxTooltipX = Math.max(48, rect.width - 190); + setActiveBucket({ + bucket, + x: Math.min(Math.max(event.clientX - rect.left + 12, 48), maxTooltipX), + y: Math.max(event.clientY - rect.top - 18, 12), + }); + }; + return ( +
+
+ {tr('攻击成功')} + {tr('攻击失败')} + {tr('未知')} +
+ setActiveBucket(null)} + > + {[plotTop, plotMiddle, plotBottom].map((y) => ( + + ))} + {buckets.map((bucket, index) => { + const x = xForIndex(index); + const successHeight = Math.max(bucket.success ? 2 : 0, (bucket.success / maxValue) * plotHeight); + const failedHeight = Math.max(bucket.failed ? 2 : 0, (bucket.failed / maxValue) * plotHeight); + const unknownHeight = Math.max(bucket.unknown ? 2 : 0, (bucket.unknown / maxValue) * plotHeight); + const failedY = plotBottom - successHeight - failedHeight; + const unknownY = failedY - unknownHeight; + return ( + showBucket(bucket, event)} + onMouseMove={(event) => showBucket(bucket, event)} + onClick={(event) => showBucket(bucket, event)} + className={bucket.total ? 'cursor-pointer' : undefined} + > + {unknownHeight > 0 && } + {failedHeight > 0 && } + {successHeight > 0 && } + {bucket.total > 0 && } + + ); + })} + + {formatNumber(maxValue)} + {formatNumber(midValue)} + 0 + {labelIndexes.map((bucketIndex, labelIndex) => { + const bucket = buckets[bucketIndex]; + const anchor = labelIndex === 0 ? 'start' : labelIndex === labelIndexes.length - 1 ? 'end' : 'middle'; + return ( + {bucket.label} + ); + })} + + {activeBucket && ( +
+
{activeBucket.bucket.label}
+
{tr('攻击成功')}{tr(':')}{formatNumber(activeBucket.bucket.success)}
+
{tr('攻击失败')}{tr(':')}{formatNumber(activeBucket.bucket.failed)}
+
{tr('未知')}{tr(':')}{formatNumber(activeBucket.bucket.unknown)}
+
+ )} +
+ ); +} + +function IncidentTable({ + incidents, + total, + page, + pageCount, + onPageChange, + selectedIncidentId, + onSelect, + onCloseSelected, + tr, +}: { + incidents: IncidentCluster[]; + total: number; + page: number; + pageCount: number; + onPageChange: (page: number) => void; + selectedIncidentId?: string; + onSelect: (incident: IncidentCluster) => void; + onCloseSelected: () => void; + tr: Translate; +}) { + const rangeStart = total === 0 ? 0 : (page - 1) * PAGE_SIZE + 1; + const rangeEnd = Math.min(page * PAGE_SIZE, total); + const go = (next: number) => onPageChange(Math.max(1, Math.min(pageCount, next))); + + if (total === 0) { + return ( +
+ {tr('暂无可展示的告警数据。')} +
+ ); + } + + const headers = ['事件时间', '威胁名称', '威胁类型', '攻击阶段', '攻击结果', '流量方向', '源地址', '源端口', '目标地址', '目标端口', '请求 URL']; + const tableWidth = headers.length * ALERT_TABLE_COLUMN_WIDTH; + return ( +
+
+ + + {headers.map((header) => ( + + ))} + + + + {headers.map((header) => ( + + ))} + + + + {incidents.map((incident) => { + const selected = selectedIncidentId === incident.id; + const threatName = displayThreatName(incident); + return ( + + onSelect(incident)} className={`cursor-pointer ${selected ? 'bg-blue-50/70' : 'hover:bg-blue-50/60'}`}> + + + + + + + + + + + + + {selected && ( + + + + )} + + ); + })} + +
{tr(header)}
{incident.observedAt || cellValue(incident, 'time', '-')}
{threatName}
{cellValue(incident, 'threat_type', '-')}
{cellValue(incident, 'threat_phase', '-')}
{attackResultLabel(incident, tr)}{cellValue(incident, 'direction', '-')}
{incident.srcIp || cellValue(incident, 'sip', '-')}
{cellValue(incident, 'sport', '-')}
{cellValue(incident, 'dip', '-')}
{cellValue(incident, 'dport', '-')}
{cellValue(incident, 'req_http_url', incident.request?.uri || '-')}
+
+ +
+
+
+
+ {interpolate(tr('显示 {start}-{end} / {total} 条,每页 {pageSize} 条'), { + start: formatNumber(rangeStart), + end: formatNumber(rangeEnd), + total: formatNumber(total), + pageSize: PAGE_SIZE, + })} +
+ + + {page} / {pageCount} + + +
+
+
+ ); +} + +function IncidentInlineDetail({ incident, onClose, tr }: { incident: IncidentCluster; onClose: () => void; tr: Translate }) { + const [stepsOpen, setStepsOpen] = useState(true); + const [activePane, setActivePane] = useState<'detail' | 'triage'>('detail'); + const [copyDone, setCopyDone] = useState(false); + const report = parseTaggedReport(incident.triageReport); + const markdownReport = report ? null : parseMarkdownReport(incident.triageReport); + const attackJudgement = incident.conclusion?.verdict || tr('待确认'); + const attackResult = attackResultLabel(incident, tr); + const attackResultBucket = verdictBucket(incident); + const srcAddress = incident.srcIp || cellValue(incident, 'sip', '-'); + const srcPort = cellValue(incident, 'sport', '-'); + const dstAddress = cellValue(incident, 'dip', '-'); + const dstPort = cellValue(incident, 'dport', '-'); + const host = cellValue(incident, 'req_host', incident.request?.host || '-'); + const url = cellValue(incident, 'req_http_url', incident.request?.uri || '-'); + const requestLine = cellValue(incident, 'req_line', ''); + const requestHeader = cellValue(incident, 'req_header', ''); + const requestBody = cellValue(incident, 'req_body', ''); + const responseLine = cellValue(incident, 'rsp_line', ''); + const responseHeader = cellValue(incident, 'rsp_header', ''); + const responseBody = cellValue(incident, 'rsp_body', ''); + const requestText = [requestLine, requestHeader, requestBody].filter(Boolean).join('\n'); + const responseText = [responseLine, responseHeader, responseBody].filter(Boolean).join('\n'); + const ruleId = incident.ndrRule || cellValue(incident, 'threat_rule_id', '-'); + const observedAt = incident.observedAt || cellValue(incident, 'time', '-'); + const threatName = cellValue(incident, 'threat_name', incident.title); + const threatMessage = cellValue(incident, 'threat_msg', incident.reason || '-'); + const basicFields = [ + ['事件时间', observedAt], + ['威胁名称', threatName], + ['威胁类型', cellValue(incident, 'threat_type', '-')], + ['攻击阶段', cellValue(incident, 'threat_phase', '-')], + ['攻击行为', attackJudgement], + ['攻击结果', attackResult], + ['流量方向', cellValue(incident, 'direction', '-')], + ['响应码', cellValue(incident, 'rsp_status_code', '-')], + ['规则 ID', ruleId], + ['威胁描述', threatMessage], + ]; + const sourceFields = [ + ['源地址', srcAddress], + ['源端口', srcPort], + ['流量方向', cellValue(incident, 'direction', '-')], + ['数据源', cellValue(incident, '_source_type', '-')], + ]; + const accessFields = [ + ['HTTP Host', host], + ['请求 URL', url], + ['请求行', requestLine || '-'], + ['User-Agent', cellValue(incident, 'req_user_agent', '-')], + ['请求体长度', cellValue(incident, 'req_body_len', '-')], + ['协议类型', cellValue(incident, 'net_type', '-')], + ]; + const destinationFields = [ + ['目标地址', dstAddress], + ['目标端口', dstPort], + ['响应码', cellValue(incident, 'rsp_status_code', '-')], + ['响应行', responseLine || '-'], + ['响应体长度', cellValue(incident, 'rsp_body_len', '-')], + ['规则 ID', ruleId], + ]; + const triageSummary = incident.conclusion?.summary || incident.reason || tr('暂无研判摘要。'); + const triageFields = [ + ['事件时间', observedAt], + ['攻击行为', attackJudgement], + ['攻击结果', attackResult], + ['威胁名称', threatName], + ['响应码', cellValue(incident, 'rsp_status_code', '-')], + ['攻击阶段', cellValue(incident, 'threat_phase', '-')], + ['规则 ID', ruleId], + ]; + const reportContent = (...parts: Array) => parts + .map((part) => (part || '').trim()) + .filter(Boolean) + .join('\n\n') || '-'; + const reportSectionMap = new Map(markdownReport?.sections || []); + const reportStepMap = new Map(markdownReport?.steps || []); + const markdownReportSection = (title: string) => reportSectionMap.get(title) || ''; + const markdownStepSection = (title: string) => reportStepMap.get(title) || ''; + const markdownRawLog = markdownReportSection('原始日志'); + const triageSections = report ? [ + ['研判结论', report.sections.triage_conclusion], + ['攻击payload', reportContent(report.sections.attack_payload, report.sections.payload_explanation)], + ['重要证据', reportContent(report.sections.response_evidence, report.sections.key_evidence)], + ['处置建议', report.sections.disposal_recommendation], + ] : markdownReport ? [ + ['研判结论', reportContent(markdownReportSection('分析结果'), markdownReportSection('安全分析报告'), markdownReportSection('报告摘要'), triageSummary)], + ['攻击payload', reportContent(markdownReportSection('攻击负载分析结果'), markdownStepSection('攻击负载分析'), requestLine || incident.request?.payload || url)], + ['重要证据', reportContent(markdownReportSection('漏洞情报原始数据'), markdownRawLog || reportContent(requestText, responseText))], + ['处置建议', incident.conclusion?.recommendation || (incident.actions || []).join(';') || '-'], + ] : [ + ['研判结论', triageSummary], + ['攻击payload', requestLine || incident.request?.payload || url || '-'], + ['重要证据', reportContent(incident.response?.llmAnalysis, responseLine || incident.response?.sample)], + ['处置建议', incident.conclusion?.recommendation || (incident.actions || []).join(';') || '-'], + ]; + const reportTitle = incident.reportTitle || report?.title || markdownReport?.title || incident.title; + const triageDetailTitle = markdownReport?.title || threatName; + const markdownSteps = markdownReport?.steps || []; + const stepCount = report?.stepCount || markdownSteps.length; + const exportPayload = { + title: reportTitle, + observedAt, + fields: triageFields, + summary: triageSummary, + steps: report ? [['分析步骤', report.sections.analysis_steps] as [string, string]] : markdownSteps, + sections: triageSections, + }; + const handleCopyReport = useCallback(async () => { + await copyTextToClipboard(reportPlainText(exportPayload)); + setCopyDone(true); + window.setTimeout(() => setCopyDone(false), 1400); + }, [exportPayload]); + const handleDownloadReport = useCallback(() => { + downloadBlob(buildDocxBlob(exportPayload), exportFileName(reportTitle, observedAt)); + }, [exportPayload, observedAt, reportTitle]); + + return ( +
+
+
+
+
+ {attackJudgement} + {observedAt} + / + {ruleId} +
+
+ {reportTitle} +
+
+ {threatMessage} +
+
+
+ {activePane === 'triage' && ( +
+ + +
+ )} +
+ {[ + ['detail', '详细信息'], + ['triage', '研判结果'], + ].map(([key, label]) => ( + + ))} +
+ +
+
+ +
+ {activePane === 'detail' ? ( +
+ + + + + + + + + + + + + + + + + + + + + + + +
+ ) : ( +
+ {stepCount > 0 && ( + setStepsOpen((open) => !open)} + tr={tr} + > + {report ? ( + + ) : ( + + )} + + )} + + +
+
+ +
+
+ + {triageSections.map(([title, content]) => ( + + ))} +
+
+
+
+ )} +
+
+
+ ); +} + +function SectionTitle({ title, subtitle }: { title: string; subtitle?: string }) { + return ( +
+
{title}
+ {subtitle &&
{subtitle}
} +
+ ); +} + +function ArchiveSection({ title, value, children }: { title: string; value: string; children: React.ReactNode }) { + return ( +
+
+
+ {title}: + {value || '-'} +
+
+ {children} +
+
+
+ ); +} + +function InfoGrid({ fields, tr, monoLabels = [], wideLabels = [] }: { fields: string[][]; tr: Translate; monoLabels?: string[]; wideLabels?: string[] }) { + return ( +
+ {fields.map(([label, value]) => { + const mono = monoLabels.includes(label); + const wide = wideLabels.includes(label); + return ( +
+
{tr(label)}:
+
+ {value || '-'} +
+
+ ); + })} +
+ ); +} + +function PlainTextBlock({ label, content, mono = false }: { label: string; content: string; mono?: boolean }) { + return ( +
+
{label}:
+
+ {content || '-'} +
+
+ ); +} + +function TriageTextSection({ title, content }: { title: string; content: string }) { + return ( +
+
{title}:
+ +
+ ); +} + +function TriageConclusionSection({ title, fields, summary, tr }: { title: string; fields: string[][]; summary: string; tr: Translate }) { + return ( +
+
+ {tr(title)} +
+ +
+
{tr('研判结论')}:
+ +
+
+ ); +} + +function AnalysisStepsCard({ + count, + open, + onToggle, + children, + tr, +}: { + count: number; + open: boolean; + onToggle: () => void; + children: React.ReactNode; + tr: Translate; +}) { + return ( +
+ + {open && ( +
+ {children} +
+ )} +
+ ); +} + +function AnalysisStepTimeline({ steps, tr }: { steps: Array<[string, string]>; tr: Translate }) { + return ( +
+ {steps.map(([title, content], index) => { + const isLast = index === steps.length - 1; + return ( +
+ {!isLast && ( +
+ )} +
+ ✓ +
+
+
+ {tr(title)} +
+
+ +
+
+
+ ); + })} +
+ ); +} + +function AttackResultStamp({ bucket, tr }: { bucket: 'success' | 'failed' | 'unknown'; tr: Translate }) { + const config = bucket === 'success' + ? { label: tr('攻击成功'), color: '#ef4444', fontSize: 29 } + : bucket === 'failed' + ? { label: tr('攻击失败'), color: '#16a34a', fontSize: 29 } + : { label: tr('未知'), color: '#64748b', fontSize: 35 }; + return ( +
+ +
+ ); +} + +function MarkdownText({ content, compact = false }: { content: string; compact?: boolean }) { + const lines = content.split(/\r?\n/); + let inCodeBlock = false; + const codeLines: string[] = []; + const paragraphLines: string[] = []; + const flushCodeBlock = (key: string) => { + const text = codeLines.join('\n') || '-'; + codeLines.length = 0; + return ( +
+        {text}
+      
+ ); + }; + const renderInline = (line: string) => line.replace(/\*\*([^*]+)\*\*/g, '$1').replace(/`([^`]+)`/g, '$1'); + const nodes: React.ReactNode[] = []; + const flushParagraph = (key: string) => { + if (!paragraphLines.length) return; + const text = paragraphLines.join(' '); + paragraphLines.length = 0; + nodes.push(

{text}

); + }; + lines.forEach((line, index) => { + const key = `${index}-${line}`; + const trimmed = line.trim(); + if (line.trim().startsWith('```')) { + flushParagraph(`${key}-paragraph`); + if (inCodeBlock) { + nodes.push(flushCodeBlock(key)); + inCodeBlock = false; + } else { + inCodeBlock = true; + } + return; + } + if (inCodeBlock) { + codeLines.push(line); + return; + } + if (!trimmed) { + flushParagraph(`${key}-paragraph`); + nodes.push(
); + return; + } + if (trimmed.startsWith('# ')) { + flushParagraph(`${key}-paragraph`); + nodes.push(
{renderInline(trimmed.slice(2))}
); + return; + } + if (trimmed.startsWith('## ')) { + flushParagraph(`${key}-paragraph`); + nodes.push(
{renderInline(trimmed.slice(3))}
); + return; + } + if (trimmed.startsWith('### ')) { + flushParagraph(`${key}-paragraph`); + nodes.push(
{renderInline(trimmed.slice(4))}
); + return; + } + if (/^\d+[.、]\s+/.test(trimmed)) { + flushParagraph(`${key}-paragraph`); + nodes.push(
{renderInline(trimmed)}
); + return; + } + if (trimmed.startsWith('- ')) { + flushParagraph(`${key}-paragraph`); + nodes.push(
• {renderInline(trimmed.slice(2))}
); + return; + } + if (/^\*\*[^*]+?\*\*\s*[::]/.test(trimmed)) { + flushParagraph(`${key}-paragraph`); + nodes.push(
{renderInline(trimmed)}
); + return; + } + paragraphLines.push(renderInline(trimmed)); + }); + flushParagraph('paragraph-tail'); + if (inCodeBlock) nodes.push(flushCodeBlock('code-tail')); + return ( +
+ {nodes} +
+ ); +} diff --git a/.flocks/flockshub/plugins/webuis/soc_ui/soc_dashboard/api/handlers.py b/.flocks/flockshub/plugins/webuis/soc_ui/soc_dashboard/api/handlers.py new file mode 100644 index 000000000..41eea7ca1 --- /dev/null +++ b/.flocks/flockshub/plugins/webuis/soc_ui/soc_dashboard/api/handlers.py @@ -0,0 +1,1133 @@ +import json +import math +import os +import re +import sqlite3 +import time +from collections import Counter +from dataclasses import dataclass +from datetime import datetime, timedelta +from pathlib import Path + + +DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") +SQL_IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") +CONTRACTS_ROOT = Path(__file__).resolve().parents[4] +ACCESS_CONFIG_PATH = CONTRACTS_ROOT / "access" / "soc_alerts.json" +DEFAULT_DATA_SOURCE = "sqlite" +DEFAULT_SQLITE_DB = Path.home() / ".flocks" / "data" / "soc.db" +DEFAULT_SQLITE_TABLE = "alert_records" +DEFAULT_SQLITE_RECORD_COLUMN = "record_json" +DEFAULT_SQLITE_DATE_COLUMN = "asset_date" +DEFAULT_SQLITE_EVENT_TIME_COLUMN = "event_time" +SIMULATED_TIME_BUCKETS = 24 + +WORKFLOW_DB = Path.home() / ".flocks" / "data" / "workflow.db" + +_workflow_stats_cache: dict = {} +_cache_updated_at: float = 0 +_CACHE_TTL: float = 30.0 + + +@dataclass(frozen=True) +class _RecordSource: + path: Path + role: str + date: str + data_source: str + record_count: int = 0 + + +def _get_workflow_call_count(workflow_name: str, date: str = None) -> int: + global _workflow_stats_cache, _cache_updated_at + now = time.time() + cache_key = f"{workflow_name}:{date or 'total'}" + + if now - _cache_updated_at < _CACHE_TTL and cache_key in _workflow_stats_cache: + return _workflow_stats_cache[cache_key] + + empty = {"callCount": 0, "dupCount": 0, "uniqueCount": 0} + if not WORKFLOW_DB.is_file(): + return empty + + try: + if date: + start = int(datetime.strptime(date, "%Y-%m-%d").timestamp() * 1000) + end = start + 86400 * 1000 + with sqlite3.connect(WORKFLOW_DB) as conn: + rows = conn.execute( + """ + SELECT output_results + FROM workflow_executions + WHERE workflow_id = ? AND started_at >= ? AND started_at < ? + """, + (workflow_name, start, end), + ).fetchall() + dup_count = 0 + unique_count = 0 + for (output_text,) in rows: + try: + output = json.loads(output_text or "{}") + except Exception: + output = {} + stats = output.get("stats") if isinstance(output.get("stats"), dict) else {} + raw = _safe_int(stats.get("raw_count")) + if "after_dedup_count" in stats and stats.get("after_dedup_count") is not None: + unique_count += _safe_int(stats.get("after_dedup_count")) + else: + unique_count += raw + if output.get("is_duplicate") is True: + dup_count += 1 + result_dict = { + "callCount": len(rows), + "dupCount": dup_count, + "uniqueCount": unique_count, + } + else: + with sqlite3.connect(WORKFLOW_DB) as conn: + row = conn.execute( + "SELECT call_count FROM workflow_stats WHERE workflow_id = ?", + (workflow_name,), + ).fetchone() + result_dict = {"callCount": _safe_int(row[0] if row else 0), "dupCount": 0, "uniqueCount": 0} + + _workflow_stats_cache[cache_key] = result_dict + _cache_updated_at = now + return result_dict + except Exception: + return _workflow_stats_cache.get(cache_key, empty) + + +SOURCE_DEFS = [ + ("ndr", "NDR 网络流量", ("ndr", "tdp", "network")), + ("edr", "EDR 主机告警", ("edr", "hids", "linux")), + ("waf", "WAF Web 防护", ("waf", "web")), + ("ids", "IDS/IPS 入侵检测", ("ids", "ips", "skyeye")), + ("cloud", "云日志", ("cloud", "aliyun", "qcloud")), + ("vuln", "漏洞情报", ("vuln", "cve", "qingteng")), + ("other", "其他接入", ("other", "unknown", "none")), +] + +PHASE_LABELS = { + "recon": "侦察探测", + "exploit": "漏洞利用", + "post_exploit": "后渗透", + "control": "控制通信", + "unknown": "未知阶段", +} + +DIRECTION_LABELS = { + "in": "入站", + "out": "出站", + "lateral": "横向", + "unknown": "未知方向", +} + +RESULT_LABELS = { + "success": "攻击成功", + "succeeded": "攻击成功", + "failed": "攻击失败", + "blocked": "已阻断", + "attack_success": "攻击成功", + "attack": "攻击行为", + "attack_failed": "攻击失败", + "benign": "良性", + "unknown": "待确认", +} + + +def get_stats(ctx, request): + date = _normalize_date(request.query_params.get("date") or _latest_asset_date()) + start_date, end_date = _normalize_range( + request.query_params.get("startDate"), + request.query_params.get("endDate"), + date, + ) + started = time.time() + + denoise_files, denoise_locations = [], [] + triage_files, triage_locations = [], [] + + asset_files = _find_asset_files(start_date, end_date) + asset_denoise_files = [path for path in asset_files if _asset_file_role(path) == "denoise"] + asset_triage_files = [path for path in asset_files if _asset_file_role(path) == "triage"] + sample_mode = bool(asset_denoise_files or asset_triage_files) + if asset_denoise_files: + denoise_files = asset_denoise_files + if asset_triage_files: + triage_files = asset_triage_files + + workflow_stats = _get_workflow_call_count("stream_alert_denoise", date=start_date) + denoise = _read_denoise(denoise_files) + triage = _read_triage(triage_files) + if triage["totalRecords"] == 0 and denoise["totalRaw"] > 0: + triage = _simulate_triage_from_denoise(denoise_files) + sources = _build_sources(denoise["sourceCounter"] or triage["sourceCounter"]) + closed_loop = _build_closed_loop(triage) + pipeline = _build_pipeline(denoise, triage) + date_range = _build_date_range(start_date, end_date, asset_files) + event_range = _build_event_range(date_range, denoise, triage) + + return { + "date": start_date, + "dateRange": date_range, + "eventRange": event_range, + "generatedAt": datetime.now().isoformat(timespec="seconds"), + "latencyMs": round((time.time() - started) * 1000), + "sourceStatus": { + "workflowStatsDb": _display_path(WORKFLOW_DB), + "workflowStats": workflow_stats, + "sampleMode": sample_mode, + "sampleFile": ", ".join(_source_label(path) for path in asset_files) if sample_mode else "", + "assets": { + "path": _display_path(_active_source_path()), + "exists": _active_source_exists(), + "dataSource": _active_data_source(), + "config": _display_path(ACCESS_CONFIG_PATH), + "fileCount": len(asset_files), + "availableDates": _available_asset_dates(), + "selectedDates": date_range["fileDates"], + }, + "assetFiles": [_file_brief(path) for path in asset_files], + "denoise": denoise_locations, + "triage": triage_locations, + "denoiseFiles": [_file_brief(path) for path in denoise_files], + "triageFiles": [_file_brief(path) for path in triage_files], + "missing": [] if sample_mode else [ + item + for item in denoise_locations + triage_locations + if not item["exists"] or item["fileCount"] == 0 + ], + }, + "denoise": _without_counters(denoise), + "triage": _without_counters(triage), + "pipeline": pipeline, + "sources": sources, + "closedLoop": closed_loop, + "attackProfile": _build_attack_profile(denoise, triage), + "verdicts": [ + {"key": "attack_success", "label": "攻击成功", "value": triage["attackSuccess"], "color": "#ff4d6d"}, + {"key": "attack", "label": "攻击行为", "value": triage["attack"], "color": "#ffb020"}, + {"key": "attack_failed", "label": "攻击失败", "value": triage["attackFailed"], "color": "#2ee6a6"}, + {"key": "benign", "label": "良性", "value": triage["benign"], "color": "#58a6ff"}, + {"key": "unknown", "label": "未知", "value": triage["unknown"], "color": "#9b8cff"}, + ], + "topThreats": _counter_items(triage["threatCounter"] or denoise["threatCounter"], 14), + "riskLevels": _counter_items(triage["riskCounter"], 5), + "timeline": { + "labels": _series_labels(max(len(denoise["seriesRaw"]), len(triage["seriesTotal"]))), + "window": _timeline_window(start_date, end_date, len(denoise["seriesRaw"])), + "denoiseRaw": denoise["seriesRaw"], + "denoiseUnique": denoise["seriesUnique"], + "triageTotal": triage["seriesTotal"], + "triageAttack": triage["seriesAttack"], + }, + } + + +def _simulate_triage_from_denoise(paths): + total_records = 0 + parse_errors = 0 + source_counter = Counter() + threat_counter = Counter() + risk_counter = Counter() + verdict_counter = Counter() + profile_counters = _new_profile_counters() + event_start = None + event_end = None + series_total = [] + series_attack = [] + + for path in paths: + file_total = 0 + file_attack = 0 + for obj in _iter_source_records(path): + if obj is None: + parse_errors += 1 + continue + if obj.get("_type") == "file_header" or obj.get("is_duplicate") is True: + continue + total_records += 1 + file_total += 1 + verdict = _dedup_record_verdict(obj) + verdict_counter[verdict] += 1 + if verdict in {"attack_success", "attack", "attack_failed"}: + file_attack += 1 + source_counter[_norm(obj.get("_source_type") or obj.get("source_type") or obj.get("device_type"))] += 1 + threat_counter[_norm(obj.get("_threat_type") or obj.get("threat_name") or obj.get("threat_type"))] += 1 + risk_counter[_norm(obj.get("threat_level") or obj.get("threat_severity") or obj.get("risk_level"))] += 1 + _update_profile_counters(obj, profile_counters) + event_start, event_end = _merge_record_time(event_start, event_end, obj) + series_total.append(file_total) + series_attack.append(file_attack) + + attack_success = verdict_counter["attack_success"] + attack = verdict_counter["attack"] + attack_failed = verdict_counter["attack_failed"] + attack_total = attack_success + attack + attack_failed + benign = verdict_counter["benign"] + unknown = verdict_counter["unknown"] + new_triaged = round(total_records * 0.22) + cache_hit = round(total_records * 0.68) + followers_reused = max(total_records - new_triaged - cache_hit, 0) + + series_total = _expand_series(series_total, total_records, seed=17) + series_attack = _expand_series(series_attack, attack_total, seed=19) + + return { + "totalRecords": total_records, + "batchTotal": total_records, + "newTriaged": new_triaged, + "cacheHit": cache_hit, + "triageFailed": 0, + "followersReused": followers_reused, + "attackTotal": attack_total, + "attackSuccess": attack_success, + "attack": attack, + "attackFailed": attack_failed, + "benign": benign, + "unknown": unknown, + "attackRate": _ratio(attack_total, total_records), + "successRate": _ratio(attack_success, attack_total), + "cacheRate": _ratio(cache_hit + followers_reused, total_records), + "coverageRate": _ratio(total_records, total_records), + "headers": 0, + "files": len(paths), + "parseErrors": parse_errors, + "eventStart": _format_event_time(event_start), + "eventEnd": _format_event_time(event_end), + "sourceCounter": source_counter, + "threatCounter": threat_counter, + "riskCounter": risk_counter, + "statusCounter": Counter({"simulated": total_records}), + **profile_counters, + "seriesTotal": series_total, + "seriesAttack": series_attack, + } + + +def _dedup_record_verdict(obj): + threat_level = _norm(obj.get("threat_level")) + threat_result = _norm(obj.get("threat_result")) + status = _safe_int(obj.get("rsp_status_code")) + body_len = _safe_int(obj.get("rsp_body_len")) + + if threat_level in {"benign", "info", "low"}: + return "benign" + if threat_result in {"success", "succeeded"}: + return "attack_success" + if threat_result in {"failed", "blocked"} or status in {401, 403, 404, 405, 406, 410}: + return "attack_failed" + if status == 200 and body_len > 0: + return "attack_success" + if threat_level == "attack" or obj.get("threat_name"): + return "attack" + return "unknown" + + +def _normalize_date(value): + if value and DATE_RE.match(str(value)): + return str(value) + return datetime.now().strftime("%Y-%m-%d") + + +def _normalize_range(start_value, end_value, fallback_date): + start_date = _normalize_date(start_value or fallback_date) + end_date = _normalize_date(end_value or start_date) + if start_date > end_date: + start_date, end_date = end_date, start_date + return start_date, end_date + + +def _date_span(start_date, end_date): + start = datetime.strptime(start_date, "%Y-%m-%d").date() + end = datetime.strptime(end_date, "%Y-%m-%d").date() + current = start + while current <= end: + yield current.strftime("%Y-%m-%d") + current += timedelta(days=1) + + +def _find_asset_files(start_date, end_date): + return _find_sqlite_sources(start_date, end_date) + + +def _asset_file_date(path): + if isinstance(path, _RecordSource): + return path.date + return "" + + +def _latest_asset_date(): + dates = _available_asset_dates() + return dates[-1] if dates else datetime.now().strftime("%Y-%m-%d") + + +def _available_asset_dates(): + return _available_sqlite_dates() + + +def _load_alerts_config(): + raw = {} + if ACCESS_CONFIG_PATH.is_file(): + try: + value = json.loads(ACCESS_CONFIG_PATH.read_text(encoding="utf-8")) + except Exception: + value = {} + if isinstance(value, dict): + raw = value + + sqlite_config = raw.get("sqlite") if isinstance(raw.get("sqlite"), dict) else {} + return { + "dataSource": DEFAULT_DATA_SOURCE, + "sqlite": { + "dbPath": _read_config_string( + os.environ.get("FLOCKS_SOC_ALERTS_SQLITE_DB"), + sqlite_config.get("dbPath"), + str(DEFAULT_SQLITE_DB), + ), + "table": _read_config_string(sqlite_config.get("table"), DEFAULT_SQLITE_TABLE), + "recordColumn": _read_config_string(sqlite_config.get("recordColumn"), DEFAULT_SQLITE_RECORD_COLUMN), + "dateColumn": _read_config_string(sqlite_config.get("dateColumn"), DEFAULT_SQLITE_DATE_COLUMN), + "eventTimeColumn": _read_config_string( + sqlite_config.get("eventTimeColumn"), + DEFAULT_SQLITE_EVENT_TIME_COLUMN, + ), + }, + } + + +def _active_data_source(): + return "sqlite" + + +def _read_config_string(*values): + for value in values: + if isinstance(value, str) and value.strip(): + return value.strip() + return "" + + +def _resolve_config_path(value, fallback): + text = _read_config_string(value, str(fallback)) + path = Path(text).expanduser() + if path.is_absolute(): + return path + return (ACCESS_CONFIG_PATH.parent / path).resolve() + + +def _sqlite_settings(): + config = _load_alerts_config()["sqlite"] + return { + "db_path": _resolve_config_path(config.get("dbPath"), DEFAULT_SQLITE_DB), + "table": _sql_identifier(config.get("table"), DEFAULT_SQLITE_TABLE), + "record_column": _sql_identifier(config.get("recordColumn"), DEFAULT_SQLITE_RECORD_COLUMN), + "date_column": _sql_identifier(config.get("dateColumn"), DEFAULT_SQLITE_DATE_COLUMN), + "event_time_column": _sql_identifier( + config.get("eventTimeColumn"), + DEFAULT_SQLITE_EVENT_TIME_COLUMN, + ), + } + + +def _sql_identifier(value, fallback): + text = _read_config_string(value, fallback) + if not SQL_IDENTIFIER_RE.match(text): + text = fallback + return f'"{text}"' + + +def _active_source_path(): + return _sqlite_settings()["db_path"] + + +def _active_source_exists(): + path = _active_source_path() + return path.is_file() + + +def _find_sqlite_sources(start_date, end_date): + settings = _sqlite_settings() + db_path = settings["db_path"] + if not db_path.is_file(): + return [] + + query = ( + f"SELECT {settings['date_column']} AS asset_date, COUNT(*) AS record_count " + f"FROM {settings['table']} " + f"WHERE {settings['date_column']} BETWEEN ? AND ? " + f"GROUP BY {settings['date_column']} " + f"ORDER BY {settings['date_column']}" + ) + try: + with sqlite3.connect(db_path) as conn: + rows = conn.execute(query, (start_date, end_date)).fetchall() + except Exception: + return [] + + sources = [] + for asset_date, record_count in rows: + asset_date = str(asset_date or "") + if not DATE_RE.match(asset_date): + continue + sources.append( + _RecordSource( + path=db_path, + role="denoise", + date=asset_date, + data_source="sqlite", + record_count=int(record_count or 0), + ) + ) + return sources + + +def _available_sqlite_dates(): + settings = _sqlite_settings() + db_path = settings["db_path"] + if not db_path.is_file(): + return [] + query = ( + f"SELECT DISTINCT {settings['date_column']} AS asset_date " + f"FROM {settings['table']} " + f"WHERE {settings['date_column']} IS NOT NULL " + f"ORDER BY {settings['date_column']}" + ) + try: + with sqlite3.connect(db_path) as conn: + rows = conn.execute(query).fetchall() + except Exception: + return [] + return [str(row[0]) for row in rows if DATE_RE.match(str(row[0]))] + + +def _build_date_range(start_date, end_date, asset_files): + file_dates = sorted({date for date in (_asset_file_date(path) for path in asset_files) if date}) + return { + "start": start_date, + "end": end_date, + "label": start_date if start_date == end_date else f"{start_date} 至 {end_date}", + "availableDates": _available_asset_dates(), + "fileDates": file_dates, + } + + +def _build_event_range(date_range, denoise, triage): + values = [ + _parse_event_time(denoise.get("eventStart")), + _parse_event_time(denoise.get("eventEnd")), + _parse_event_time(triage.get("eventStart")), + _parse_event_time(triage.get("eventEnd")), + ] + values = [value for value in values if value] + if not values: + return { + "start": "", + "end": "", + "label": date_range["label"], + "source": "dateRange", + } + + start = min(values) + end = max(values) + return { + "start": _format_event_time(start), + "end": _format_event_time(end), + "label": _format_event_range_label(start, end), + "source": "recordTime", + } + + +def _timeline_window(start_date, end_date, series_length): + if start_date != end_date: + days = len(list(_date_span(start_date, end_date))) + return f"{days} 天范围聚合" + if series_length >= SIMULATED_TIME_BUCKETS: + return "近 24 小时分布" + return "按批次统计" + + +def _asset_file_role(path): + if isinstance(path, _RecordSource): + return path.role + name = path.name.lower() + if "triage" in name or "研判" in name: + return "triage" + return "denoise" + + +def _read_denoise(paths, workflow_call_count: int = 0): + total_raw = 0 + duplicates = 0 + parse_errors = 0 + headers = [] + source_counter = Counter() + threat_counter = Counter() + profile_counters = _new_profile_counters() + event_start = None + event_end = None + series_raw = [] + series_unique = [] + + for path in paths: + file_raw = 0 + file_duplicates = 0 + for obj in _iter_source_records(path): + if obj is None: + parse_errors += 1 + continue + if obj.get("_type") == "file_header": + headers.append(obj) + continue + file_raw += 1 + if obj.get("is_duplicate") is True: + file_duplicates += 1 + source_counter[_norm(obj.get("_source_type") or obj.get("source_type") or obj.get("device_type"))] += 1 + threat_counter[_norm(obj.get("_threat_type") or obj.get("threat_name") or obj.get("threat_type"))] += 1 + _update_profile_counters(obj, profile_counters) + event_start, event_end = _merge_record_time(event_start, event_end, obj) + total_raw += file_raw + duplicates += file_duplicates + series_raw.append(file_raw) + series_unique.append(max(file_raw - file_duplicates, 0)) + + total_unique = max(total_raw - duplicates, 0) + series_raw = _expand_series(series_raw, total_raw, seed=7) + series_unique = _expand_series(series_unique, total_unique, seed=11) + + return { + "totalRaw": total_raw, + "totalUnique": total_unique, + "duplicates": duplicates, + "duplicateRate": _ratio(duplicates, total_raw), + "uniqueRate": _ratio(total_unique, total_raw), + "headers": len(headers), + "files": len(paths), + "parseErrors": parse_errors, + "eventStart": _format_event_time(event_start), + "eventEnd": _format_event_time(event_end), + "sourceCounter": source_counter, + "threatCounter": threat_counter, + **profile_counters, + "seriesRaw": series_raw, + "seriesUnique": series_unique, + "workflowCallCount": workflow_call_count, + } + + +def _read_triage(paths): + total_records = 0 + parse_errors = 0 + headers = [] + verdict_counter = Counter() + source_counter = Counter() + threat_counter = Counter() + risk_counter = Counter() + status_counter = Counter() + profile_counters = _new_profile_counters() + event_start = None + event_end = None + header_sums = Counter() + fallback_new = 0 + fallback_cache = 0 + fallback_failed = 0 + fallback_followers = 0 + extra_success = 0 + series_total = [] + series_attack = [] + + for path in paths: + file_total = 0 + file_attack = 0 + for obj in _iter_source_records(path): + if obj is None: + parse_errors += 1 + continue + if obj.get("_type") == "file_header": + headers.append(obj) + for key in ( + "batch_total", + "batch_triaged", + "batch_cache_hit", + "batch_triage_failed", + "batch_followers_reused", + ): + header_sums[key] += _safe_int(obj.get(key)) + continue + + total_records += 1 + file_total += 1 + verdict = _norm(obj.get("attack_verdict") or "unknown") + if verdict not in {"attack_success", "attack", "attack_failed", "benign", "unknown"}: + verdict = "unknown" + verdict_counter[verdict] += 1 + if obj.get("attack_success") is True and verdict != "attack_success": + extra_success += 1 + if verdict in {"attack_success", "attack", "attack_failed"}: + file_attack += 1 + + source = _norm(obj.get("_source_type") or obj.get("source_type") or obj.get("device_type")) + source_counter[source] += 1 + threat_counter[_norm(obj.get("_threat_type") or obj.get("threat_name") or obj.get("threat_type"))] += 1 + risk_counter[_norm(obj.get("risk_level") or obj.get("threat_level") or obj.get("threat_severity"))] += 1 + _update_profile_counters(obj, profile_counters) + event_start, event_end = _merge_record_time(event_start, event_end, obj) + triage_source = _norm(obj.get("triage_source")) + triage_status = _norm(obj.get("triage_status")) + status_counter[triage_status or triage_source] += 1 + + if triage_source == "cache" or triage_status == "cached": + fallback_cache += 1 + elif triage_source in {"follower", "followers", "follower_reused"} or triage_status == "follower_reused": + fallback_followers += 1 + elif triage_status in {"failed", "error"}: + fallback_failed += 1 + else: + fallback_new += 1 + + series_total.append(file_total) + series_attack.append(file_attack) + + has_batch_fields = any( + _safe_int(header_sums[key]) > 0 + for key in ("batch_triaged", "batch_cache_hit", "batch_triage_failed", "batch_followers_reused") + ) + new_triaged = fallback_new + cache_hit = fallback_cache + triage_failed = fallback_failed + followers_reused = fallback_followers + + attack_success = verdict_counter["attack_success"] + extra_success + attack = verdict_counter["attack"] + attack_failed = verdict_counter["attack_failed"] + attack_total = attack_success + attack + attack_failed + benign = verdict_counter["benign"] + unknown = verdict_counter["unknown"] + + series_total = _expand_series(series_total, total_records, seed=23) + series_attack = _expand_series(series_attack, attack_total, seed=29) + + return { + "totalRecords": total_records, + "batchTotal": header_sums["batch_total"], + "newTriaged": new_triaged, + "cacheHit": cache_hit, + "triageFailed": triage_failed, + "followersReused": followers_reused, + "attackTotal": attack_total, + "attackSuccess": attack_success, + "attack": attack, + "attackFailed": attack_failed, + "benign": benign, + "unknown": unknown, + "attackRate": _ratio(attack_total, total_records), + "successRate": _ratio(attack_success, attack_total), + "cacheRate": _ratio(cache_hit + followers_reused, total_records), + "coverageRate": _ratio(total_records - triage_failed, total_records), + "headers": len(headers), + "files": len(paths), + "parseErrors": parse_errors, + "eventStart": _format_event_time(event_start), + "eventEnd": _format_event_time(event_end), + "sourceCounter": source_counter, + "threatCounter": threat_counter, + "riskCounter": risk_counter, + "statusCounter": status_counter, + **profile_counters, + "seriesTotal": series_total, + "seriesAttack": series_attack, + } + + +def _new_profile_counters(): + return { + "phaseCounter": Counter(), + "directionCounter": Counter(), + "resultCounter": Counter(), + "portCounter": Counter(), + "protocolCounter": Counter(), + "severityCounter": Counter(), + "responseCounter": Counter(), + } + + +def _update_profile_counters(obj, counters): + counters["phaseCounter"][_norm(obj.get("threat_phase") or obj.get("attack_phase") or obj.get("kill_chain_phase"))] += 1 + counters["directionCounter"][_norm(obj.get("direction") or obj.get("traffic_direction"))] += 1 + counters["resultCounter"][_norm(obj.get("threat_result") or obj.get("attack_verdict"))] += 1 + counters["protocolCounter"][_norm(obj.get("net_type") or obj.get("net_app_proto") or obj.get("protocol"))] += 1 + counters["severityCounter"][_norm(obj.get("threat_severity") or obj.get("threat_level") or obj.get("risk_level"))] += 1 + counters["responseCounter"][_norm(obj.get("rsp_status_code") or obj.get("status_code"))] += 1 + + port_value = obj.get("dport") or obj.get("dst_port") or obj.get("destination_port") + port = str(_safe_int(port_value)) if _safe_int(port_value) > 0 else _norm(port_value) + counters["portCounter"][port] += 1 + + +def _expand_series(values, total, *, seed): + values = [max(_safe_int(value), 0) for value in values if _safe_int(value) > 0] + total = _safe_int(total) + if total <= 0: + return [] + if len(values) >= 8: + return values + return _simulate_time_series(total, SIMULATED_TIME_BUCKETS, seed=seed) + + +def _simulate_time_series(total, buckets, *, seed): + if total <= 0 or buckets <= 0: + return [] + spike_a = (seed * 3 + 5) % buckets + spike_b = (seed * 5 + 11) % buckets + weights = [] + for hour in range(buckets): + workday = 1.0 if 8 <= hour <= 22 else 0.34 + wave = 1.0 + 0.46 * math.sin((hour + seed) * 0.68) + 0.22 * math.sin((hour + seed) * 1.31) + spike = 1.0 + if hour == spike_a: + spike += 1.05 + if hour == spike_b: + spike += 0.72 + if 14 <= hour <= 16: + spike += 0.45 + weights.append(max(0.08, workday * wave * spike)) + + weight_sum = sum(weights) or 1 + exact = [total * weight / weight_sum for weight in weights] + series = [int(value) for value in exact] + remainder = total - sum(series) + order = sorted(range(buckets), key=lambda index: exact[index] - series[index], reverse=True) + for index in order[:remainder]: + series[index] += 1 + return series + + +def _series_labels(length): + if length == SIMULATED_TIME_BUCKETS: + return [f"{hour:02d}:00" for hour in range(SIMULATED_TIME_BUCKETS)] + return [f"B{index + 1:02d}" for index in range(length)] + + +def _build_sources(counter): + total = sum(counter.values()) + rows = [] + for key, label, aliases in SOURCE_DEFS: + count = 0 + for source, value in counter.items(): + if source in aliases or any(alias in source for alias in aliases): + count += value + rows.append( + { + "key": key, + "label": label, + "value": count, + "rate": _ratio(count, total), + "active": count > 0, + } + ) + + known = sum(item["value"] for item in rows) + unknown = max(total - known, 0) + if unknown: + rows.append({"key": "unknown", "label": "未归类来源", "value": unknown, "rate": _ratio(unknown, total), "active": True}) + return rows + + +def _build_closed_loop(triage): + total = triage["totalRecords"] + auto_closed = triage["attackFailed"] + triage["benign"] + manual = triage["unknown"] + pending = triage["triageFailed"] + triage["unknown"] + resolved = max(total - pending, 0) + return { + "autoClosed": auto_closed, + "resolved": resolved, + "manualDecision": manual, + "pending": pending, + "resolutionRate": _ratio(resolved, total), + } + + +def _build_pipeline(denoise, triage): + raw = denoise.get("workflowCallCount") or denoise["totalRaw"] + unique = denoise["totalUnique"] + triage_total = triage["totalRecords"] + attack_total = triage["attackTotal"] + reused = triage["cacheHit"] + triage["followersReused"] + duplicates = raw - unique + return { + "raw": raw, + "unique": unique, + "triageTotal": triage_total, + "attackTotal": attack_total, + "reductionSaved": duplicates, + "llmSaved": reused, + "uniqueRate": _ratio(unique, raw), + "workloadReuseRate": _ratio(reused, triage_total), + "coverageRate": _ratio(unique, raw), + "attackRate": _ratio(attack_total, triage_total), + "successRate": _ratio(triage["attackSuccess"], attack_total) if attack_total > 0 else 0, + } + + +def _build_attack_profile(denoise, triage): + return [ + _profile_group( + "phase", + "攻击阶段", + _profile_counter(denoise, triage, "phaseCounter"), + 4, + "#9b8cff", + lambda value: PHASE_LABELS.get(value, value), + ), + _profile_group( + "direction", + "流量方向", + _profile_counter(denoise, triage, "directionCounter"), + 4, + "#2be7ff", + lambda value: DIRECTION_LABELS.get(value, value), + ), + _profile_group( + "result", + "结果状态", + _profile_counter(denoise, triage, "resultCounter"), + 4, + "#2ee6a6", + lambda value: RESULT_LABELS.get(value, value), + ), + _profile_group( + "port", + "重点端口", + _profile_counter(denoise, triage, "portCounter"), + 4, + "#ffb020", + _port_label, + ), + ] + + +def _profile_counter(denoise, triage, key): + triage_counter = triage.get(key) or Counter() + if sum(triage_counter.values()) > 0: + return triage_counter + return denoise.get(key) or Counter() + + +def _profile_group(key, label, counter, limit, color, labeler): + total = sum(counter.values()) + return { + "key": key, + "label": label, + "total": total, + "color": color, + "items": [ + { + "key": value, + "label": labeler(value), + "value": count, + "rate": _ratio(count, total), + } + for value, count in counter.most_common(limit) + if value and value != "none" + ], + } + + +def _port_label(value): + if value == "unknown": + return "未知端口" + if str(value).isdigit(): + return f"{value}/TCP" + return str(value) + + +def _iter_source_records(path): + if isinstance(path, _RecordSource) and path.data_source == "sqlite": + yield from _iter_sqlite_records(path) + return + + +def _iter_sqlite_records(source): + settings = _sqlite_settings() + query = ( + f"SELECT {settings['record_column']} AS record_json " + f"FROM {settings['table']} " + f"WHERE {settings['date_column']} = ? " + f"ORDER BY {settings['event_time_column']}, rowid" + ) + try: + with sqlite3.connect(settings["db_path"]) as conn: + rows = conn.execute(query, (source.date,)).fetchall() + except Exception: + return + + for row in rows: + try: + payload = json.loads(row[0]) + except Exception: + yield None + continue + if isinstance(payload, dict): + yield payload + elif isinstance(payload, list): + yield from _iter_json_payload(payload) + else: + yield None + + +def _iter_json_payload(payload): + if isinstance(payload, list): + for item in payload: + yield item if isinstance(item, dict) else None + return + + if not isinstance(payload, dict): + yield None + return + + records = None + for key in ("records", "data", "items", "rows"): + value = payload.get(key) + if isinstance(value, list): + records = value + break + + if records is None: + yield payload + return + + header = { + key: value + for key, value in payload.items() + if key not in {"records", "data", "items", "rows"} + } + if header: + yield {"_type": "file_header", **header} + for item in records: + yield item if isinstance(item, dict) else None + + +def _without_counters(payload): + return { + key: value + for key, value in payload.items() + if not isinstance(value, Counter) + } + + +def _counter_items(counter, limit): + total = sum(counter.values()) + return [ + {"label": label, "value": value, "rate": _ratio(value, total)} + for label, value in counter.most_common(limit) + if label + ] + + +def _file_brief(path): + if isinstance(path, _RecordSource): + stat = path.path.stat() if path.path.exists() else None + return { + "name": f"{path.path.name}:{path.date}", + "path": _display_path(path.path), + "date": path.date, + "role": path.role, + "dataSource": path.data_source, + "recordCount": path.record_count, + "sizeBytes": stat.st_size if stat else 0, + "modifiedAt": datetime.fromtimestamp(stat.st_mtime).isoformat(timespec="seconds") if stat else "", + } + stat = path.stat() + return { + "name": path.name, + "path": _display_path(path), + "bytes": stat.st_size, + "modifiedAt": datetime.fromtimestamp(stat.st_mtime).isoformat(timespec="seconds"), + } + + +def _display_path(path): + try: + return "~/" + str(Path(path).resolve().relative_to(Path.home())).replace("\\", "/") + except Exception: + return str(path) + + +def _source_label(path): + if isinstance(path, _RecordSource): + return f"{_display_path(path.path)}:{path.date}" + return _display_path(path) + + +def _merge_record_time(current_start, current_end, obj): + value = None + for key in ("time", "event_time", "timestamp", "created_at", "occur_time", "start_time"): + value = _parse_event_time(obj.get(key)) + if value: + break + if not value: + return current_start, current_end + if current_start is None or value < current_start: + current_start = value + if current_end is None or value > current_end: + current_end = value + return current_start, current_end + + +def _parse_event_time(value): + if value is None or value == "": + return None + if isinstance(value, datetime): + return value.replace(tzinfo=None) + try: + number = float(value) + if number > 1_000_000_000_000: + number = number / 1000 + if number > 10_000_000: + return datetime.fromtimestamp(number).replace(tzinfo=None) + except Exception: + pass + try: + text = str(value).strip().replace("Z", "+00:00") + return datetime.fromisoformat(text).replace(tzinfo=None) + except Exception: + return None + + +def _format_event_time(value): + if not value: + return "" + return value.isoformat(sep=" ", timespec="seconds") + + +def _format_event_range_label(start, end): + if start.date() == end.date(): + return f"{start:%Y-%m-%d %H:%M} - {end:%H:%M}" + return f"{start:%Y-%m-%d %H:%M} 至 {end:%Y-%m-%d %H:%M}" + + +def _safe_int(value): + try: + if value is None or value == "": + return 0 + return int(value) + except Exception: + return 0 + + +def _ratio(part, total): + part = _safe_int(part) + total = _safe_int(total) + if total <= 0: + return 0 + return round(part / total, 4) + + +def _norm(value): + if value is None: + return "unknown" + text = str(value).strip() + if not text: + return "unknown" + return text.lower() diff --git a/.flocks/flockshub/plugins/webuis/soc_ui/soc_dashboard/api/routes.yaml b/.flocks/flockshub/plugins/webuis/soc_ui/soc_dashboard/api/routes.yaml new file mode 100644 index 000000000..5c0d6b949 --- /dev/null +++ b/.flocks/flockshub/plugins/webuis/soc_ui/soc_dashboard/api/routes.yaml @@ -0,0 +1,6 @@ +routes: + - method: GET + path: /stats + handler: handlers.get_stats + timeoutMs: 30000 + description: Alert denoise and triage dashboard statistics diff --git a/.flocks/flockshub/plugins/webuis/soc_ui/soc_dashboard/manifest.json b/.flocks/flockshub/plugins/webuis/soc_ui/soc_dashboard/manifest.json new file mode 100644 index 000000000..afabbbcb0 --- /dev/null +++ b/.flocks/flockshub/plugins/webuis/soc_ui/soc_dashboard/manifest.json @@ -0,0 +1,12 @@ +{ + "id": "soc-dashboard", + "title": "告警态势", + "titleEn": "Alert Posture", + "route": "/contracts/webui/soc-dashboard", + "icon": "ShieldCheck", + "order": 30, + "enabled": true, + "placement": "home.after", + "entry": "src/index.tsx", + "updatedAt": 1781509788949 +} diff --git a/.flocks/flockshub/plugins/webuis/soc_ui/soc_dashboard/src/Page.tsx b/.flocks/flockshub/plugins/webuis/soc_ui/soc_dashboard/src/Page.tsx new file mode 100644 index 000000000..7a3642b29 --- /dev/null +++ b/.flocks/flockshub/plugins/webuis/soc_ui/soc_dashboard/src/Page.tsx @@ -0,0 +1,1787 @@ +function getSdk() { + const sdk = globalThis.__FLOCKS_WEBUI_CONTRACT_SDK__; + if (!sdk || !sdk.React || !sdk.api) { + throw new Error('Flocks WebUI contract page runtime is not initialized.'); + } + return sdk; +} + +function getReact() { + return getSdk().React; +} + +function getApi() { + return getSdk().api; +} + +const h = (...args) => getReact().createElement(...args); + +const EMPTY_STATS = { + date: '', + dateRange: { start: '', end: '', label: '', availableDates: [], fileDates: [] }, + eventRange: { start: '', end: '', label: '', source: '' }, + generatedAt: '', + latencyMs: 0, + sourceStatus: { workflowRoot: '', denoise: [], triage: [], denoiseFiles: [], triageFiles: [], missing: [] }, + denoise: { totalRaw: 0, totalUnique: 0, duplicates: 0, duplicateRate: 0, uniqueRate: 0, files: 0, parseErrors: 0 }, + triage: { + totalRecords: 0, + newTriaged: 0, + cacheHit: 0, + triageFailed: 0, + followersReused: 0, + attackTotal: 0, + attackSuccess: 0, + attack: 0, + attackFailed: 0, + benign: 0, + unknown: 0, + attackRate: 0, + successRate: 0, + cacheRate: 0, + coverageRate: 0, + files: 0, + parseErrors: 0, + }, + pipeline: { + raw: 0, + unique: 0, + triageTotal: 0, + attackTotal: 0, + reductionSaved: 0, + llmSaved: 0, + uniqueRate: 0, + workloadReuseRate: 0, + coverageRate: 0, + attackRate: 0, + successRate: 0, + }, + sources: [], + closedLoop: { autoClosed: 0, resolved: 0, manualDecision: 0, pending: 0, resolutionRate: 0 }, + verdicts: [], + attackProfile: [], + topThreats: [], + riskLevels: [], + timeline: { denoiseRaw: [], denoiseUnique: [], triageTotal: [], triageAttack: [] }, +}; + +function todayLocal() { + const now = new Date(); + const offset = now.getTimezoneOffset() * 60000; + return new Date(now.getTime() - offset).toISOString().slice(0, 10); +} + +function mergeStats(raw) { + return { + ...EMPTY_STATS, + ...(raw || {}), + sourceStatus: { ...EMPTY_STATS.sourceStatus, ...((raw || {}).sourceStatus || {}) }, + denoise: { ...EMPTY_STATS.denoise, ...((raw || {}).denoise || {}) }, + triage: { ...EMPTY_STATS.triage, ...((raw || {}).triage || {}) }, + pipeline: { ...EMPTY_STATS.pipeline, ...((raw || {}).pipeline || {}) }, + closedLoop: { ...EMPTY_STATS.closedLoop, ...((raw || {}).closedLoop || {}) }, + dateRange: { ...EMPTY_STATS.dateRange, ...((raw || {}).dateRange || {}) }, + eventRange: { ...EMPTY_STATS.eventRange, ...((raw || {}).eventRange || {}) }, + timeline: { ...EMPTY_STATS.timeline, ...((raw || {}).timeline || {}) }, + }; +} + +function fullNumber(value) { + const n = Number(value || 0); + return new Intl.NumberFormat('zh-CN').format(n); +} + +function compactNumber(value) { + const n = Number(value || 0); + if (Math.abs(n) >= 100000000) return `${trim(n / 100000000)}亿`; + if (Math.abs(n) >= 10000) return `${trim(n / 10000)}万`; + return fullNumber(n); +} + +function trim(value) { + return Number(value.toFixed(value >= 100 ? 0 : value >= 10 ? 1 : 2)).toString(); +} + +function pct(value) { + return `${Math.round(Number(value || 0) * 1000) / 10}%`; +} + +function ratio(part, total) { + const denominator = Number(total || 0); + if (denominator <= 0) return 0; + return Number(part || 0) / denominator; +} + +function clamp(value, min = 0, max = 1) { + return Math.max(min, Math.min(max, Number(value || 0))); +} + +function cx(...items) { + return items.filter(Boolean).join(' '); +} + +function Header({ startDate, endDate, setStartDate, setEndDate, stats, loading, refresh, error }) { + const totalSaved = (stats.denoise.duplicates || 0) + (stats.triage.cacheHit || 0) + (stats.triage.followersReused || 0); + const savedHours = trim(totalSaved * 0.018); + const rangeLabel = stats.eventRange?.label || stats.dateRange?.label || (startDate === endDate ? startDate : `${startDate} 至 ${endDate}`); + return h('header', { className: 'adtd-header' }, [ + h('div', { className: 'brand', key: 'brand' }, [ + h('div', { className: 'brand-mark', key: 'mark' }, 'AI'), + h('div', { key: 'copy' }, [ + h('div', { className: 'brand-title', key: 'title' }, 'Flocks 智能告警运营态势'), + h('div', { className: 'brand-sub', key: 'sub' }, '告警降噪与智能研判运营大屏'), + ]), + ]), + h('div', { className: 'header-center', key: 'center' }, [ + h('span', { className: 'chip', key: 'chip-a' }, `累计节省 ${compactNumber(totalSaved)} 条人工处理`), + h('span', { className: 'chip strong', key: 'chip-b' }, `${savedHours} 小时运营时间`), + error ? h('span', { className: 'chip warn', key: 'chip-c' }, '数据刷新异常') : null, + ]), + h('div', { className: 'header-tools', key: 'tools' }, [ + h('div', { className: 'date-range', key: 'range' }, [ + h('input', { + key: 'start', + className: 'date-input', + type: 'date', + value: startDate, + onChange: (event) => { + const next = event.target.value || todayLocal(); + setStartDate(next); + if (endDate < next) setEndDate(next); + }, + }), + h('span', { className: 'range-sep', key: 'sep' }, '至'), + h('input', { + key: 'end', + className: 'date-input', + type: 'date', + value: endDate, + onChange: (event) => { + const next = event.target.value || startDate; + setEndDate(next); + if (next < startDate) setStartDate(next); + }, + }), + ]), + h('button', { key: 'refresh', className: 'icon-button', type: 'button', onClick: refresh, disabled: loading }, loading ? '刷新中' : '刷新'), + h('div', { className: 'clock', key: 'clock' }, [ + h('b', { key: 'time' }, stats.generatedAt ? stats.generatedAt.slice(11, 19) : '--:--:--'), + h('span', { key: 'date-label' }, rangeLabel), + ]), + ]), + ]); +} + +function Metric({ label, value, unit, tone, sub }) { + return h('div', { className: cx('metric', tone && `metric-${tone}`) }, [ + h('span', { className: 'metric-label', key: 'label' }, label), + h('strong', { className: 'metric-value', key: 'value' }, [compactNumber(value), unit ? h('em', { key: 'unit' }, unit) : null]), + sub ? h('span', { className: 'metric-sub', key: 'sub' }, sub) : null, + ]); +} + +function Panel({ title, meta, children, className }) { + return h('section', { className: cx('panel', className) }, [ + h('div', { className: 'panel-head', key: 'head' }, [ + h('div', { className: 'panel-title', key: 'title' }, [h('i', { key: 'dot' }), h('span', { key: 'text' }, title)]), + meta ? h('div', { className: 'panel-meta', key: 'meta' }, meta) : null, + ]), + h('div', { className: 'panel-body', key: 'body' }, children), + ]); +} + +function SourceColumn({ stats }) { + const total = stats.sources.reduce((sum, item) => sum + (item.value || 0), 0); + return h('div', { className: 'column left-col' }, [ + h(Panel, { key: 'sources', title: '多源告警接入', meta: `${stats.denoise.files || 0} 个降噪批次` }, [ + h('div', { className: 'source-list', key: 'list' }, stats.sources.map((item) => { + const width = `${Math.max(4, Math.round((item.rate || 0) * 100))}%`; + return h('div', { className: 'source-row', key: item.key }, [ + h('div', { className: cx('source-node', item.active && 'active'), key: 'node' }), + h('div', { className: 'source-main', key: 'main' }, [ + h('div', { className: 'source-line', key: 'line' }, [ + h('span', { key: 'label' }, item.label), + h('b', { key: 'value' }, compactNumber(item.value)), + ]), + h('div', { className: 'source-track', key: 'track' }, h('span', { style: { width }, key: 'bar' })), + ]), + ]); + })), + h('div', { className: 'source-total', key: 'total' }, [ + h('span', { key: 'label' }, '接入总量'), + h('b', { key: 'value' }, compactNumber(stats.denoise.totalRaw)), + ]), + ]), + h(Panel, { key: 'raw', title: '告警接入趋势', meta: stats.timeline.window || '按批次统计' }, [ + h(Sparkline, { values: stats.timeline.denoiseRaw, color: '#2be7ff', key: 'spark' }), + h('div', { className: 'mini-metrics', key: 'mini' }, [ + h(Metric, { label: '原始告警', value: stats.denoise.totalRaw, tone: 'cyan', key: 'a' }), + h(Metric, { label: '唯一告警', value: stats.denoise.totalUnique, tone: 'green', key: 'b' }), + ]), + ]), + h(Panel, { key: 'efficiency', title: '降噪效率', meta: '压缩收益' }, h('div', { className: 'side-summary' }, [ + h(SummaryTile, { key: 'dup', label: '重复压缩', value: pct(stats.denoise.duplicateRate), sub: `${compactNumber(stats.denoise.duplicates)} 条收敛`, tone: 'green' }), + h(SummaryTile, { key: 'unique', label: '唯一留存', value: pct(stats.denoise.uniqueRate), sub: `${compactNumber(stats.denoise.totalUnique)} 条进入研判`, tone: 'cyan' }), + h(SummaryTile, { key: 'batch', label: '样例批次', value: compactNumber(stats.denoise.files), sub: '资产目录命中', tone: 'violet' }), + ])), + ]); +} + +function CenterColumn({ stats }) { + const radar = [ + { label: '降噪', value: 1 - clamp(stats.denoise.duplicateRate) }, + { label: '复用', value: stats.pipeline.workloadReuseRate }, + { label: '覆盖', value: stats.pipeline.coverageRate }, + { label: '攻击', value: stats.pipeline.attackRate }, + { label: '成功', value: stats.pipeline.successRate }, + ]; + return h('div', { className: 'column center-col' }, [ + h('div', { className: 'ai-stage', key: 'stage' }, [ + h(StageCard, { + key: 'stage1', + stage: '阶段一', + title: '智能降噪', + value: stats.denoise.totalUnique, + sub: `重复压缩 ${pct(stats.denoise.duplicateRate)}`, + tone: 'green', + }), + h(AiCore, { key: 'core', stats }), + h(StageCard, { + key: 'stage2', + stage: '阶段二', + title: '智能研判', + value: stats.triage.totalRecords, + sub: `缓存复用 ${pct(stats.triage.cacheRate)}`, + tone: 'violet', + }), + ]), + h('div', { className: 'flow-strip', key: 'flow' }, [ + h('span', { key: 'a' }, [h('small', { key: 'l' }, '原始告警'), h('b', { key: 'v' }, compactNumber(stats.denoise.totalRaw))]), + h('i', { key: 'arrow-a' }), + h('span', { key: 'b' }, [h('small', { key: 'l' }, '唯一告警'), h('b', { key: 'v' }, compactNumber(stats.denoise.totalUnique))]), + h('i', { key: 'arrow-b' }), + h('span', { key: 'c' }, [h('small', { key: 'l' }, 'AI 研判'), h('b', { key: 'v' }, compactNumber(stats.triage.totalRecords))]), + h('i', { key: 'arrow-c' }), + h('span', { key: 'd', className: 'flow-success', title: '攻击成功' }, [h('small', { key: 'l' }, '攻击成功'), h('b', { key: 'v' }, compactNumber(stats.triage.attackSuccess))]), + ]), + h('div', { className: 'dashboard-grid', key: 'dash' }, [ + h(Panel, { title: '攻击判定环', meta: '判定分布', className: 'panel-donut', key: 'donut' }, h(Donut, { data: stats.verdicts })), + h(Panel, { title: 'AI 能效雷达', meta: '能力评分', className: 'panel-radar', key: 'radar' }, h(Radar, { data: radar })), + h(Panel, { title: '攻击画像', meta: '案例维度', className: 'panel-profile', key: 'profile' }, h(ProfileMatrix, { data: stats.attackProfile })), + ]), + ]); +} + +function RightColumn({ stats }) { + const threatRows = stats.topThreats || []; + const threatRankLabel = threatRows.length ? `Top ${threatRows.length}` : '排行'; + const threatTotal = threatRows.reduce((sum, item) => sum + (item.value || 0), 0); + const threatBase = stats.triage.totalRecords || stats.triage.attackTotal || threatTotal; + const topThreat = threatRows[0] || { value: 0, rate: 0 }; + const tailThreats = Math.max(threatBase - threatTotal, 0); + return h('div', { className: 'column right-col' }, [ + h(Panel, { key: 'loop', title: '处置闭环', meta: pct(stats.closedLoop.resolutionRate) }, [ + h('div', { className: 'loop-diagram', key: 'diagram' }, [ + h('div', { className: 'loop-node primary', key: 'valid' }, [h('b', { key: 'v' }, compactNumber(stats.triage.attackTotal)), h('span', { key: 'l' }, '有效事件')]), + h('div', { className: 'loop-node', key: 'auto' }, [h('b', { key: 'v' }, compactNumber(stats.closedLoop.autoClosed)), h('span', { key: 'l' }, '自动闭环')]), + h('div', { className: 'loop-node warn', key: 'manual' }, [h('b', { key: 'v' }, compactNumber(stats.closedLoop.manualDecision)), h('span', { key: 'l' }, '人工决策')]), + h('div', { className: 'loop-node hot', key: 'pending' }, [h('b', { key: 'v' }, compactNumber(stats.closedLoop.pending)), h('span', { key: 'l' }, '待处理')]), + ]), + h(Gauge, { label: '闭环率', value: stats.closedLoop.resolutionRate, color: '#2ee6a6', key: 'gauge' }), + ]), + h(Panel, { key: 'threats', title: '威胁类型排行', meta: threatRankLabel }, [ + h('div', { className: 'rank-list', key: 'list', style: { '--rank-count': Math.max(threatRows.length, 1) } }, threatRows.length ? threatRows.map((item, index) => h('div', { className: 'rank-row', key: item.label }, [ + h('span', { key: 'idx' }, String(index + 1).padStart(2, '0')), + h('b', { key: 'label', title: item.label }, item.label), + h('em', { key: 'value' }, compactNumber(item.value)), + ])) : h('div', { className: 'empty', key: 'empty' }, '暂无威胁分类数据')), + h('div', { className: 'rank-summary', key: 'summary' }, [ + h('div', { className: 'rank-stat', key: 'cover' }, [h('span', { key: 'l' }, `${threatRankLabel}覆盖`), h('b', { key: 'v' }, pct(ratio(threatTotal, threatBase)))]), + h('div', { className: 'rank-stat', key: 'lead' }, [h('span', { key: 'l' }, '首位占比'), h('b', { key: 'v' }, pct(topThreat.rate || ratio(topThreat.value, threatBase)))]), + h('div', { className: 'rank-stat', key: 'tail' }, [h('span', { key: 'l' }, '长尾余量'), h('b', { key: 'v' }, compactNumber(tailThreats))]), + ]), + ]), + ]); +} + +function SummaryTile({ label, value, sub, tone }) { + return h('div', { className: cx('summary-tile', tone && `summary-${tone}`) }, [ + h('span', { key: 'label' }, label), + h('b', { key: 'value' }, value), + h('small', { key: 'sub' }, sub), + ]); +} + +function StageCard({ stage, title, value, sub, tone }) { + return h('div', { className: cx('stage-card', `stage-${tone}`) }, [ + h('span', { className: 'stage-label', key: 'stage' }, stage), + h('strong', { key: 'title' }, title), + h('b', { key: 'value' }, compactNumber(value)), + h('small', { key: 'sub' }, sub), + ]); +} + +function AiCore({ stats }) { + return h('div', { className: 'ai-core' }, [ + h('div', { className: 'energy-ring ring-a', key: 'ring-a' }), + h('div', { className: 'energy-ring ring-b', key: 'ring-b' }), + h('div', { className: 'scan-line', key: 'scan' }), + h('div', { className: 'orbit orbit-a', key: 'orbit-a' }), + h('div', { className: 'orbit orbit-b', key: 'orbit-b' }), + h('div', { className: 'ai-sphere', key: 'sphere' }, [ + h('span', { key: 'ai' }, 'AI'), + h('small', { key: 'label' }, '智能研判核心'), + ]), + h('div', { className: 'core-particle particle-a', key: 'particle-a' }), + h('div', { className: 'core-particle particle-b', key: 'particle-b' }), + h('div', { className: 'core-particle particle-c', key: 'particle-c' }), + h('div', { className: 'core-numbers', key: 'numbers' }, [ + h('div', { key: 'left' }, [h('b', { key: 'v' }, pct(stats.pipeline.coverageRate)), h('span', { key: 'l' }, '覆盖率')]), + h('div', { key: 'right' }, [h('b', { key: 'v' }, pct(stats.pipeline.successRate)), h('span', { key: 'l' }, '成功率')]), + ]), + ]); +} + +function Sparkline({ values, color }) { + const nums = (values || []).map((v) => Number(v || 0)); + const max = Math.max(...nums, 1); + const points = nums.length + ? nums.map((value, index) => { + const x = nums.length === 1 ? 150 : (index / (nums.length - 1)) * 300; + const y = 88 - (value / max) * 72; + return `${x},${y}`; + }).join(' ') + : '0,88 300,88'; + return h('svg', { className: 'sparkline', viewBox: '0 0 300 96', role: 'img' }, [ + h('path', { key: 'grid', d: 'M0 88 H300 M0 56 H300 M0 24 H300', className: 'spark-grid' }), + h('polyline', { key: 'line', className: 'spark-line', points, fill: 'none', stroke: color || '#2be7ff', strokeWidth: 3, strokeLinecap: 'round', strokeLinejoin: 'round' }), + ]); +} + +function polarPoint(cx, cy, radius, angle) { + const radians = (angle - 90) * Math.PI / 180; + return { + x: cx + radius * Math.cos(radians), + y: cy + radius * Math.sin(radians), + }; +} + +function ringSegmentPath(cx, cy, innerRadius, outerRadius, startAngle, endAngle) { + const outerStart = polarPoint(cx, cy, outerRadius, startAngle); + const outerEnd = polarPoint(cx, cy, outerRadius, endAngle); + const innerEnd = polarPoint(cx, cy, innerRadius, endAngle); + const innerStart = polarPoint(cx, cy, innerRadius, startAngle); + const largeArc = endAngle - startAngle > 180 ? 1 : 0; + return [ + `M ${outerStart.x} ${outerStart.y}`, + `A ${outerRadius} ${outerRadius} 0 ${largeArc} 1 ${outerEnd.x} ${outerEnd.y}`, + `L ${innerEnd.x} ${innerEnd.y}`, + `A ${innerRadius} ${innerRadius} 0 ${largeArc} 0 ${innerStart.x} ${innerStart.y}`, + 'Z', + ].join(' '); +} + +function Donut({ data }) { + const { useState } = getReact(); + const rows = (data || []).filter((item) => item.value > 0); + const total = rows.reduce((sum, item) => sum + item.value, 0); + const [activeKey, setActiveKey] = useState(''); + const active = rows.find((item) => item.key === activeKey) || rows[0] || null; + const activeRate = active && total ? active.value / total : 0; + const centerX = 60; + const centerY = 60; + const innerRadius = 32; + const outerRadius = 50; + let cursor = 0; + const arcs = rows.map((item) => { + const share = total ? item.value / total : 0; + const start = cursor; + const end = cursor + share * 360; + const gap = rows.length > 1 ? Math.min(2.4, (end - start) * 0.18) : 0; + const arcStart = start + gap / 2; + const arcEnd = Math.max(arcStart + 0.1, end - gap / 2); + const mid = (arcStart + arcEnd) / 2; + const label = polarPoint(centerX, centerY, 56, mid); + const lineStart = polarPoint(centerX, centerY, outerRadius + 1, mid); + const lineEnd = polarPoint(centerX, centerY, 53, mid); + const selected = active?.key === item.key; + cursor = end; + return h('g', { key: item.key, className: 'donut-segment' }, [ + h('path', { + key: 'arc', + className: cx('donut-arc', selected && 'active'), + d: ringSegmentPath(centerX, centerY, innerRadius, outerRadius, arcStart, arcEnd), + fill: item.color, + role: 'button', + tabIndex: 0, + 'aria-label': `${item.label} ${compactNumber(item.value)},占比 ${pct(share)}`, + onClick: () => setActiveKey(item.key), + onMouseEnter: () => setActiveKey(item.key), + onFocus: () => setActiveKey(item.key), + onKeyDown: (event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + setActiveKey(item.key); + } + }, + }), + share >= 0.04 ? h('line', { + key: 'line', + className: 'donut-label-line', + x1: lineStart.x, + y1: lineStart.y, + x2: lineEnd.x, + y2: lineEnd.y, + stroke: item.color, + }) : null, + share >= 0.04 ? h('text', { + key: 'rate', + className: cx('donut-percent', selected && 'active'), + x: label.x, + y: label.y, + textAnchor: label.x >= centerX ? 'start' : 'end', + dominantBaseline: 'middle', + fill: item.color, + }, pct(share)) : null, + ]); + }); + const emptyArc = total ? null : h('circle', { + key: 'empty', + className: 'donut-empty', + cx: centerX, + cy: centerY, + r: 42, + fill: 'none', + stroke: 'rgba(130,170,210,.16)', + strokeWidth: 12, + strokeLinecap: 'round', + }); + return h('div', { className: 'donut-wrap' }, [ + h('svg', { viewBox: '-8 -8 136 136', className: 'donut', key: 'svg' }, [ + h('circle', { key: 'base', cx: centerX, cy: centerY, r: 41, fill: 'none', stroke: 'rgba(130,170,210,.14)', strokeWidth: 18 }), + ...arcs, + emptyArc, + h('text', { key: 'text-a', x: 60, y: 53, textAnchor: 'middle', className: 'donut-number' }, active ? compactNumber(active.value) : compactNumber(total)), + h('text', { key: 'text-b', x: 60, y: 70, textAnchor: 'middle', className: 'donut-label' }, active ? active.label : '研判结果'), + h('text', { key: 'text-c', x: 60, y: 86, textAnchor: 'middle', className: 'donut-rate' }, active ? pct(activeRate) : pct(total ? 1 : 0)), + ]), + h('div', { className: 'legend', key: 'legend' }, (data || []).map((item) => { + const selected = active?.key === item.key; + return h('div', { + className: cx('legend-item', selected && 'active'), + key: item.key, + role: 'button', + tabIndex: 0, + onClick: () => setActiveKey(item.key), + onKeyDown: (event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + setActiveKey(item.key); + } + }, + }, [ + h('i', { style: { background: item.color }, key: 'dot' }), + h('span', { key: 'label' }, item.label), + h('b', { key: 'value' }, compactNumber(item.value)), + h('em', { key: 'rate' }, pct(total ? item.value / total : 0)), + ]); + })), + ]); +} + +function Radar({ data }) { + const centerX = 76; + const centerY = 84; + const radius = 48; + const labelRadius = radius + 15; + const ringPoints = (scale) => data.map((item, index) => { + const angle = (-90 + index * (360 / data.length)) * Math.PI / 180; + const r = radius * scale; + return `${centerX + Math.cos(angle) * r},${centerY + Math.sin(angle) * r}`; + }).join(' '); + const points = data.map((item, index) => { + const angle = (-90 + index * (360 / data.length)) * Math.PI / 180; + const value = clamp(item.value); + return `${centerX + Math.cos(angle) * radius * value},${centerY + Math.sin(angle) * radius * value}`; + }).join(' '); + const spokes = data.map((item, index) => { + const angle = (-90 + index * (360 / data.length)) * Math.PI / 180; + const x = centerX + Math.cos(angle) * radius; + const y = centerY + Math.sin(angle) * radius; + const lx = centerX + Math.cos(angle) * labelRadius; + const ly = centerY + Math.sin(angle) * labelRadius; + return h('g', { key: item.label }, [ + h('line', { key: 'line', x1: centerX, y1: centerY, x2: x, y2: y, className: 'radar-line' }), + h('text', { key: 'text', x: lx, y: ly, textAnchor: 'middle', dominantBaseline: 'middle', className: 'radar-text' }, item.label), + ]); + }); + return h('svg', { viewBox: '0 0 152 168', className: 'radar', role: 'img' }, [ + h('polygon', { key: 'grid1', points: ringPoints(1), className: 'radar-grid' }), + h('polygon', { key: 'grid2', points: ringPoints(0.58), className: 'radar-grid' }), + ...spokes, + h('polygon', { key: 'value', points, className: 'radar-value' }), + ]); +} + +function ProfileMatrix({ data }) { + const groups = (data || []).filter((group) => group && (group.items || []).length); + if (!groups.length) { + return h('div', { className: 'empty' }, '暂无攻击画像数据'); + } + return h('div', { className: 'profile-grid' }, groups.map((group) => { + const color = group.color || '#2be7ff'; + return h('div', { className: 'profile-group', key: group.key, style: { '--profile-color': color } }, [ + h('div', { className: 'profile-head', key: 'head' }, [ + h('span', { key: 'label' }, group.label), + h('b', { key: 'total' }, compactNumber(group.total)), + ]), + h('div', { className: 'profile-items', key: 'items' }, group.items.map((item) => h('div', { className: 'profile-row', key: item.key }, [ + h('div', { className: 'profile-line', key: 'line' }, [ + h('span', { key: 'label', title: item.label }, item.label), + h('b', { key: 'value' }, `${compactNumber(item.value)} · ${pct(item.rate)}`), + ]), + h('div', { className: 'profile-track', key: 'track' }, h('span', { style: { width: `${Math.max(5, Math.round(clamp(item.rate) * 100))}%` }, key: 'bar' })), + ]))), + ]); + })); +} + +function Funnel({ data }) { + const max = Math.max(...(data || []).map((item) => item.value || 0), 1); + return h('div', { className: 'funnel' }, (data || []).map((item) => { + const width = `${Math.max(16, Math.round((item.value / max) * 100))}%`; + return h('div', { className: 'funnel-row', key: item.label }, [ + h('span', { key: 'label' }, item.label), + h('div', { className: 'funnel-bar', style: { width, borderColor: item.color, background: `linear-gradient(90deg, ${item.color}33, ${item.color}aa)` }, key: 'bar' }), + h('b', { key: 'value' }, compactNumber(item.value)), + ]); + })); +} + +function Gauge({ label, value, color }) { + const radius = 42; + const circumference = Math.PI * radius; + const dash = clamp(value) * circumference; + return h('div', { className: 'gauge-wrap' }, [ + h('svg', { viewBox: '0 0 120 72', className: 'gauge', key: 'svg' }, [ + h('path', { key: 'base', d: 'M18 60 A42 42 0 0 1 102 60', fill: 'none', stroke: 'rgba(130,170,210,.16)', strokeWidth: 12, strokeLinecap: 'round' }), + h('path', { + key: 'value', + className: 'gauge-value', + d: 'M18 60 A42 42 0 0 1 102 60', + fill: 'none', + stroke: color || '#2ee6a6', + strokeWidth: 12, + strokeLinecap: 'round', + strokeDasharray: `${dash} ${circumference - dash}`, + }), + ]), + h('div', { className: 'gauge-label', key: 'label' }, [h('b', { key: 'v' }, pct(value)), h('span', { key: 'l' }, label)]), + ]); +} + +export default function Page() { + const { useCallback, useEffect, useState } = getReact(); + const [startDate, setStartDate] = useState(todayLocal()); + const [endDate, setEndDate] = useState(todayLocal()); + const [stats, setStats] = useState(EMPTY_STATS); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + + const refresh = useCallback(async () => { + setLoading(true); + try { + const response = await getApi().page.get('/stats', { params: { startDate, endDate } }); + setStats(mergeStats(response.data)); + setError(''); + } catch (err) { + setError(err instanceof Error ? err.message : 'stats api failed'); + } finally { + setLoading(false); + } + }, [startDate, endDate]); + + useEffect(() => { + void refresh(); + const id = window.setInterval(() => void refresh(), 30000); + return () => window.clearInterval(id); + }, [refresh]); + + return h('div', { className: 'adtd-root' }, [ + h('style', { key: 'style' }, CSS), + h(Header, { key: 'header', startDate, endDate, setStartDate, setEndDate, stats, loading, refresh, error }), + error ? h('div', { className: 'error-banner', key: 'error' }, `统计接口异常:${error}`) : null, + h('main', { className: 'screen-grid', key: 'main' }, [ + h(SourceColumn, { key: 'left', stats }), + h(CenterColumn, { key: 'center', stats }), + h(RightColumn, { key: 'right', stats }), + ]), + ]); +} + +const CSS = ` +.adtd-root { + box-sizing: border-box; + min-height: 100vh; + min-width: 1100px; + margin: 0; + padding: 0 16px; + color: #d9f7ff; + background: + linear-gradient(90deg, rgba(43,231,255,.08) 1px, transparent 1px), + linear-gradient(0deg, rgba(43,231,255,.06) 1px, transparent 1px), + linear-gradient(145deg, #05111f 0%, #07182a 38%, #150f26 100%); + background-size: 34px 34px, 34px 34px, auto; + font-family: Inter, "Microsoft YaHei", "PingFang SC", Arial, sans-serif; + overflow-x: auto; +} +.adtd-root * { box-sizing: border-box; } +.adtd-header { + position: relative; + display: grid; + grid-template-columns: minmax(260px, 360px) 1fr minmax(500px, 620px); + align-items: center; + gap: 12px; + min-height: 62px; + padding: 10px 14px; + border: 1px solid rgba(43,231,255,.38); + border-radius: 8px; + background: linear-gradient(180deg, rgba(7, 25, 42, .94), rgba(4, 12, 24, .86)); + box-shadow: 0 0 32px rgba(43,231,255,.12), inset 0 0 24px rgba(88,166,255,.08); +} +.adtd-header:before, .adtd-header:after { + content: ""; + position: absolute; + top: -1px; + width: 180px; + height: 2px; + background: linear-gradient(90deg, transparent, #2be7ff, transparent); +} +.adtd-header:before { left: 18px; } +.adtd-header:after { right: 18px; } +.brand { display: flex; align-items: center; gap: 12px; min-width: 0; } +.brand-mark { + position: relative; + width: 48px; + height: 38px; + display: grid; + place-items: center; + overflow: hidden; + border: 1px solid rgba(43,231,255,.55); + border-radius: 8px; + color: #ffd166; + font-weight: 900; + font-size: 18px; + background: linear-gradient(145deg, rgba(43,231,255,.18), rgba(255,177,32,.12)); + box-shadow: inset 0 0 18px rgba(43,231,255,.16); +} +.brand-mark:after { + content: ""; + position: absolute; + inset: -35% auto -35% -70%; + width: 30px; + background: linear-gradient(90deg, transparent, rgba(255,255,255,.5), transparent); + transform: rotate(18deg); + animation: markSweep 4.8s ease-in-out infinite; + pointer-events: none; +} +.brand-title { font-size: 18px; font-weight: 800; color: #f7fdff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.brand-sub { margin-top: 3px; font-size: 12px; color: rgba(170,222,255,.66); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.header-center { display: flex; justify-content: center; flex-wrap: wrap; gap: 8px; min-width: 0; } +.chip { + min-height: 28px; + padding: 5px 12px; + display: inline-flex; + flex: 0 0 auto; + align-items: center; + justify-content: center; + white-space: nowrap; + border: 1px solid rgba(88,166,255,.28); + border-radius: 999px; + background: rgba(8,31,54,.62); + color: #aadeff; + font-size: 12px; +} +.chip.strong { color: #2ee6a6; border-color: rgba(46,230,166,.42); } +.chip.warn { color: #ffb020; border-color: rgba(255,176,32,.45); } +.header-tools { display: flex; justify-content: flex-end; align-items: center; gap: 8px; min-width: 0; } +.date-range { display: flex; align-items: center; gap: 6px; min-width: 0; } +.range-sep { color: rgba(170,222,255,.62); font-size: 12px; white-space: nowrap; } +.date-input, .icon-button { + height: 34px; + border: 1px solid rgba(43,231,255,.34); + border-radius: 6px; + color: #d9f7ff; + background: rgba(6,18,34,.82); + font: inherit; + font-size: 12px; +} +.date-input { width: 136px; padding: 0 8px; color-scheme: dark; } +.icon-button { min-width: 70px; padding: 0 12px; cursor: pointer; } +.icon-button:hover { border-color: rgba(46,230,166,.7); color: #2ee6a6; } +.icon-button:disabled { opacity: .62; cursor: default; } +.clock { display: grid; gap: 1px; min-width: 146px; text-align: right; } +.clock b { font-size: 15px; color: #ffffff; } +.clock span { font-size: 11px; color: rgba(170,222,255,.68); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.screen-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(330px, 380px); + grid-template-areas: + "center right" + "left right"; + gap: 12px; + margin-top: 12px; + align-items: start; +} +.column { display: grid; gap: 12px; align-content: stretch; min-width: 0; height: 100%; } +.left-col { + grid-area: left; + grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-template-rows: none; + height: auto; +} +.left-col .panel { + min-height: 0; +} +.left-col .panel:last-child { + display: flex; + flex-direction: column; +} +.left-col .panel:last-child .panel-body { + flex: 1; + display: grid; +} +.left-col .panel:last-child .side-summary { align-content: stretch; } +.center-col { + grid-area: center; + grid-template-rows: auto auto minmax(0, 1fr); +} +.right-col { + grid-area: right; + grid-template-rows: auto minmax(0, 1fr); + align-self: start; +} +.right-col .panel { + min-height: 0; +} +.right-col .panel:last-child { + display: flex; + flex-direction: column; +} +.right-col .panel:last-child .panel-body { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; +} +.panel { + position: relative; + min-width: 0; + border: 1px solid rgba(43,231,255,.28); + border-radius: 8px; + background: linear-gradient(180deg, rgba(6,22,39,.86), rgba(5,13,28,.78)); + box-shadow: inset 0 0 20px rgba(43,231,255,.06), 0 12px 26px rgba(0,0,0,.2); + overflow: hidden; +} +.panel:before { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + background: linear-gradient(135deg, rgba(43,231,255,.18), transparent 22%, transparent 76%, rgba(155,140,255,.18)); + opacity: .7; +} +.panel-head { + position: relative; + z-index: 1; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 38px; + padding: 10px 12px 6px; + border-bottom: 1px solid rgba(88,166,255,.14); +} +.panel-title { display: flex; align-items: center; gap: 8px; min-width: 0; font-size: 14px; font-weight: 800; color: #eefcff; } +.panel-title span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.panel-title i { + width: 8px; + height: 8px; + border-radius: 50%; + background: #ffd166; + box-shadow: 0 0 10px rgba(255,209,102,.88); + flex: 0 0 auto; + animation: statusPulse 2.2s ease-in-out infinite; +} +.panel-meta { color: rgba(170,222,255,.62); font-size: 12px; white-space: nowrap; } +.panel-body { position: relative; z-index: 1; padding: 12px; } +.source-list { display: grid; gap: 12px; } +.source-row { display: flex; align-items: center; gap: 10px; min-width: 0; } +.source-node { + width: 18px; + height: 18px; + border-radius: 5px; + border: 1px solid rgba(88,166,255,.44); + background: rgba(88,166,255,.12); + flex: 0 0 auto; +} +.source-node.active { + border-color: rgba(46,230,166,.75); + background: rgba(46,230,166,.22); + box-shadow: 0 0 14px rgba(46,230,166,.25); + animation: nodePulse 2.4s ease-in-out infinite; +} +.source-main { min-width: 0; flex: 1; } +.source-line { display: flex; justify-content: space-between; gap: 10px; font-size: 12px; color: rgba(217,247,255,.84); } +.source-line span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.source-line b { color: #ffffff; flex: 0 0 auto; } +.source-track { height: 4px; margin-top: 6px; border-radius: 999px; background: rgba(88,166,255,.14); overflow: hidden; } +.source-track span { + position: relative; + display: block; + height: 100%; + border-radius: inherit; + overflow: hidden; + background: linear-gradient(90deg, #2be7ff, #2ee6a6); +} +.source-track span:after { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient(90deg, transparent, rgba(255,255,255,.72), transparent); + transform: translateX(-120%); + animation: barFlow 2.8s linear infinite; +} +.source-total { + margin-top: 12px; + padding-top: 10px; + border-top: 1px solid rgba(88,166,255,.14); + display: flex; + justify-content: space-between; + color: rgba(170,222,255,.72); +} +.source-total b { color: #2be7ff; font-size: 18px; } +.status-grid { display: grid; gap: 8px; } +.status-item { + display: flex; + align-items: center; + gap: 9px; + min-width: 0; + padding: 8px; + border-radius: 6px; + background: rgba(88,166,255,.08); +} +.status-light { + width: 10px; + height: 10px; + border-radius: 50%; + background: #7f8ca3; + box-shadow: 0 0 0 4px rgba(127,140,163,.1); + flex: 0 0 auto; +} +.status-light.ok { background: #2ee6a6; box-shadow: 0 0 0 4px rgba(46,230,166,.13), 0 0 16px rgba(46,230,166,.55); } +.status-item b { display: block; font-size: 12px; color: #f7fdff; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.status-item small { display: block; margin-top: 2px; color: rgba(170,222,255,.58); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.missing-note, .ok-note { + margin-top: 10px; + padding: 8px 10px; + border-radius: 6px; + font-size: 12px; +} +.missing-note { color: #ffcf7a; background: rgba(255,176,32,.1); border: 1px solid rgba(255,176,32,.24); } +.ok-note { color: #90f6cf; background: rgba(46,230,166,.08); border: 1px solid rgba(46,230,166,.2); } +.sparkline { width: 100%; height: 96px; display: block; } +.left-col .sparkline { + height: 142px; + min-height: 0; + padding: 6px 0; + border-radius: 8px; + background: + radial-gradient(circle at 64% 22%, rgba(43,231,255,.13), transparent 36%), + linear-gradient(180deg, rgba(88,166,255,.05), rgba(43,231,255,.02)); +} +.left-col .mini-metrics { + margin-top: 8px; +} +.spark-grid { stroke: rgba(130,170,210,.13); stroke-width: 1; } +.spark-line { + stroke-dasharray: 520; + stroke-dashoffset: 520; + filter: drop-shadow(0 0 7px rgba(43,231,255,.45)); + animation: sparkTrace 2.3s ease-out forwards, sparkGlow 2.6s ease-in-out 2.3s infinite alternate; +} +.mini-metrics, .quad { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} +.metric { + min-width: 0; + min-height: 78px; + padding: 10px; + border: 1px solid rgba(88,166,255,.18); + border-radius: 8px; + background: rgba(8,26,46,.58); +} +.metric-label, .metric-sub { display: block; color: rgba(170,222,255,.65); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.metric-value { display: block; margin-top: 7px; color: #ffffff; font-size: clamp(20px, 2.1vw, 30px); line-height: 1; overflow-wrap: anywhere; } +.metric-value em { margin-left: 3px; color: rgba(170,222,255,.65); font-size: 12px; font-style: normal; } +.metric-sub { margin-top: 7px; } +.metric-cyan .metric-value { color: #2be7ff; } +.metric-green .metric-value { color: #2ee6a6; } +.metric-violet .metric-value { color: #9b8cff; } +.metric-amber .metric-value { color: #ffb020; } +.metric-red .metric-value { color: #ff4d6d; } +.side-summary { + display: grid; + gap: 8px; +} +.summary-tile { + min-height: 46px; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + grid-template-areas: + "label value" + "sub value"; + align-items: center; + column-gap: 10px; + padding: 8px 10px; + border: 1px solid rgba(88,166,255,.18); + border-radius: 7px; + background: linear-gradient(90deg, rgba(88,166,255,.09), rgba(8,26,46,.42)); +} +.summary-tile span { + grid-area: label; + color: rgba(217,247,255,.78); + font-size: 12px; + font-weight: 700; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.summary-tile b { + grid-area: value; + color: #ffffff; + font-size: 17px; + line-height: 1; + white-space: nowrap; +} +.summary-tile small { + grid-area: sub; + color: rgba(170,222,255,.56); + font-size: 10px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.summary-cyan b { color: #2be7ff; } +.summary-green b { color: #2ee6a6; } +.summary-violet b { color: #9b8cff; } +.summary-amber b { color: #ffb020; } +.summary-red b { color: #ff4d6d; } +.ai-stage { + min-height: clamp(300px, 34vh, 344px); + display: grid; + grid-template-columns: minmax(142px, 190px) minmax(240px, 1fr) minmax(142px, 190px); + align-items: center; + gap: 12px; + padding: 16px; + border: 1px solid rgba(43,231,255,.26); + border-radius: 8px; + background: linear-gradient(180deg, rgba(8,27,50,.72), rgba(4,10,22,.58)); + box-shadow: inset 0 0 50px rgba(43,231,255,.07); + overflow: hidden; +} +.stage-card { + min-height: 144px; + padding: 14px; + display: grid; + align-content: center; + gap: 9px; + border-radius: 8px; + border: 1px solid rgba(88,166,255,.26); + background: linear-gradient(145deg, rgba(7,24,44,.9), rgba(12,18,38,.72)); +} +.stage-label { color: rgba(170,222,255,.62); font-size: 12px; } +.stage-card strong { color: #f7fdff; font-size: 16px; } +.stage-card b { font-size: clamp(28px, 3.4vw, 40px); line-height: 1; overflow-wrap: anywhere; } +.stage-card small { color: rgba(170,222,255,.72); font-size: 12px; } +.stage-green b { color: #2ee6a6; } +.stage-violet b { color: #9b8cff; } +.ai-core { + position: relative; + min-height: clamp(270px, 30vh, 304px); + display: grid; + place-items: center; + isolation: isolate; + overflow: hidden; +} +.ai-core:before { + content: ""; + position: absolute; + width: 210px; + aspect-ratio: 1; + border-radius: 50%; + background: conic-gradient(from 120deg, rgba(43,231,255,0), rgba(43,231,255,.36), rgba(155,140,255,.34), rgba(255,209,102,.22), rgba(43,231,255,0)); + filter: blur(22px); + opacity: .42; + animation: coreAura 10s linear infinite; + z-index: 0; +} +.ai-sphere { + position: relative; + width: clamp(190px, 24vw, 232px); + aspect-ratio: 1; + border-radius: 50%; + display: grid; + place-items: center; + align-content: center; + border: 1px solid rgba(43,231,255,.46); + background: + radial-gradient(circle at 35% 25%, rgba(255,255,255,.25), transparent 18%), + radial-gradient(circle at 65% 70%, rgba(155,140,255,.28), transparent 28%), + radial-gradient(circle, rgba(43,231,255,.34), rgba(20,60,110,.2) 45%, rgba(3,10,22,.74) 72%); + box-shadow: 0 0 58px rgba(43,231,255,.28), inset 0 0 46px rgba(43,231,255,.22); + z-index: 2; + overflow: hidden; + animation: sphereBreathe 4.6s ease-in-out infinite; +} +.ai-sphere:before { + content: ""; + position: absolute; + inset: -18%; + border-radius: 50%; + background: conic-gradient(from 0deg, transparent 0 20%, rgba(43,231,255,.34) 28%, transparent 38% 58%, rgba(155,140,255,.28) 68%, transparent 78% 100%); + opacity: .72; + animation: sphereSpin 8s linear infinite; + z-index: 0; +} +.ai-sphere:after { + content: ""; + position: absolute; + inset: -30% 42%; + width: 38px; + background: linear-gradient(180deg, transparent, rgba(255,255,255,.36), rgba(43,231,255,.22), transparent); + transform: translateX(-180%) rotate(22deg); + animation: sphereScan 3.8s ease-in-out infinite; + z-index: 1; +} +.ai-sphere span { + position: relative; + z-index: 2; + color: #ffd166; + font-size: clamp(52px, 7vw, 78px); + font-weight: 900; + text-shadow: 0 0 22px rgba(255,209,102,.5); +} +.ai-sphere small { + position: relative; + z-index: 2; + margin-top: 4px; + color: rgba(217,247,255,.78); +} +.orbit { + position: absolute; + border: 1px solid rgba(43,231,255,.26); + border-radius: 50%; + z-index: 1; + filter: drop-shadow(0 0 8px rgba(43,231,255,.22)); +} +.orbit:before { + content: ""; + position: absolute; + top: 50%; + left: -4px; + width: 8px; + height: 8px; + border-radius: 50%; + background: #2be7ff; + box-shadow: 0 0 14px rgba(43,231,255,.85), 0 0 28px rgba(43,231,255,.35); +} +.orbit-a { + width: min(286px, 92%); + height: 196px; + transform: rotate(18deg); + animation: orbitA 12s linear infinite; +} +.orbit-b { + width: 204px; + height: min(286px, 96%); + transform: rotate(52deg); + border-color: rgba(155,140,255,.24); + animation: orbitB 15s linear infinite; +} +.energy-ring { + position: absolute; + width: 238px; + aspect-ratio: 1; + border-radius: 50%; + border: 1px solid rgba(43,231,255,.28); + box-shadow: inset 0 0 26px rgba(43,231,255,.08), 0 0 26px rgba(43,231,255,.1); + z-index: 0; + animation: ringPulse 4s ease-out infinite; +} +.ring-b { + width: 286px; + border-color: rgba(155,140,255,.2); + animation-delay: 1.55s; +} +.scan-line { + position: absolute; + width: 2px; + height: 226px; + border-radius: 999px; + background: linear-gradient(180deg, transparent, rgba(43,231,255,.88), transparent); + box-shadow: 0 0 16px rgba(43,231,255,.72); + opacity: .72; + transform: translateX(-140px) rotate(22deg); + z-index: 1; + animation: coreSweep 3.2s ease-in-out infinite; + pointer-events: none; +} +.core-particle { + position: absolute; + width: 6px; + height: 6px; + border-radius: 50%; + background: #ffd166; + box-shadow: 0 0 12px rgba(255,209,102,.85), 0 0 24px rgba(43,231,255,.45); + z-index: 3; + pointer-events: none; +} +.particle-a { + transform: translate(122px, -78px); + animation: particleA 5.8s ease-in-out infinite; +} +.particle-b { + width: 5px; + height: 5px; + background: #2be7ff; + transform: translate(-128px, 64px); + animation: particleB 6.6s ease-in-out infinite; +} +.particle-c { + width: 4px; + height: 4px; + background: #9b8cff; + transform: translate(52px, 132px); + animation: particleC 7.2s ease-in-out infinite; +} +.core-numbers { + position: absolute; + inset: auto 8px 14px; + display: flex; + justify-content: space-between; + pointer-events: none; + z-index: 4; +} +.core-numbers div { + min-width: 78px; + padding: 7px 9px; + border: 1px solid rgba(88,166,255,.22); + border-radius: 8px; + background: rgba(5,14,28,.72); + text-align: center; +} +.core-numbers b { display: block; color: #2be7ff; } +.core-numbers span { font-size: 11px; color: rgba(170,222,255,.62); } +@keyframes coreAura { + from { transform: rotate(0deg) scale(.96); } + 50% { transform: rotate(180deg) scale(1.05); } + to { transform: rotate(360deg) scale(.96); } +} +@keyframes sphereBreathe { + 0%, 100% { + transform: scale(1); + box-shadow: 0 0 58px rgba(43,231,255,.28), inset 0 0 46px rgba(43,231,255,.22); + } + 50% { + transform: scale(1.035); + box-shadow: 0 0 78px rgba(43,231,255,.42), inset 0 0 58px rgba(155,140,255,.28); + } +} +@keyframes sphereSpin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} +@keyframes sphereScan { + 0%, 18% { transform: translateX(-210%) rotate(22deg); opacity: 0; } + 38% { opacity: .55; } + 62% { opacity: .28; } + 84%, 100% { transform: translateX(210%) rotate(22deg); opacity: 0; } +} +@keyframes orbitA { + from { transform: rotate(18deg); } + to { transform: rotate(378deg); } +} +@keyframes orbitB { + from { transform: rotate(52deg); } + to { transform: rotate(-308deg); } +} +@keyframes ringPulse { + 0% { opacity: 0; transform: scale(.68); } + 18% { opacity: .46; } + 76% { opacity: .08; transform: scale(1.18); } + 100% { opacity: 0; transform: scale(1.28); } +} +@keyframes coreSweep { + 0%, 16% { transform: translateX(-154px) rotate(22deg); opacity: 0; } + 36% { opacity: .72; } + 70% { opacity: .3; } + 100% { transform: translateX(154px) rotate(22deg); opacity: 0; } +} +@keyframes particleA { + 0%, 100% { transform: translate(122px, -78px) scale(1); opacity: .82; } + 50% { transform: translate(98px, -104px) scale(1.45); opacity: 1; } +} +@keyframes particleB { + 0%, 100% { transform: translate(-128px, 64px) scale(1); opacity: .72; } + 50% { transform: translate(-102px, 92px) scale(1.35); opacity: 1; } +} +@keyframes particleC { + 0%, 100% { transform: translate(52px, 132px) scale(1); opacity: .62; } + 50% { transform: translate(78px, 104px) scale(1.4); opacity: .95; } +} +@media (prefers-reduced-motion: reduce) { + .ai-core:before, + .ai-sphere, + .ai-sphere:before, + .ai-sphere:after, + .orbit, + .energy-ring, + .scan-line, + .core-particle { + animation: none; + } +} +.flow-strip { + min-height: 54px; + display: grid; + grid-template-columns: minmax(96px, 1fr) 28px minmax(96px, 1fr) 28px minmax(96px, 1fr) 28px minmax(96px, 1fr); + align-items: center; + gap: 6px; + padding: 10px 12px; + border: 1px solid rgba(43,231,255,.24); + border-radius: 8px; + background: rgba(5,15,30,.76); + overflow: hidden; +} +.flow-strip span { + position: relative; + min-width: 0; + padding: 8px 8px; + border-radius: 6px; + background: rgba(88,166,255,.09); + color: #ffffff; + text-align: center; + font-weight: 800; + overflow-wrap: anywhere; + box-shadow: inset 0 0 18px rgba(88,166,255,.06); + animation: flowValuePulse 4.5s ease-in-out infinite; +} +.flow-strip .flow-success { + border: 1px solid rgba(255,77,109,.36); + color: #ffebf0; + background: rgba(255,77,109,.11); + box-shadow: inset 0 0 20px rgba(255,77,109,.1), 0 0 14px rgba(255,77,109,.12); +} +.flow-strip span small { + display: block; + color: rgba(170,222,255,.64); + font-size: 10px; + font-weight: 700; + line-height: 1.15; + white-space: nowrap; +} +.flow-strip span b { + display: block; + margin-top: 3px; + color: #ffffff; + font-size: 15px; + line-height: 1; +} +.flow-strip .flow-success small { color: rgba(255,210,220,.75); } +.flow-strip .flow-success b { color: #ffffff; } +.flow-strip i { + height: 2px; + overflow: visible; + background: linear-gradient(90deg, rgba(43,231,255,.18), rgba(43,231,255,.9), rgba(46,230,166,.2)); + background-size: 220% 100%; + position: relative; + box-shadow: 0 0 12px rgba(43,231,255,.35); + animation: arrowFlow 1.8s linear infinite; +} +.flow-strip i:nth-of-type(2) { animation-delay: .28s; } +.flow-strip i:nth-of-type(3) { animation-delay: .56s; } +.flow-strip i:before { + content: ""; + position: absolute; + top: -3px; + left: 12%; + width: 8px; + height: 8px; + border-radius: 50%; + background: #2be7ff; + box-shadow: 0 0 16px rgba(43,231,255,.85); + animation: arrowDot 1.8s linear infinite; +} +.flow-strip i:after { + content: ""; + position: absolute; + right: -1px; + top: -4px; + border-left: 7px solid #2be7ff; + border-top: 5px solid transparent; + border-bottom: 5px solid transparent; +} +.dashboard-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + align-items: stretch; +} +.dashboard-grid > .panel { + align-self: stretch; +} +.panel-donut .panel-body, +.panel-radar .panel-body { + min-height: 252px; + display: grid; +} +.panel-donut .panel-body, +.panel-radar .panel-body { align-items: center; } +.panel-donut .panel-body { padding: 10px 12px; } +.panel-radar .panel-body { padding: 8px 12px 10px; justify-items: center; } +.panel-profile { + grid-column: 1 / -1; +} +.panel-profile .panel-body { + min-height: 174px; + display: grid; + padding: 10px; + align-items: stretch; +} +.panel-donut .donut-wrap { + width: 100%; +} +.donut-wrap { + display: grid; + grid-template-columns: minmax(120px, 150px) 1fr; + align-items: center; + gap: 10px; +} +.donut { width: 100%; max-height: 150px; } +.donut-arc { + transform-origin: 60px 60px; + filter: drop-shadow(0 0 4px rgba(43,231,255,.2)); + animation: donutSlicePulse 3.4s ease-in-out infinite; + cursor: pointer; + opacity: .88; + transition: opacity .18s ease, transform .18s ease, filter .18s ease; + outline: none; +} +.donut-arc:hover, +.donut-arc:focus-visible { + opacity: 1; + transform: scale(1.025); +} +.donut-arc.active { + opacity: 1; + transform: scale(1.035); + animation: donutActivePulse 2.2s ease-in-out infinite; +} +.donut-label-line { + stroke-width: 1; + opacity: .72; + pointer-events: none; +} +.donut-percent { + font-size: 7.5px; + font-weight: 900; + pointer-events: none; + paint-order: stroke; + stroke: rgba(3,10,22,.92); + stroke-width: 3px; + stroke-linejoin: round; +} +.donut-percent.active { font-size: 8.5px; } +.donut-empty { opacity: .8; } +.donut-number { fill: #ffffff; font-size: 15px; font-weight: 800; } +.donut-label { fill: rgba(170,222,255,.62); font-size: 10px; } +.donut-rate { fill: #2be7ff; font-size: 10px; font-weight: 800; } +.legend { display: grid; gap: 6px; min-width: 0; } +.legend-item { + display: grid; + grid-template-columns: 8px minmax(0, 1fr) auto auto; + align-items: center; + gap: 6px; + padding: 3px 4px; + border: 1px solid transparent; + border-radius: 5px; + color: rgba(217,247,255,.78); + font-size: 11px; + cursor: pointer; + transition: border-color .18s ease, background .18s ease, box-shadow .18s ease; + outline: none; +} +.legend-item:hover, +.legend-item:focus-visible, +.legend-item.active { + border-color: rgba(43,231,255,.28); + background: rgba(43,231,255,.08); + box-shadow: inset 0 0 12px rgba(43,231,255,.08); +} +.legend-item i { width: 8px; height: 8px; border-radius: 50%; } +.legend-item span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.legend-item b { color: #ffffff; } +.legend-item em { color: #2be7ff; font-style: normal; font-weight: 800; } +.radar { width: min(100%, 190px); height: 176px; display: block; } +.radar-grid { fill: rgba(88,166,255,.05); stroke: rgba(88,166,255,.2); stroke-width: 1; animation: radarGridPulse 4s ease-in-out infinite; } +.radar-line { stroke: rgba(88,166,255,.16); stroke-width: 1; } +.radar-text { fill: rgba(170,222,255,.72); font-size: 9px; } +.radar-value { + fill: rgba(46,230,166,.22); + stroke: #2ee6a6; + stroke-width: 2; + filter: drop-shadow(0 0 7px rgba(46,230,166,.32)); + animation: radarValuePulse 2.8s ease-in-out infinite; +} +.profile-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + grid-template-rows: minmax(0, 1fr); + gap: 10px; + min-height: 0; +} +.profile-group { + min-width: 0; + min-height: 0; + padding: 9px; + border: 1px solid rgba(88,166,255,.18); + border-radius: 8px; + background: rgba(7,23,42,.55); + background: radial-gradient(circle at 12% 0%, color-mix(in srgb, var(--profile-color) 22%, transparent), transparent 42%), rgba(7,23,42,.55); + box-shadow: inset 0 0 18px rgba(88,166,255,.05); +} +.profile-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 7px; + color: rgba(217,247,255,.9); + font-size: 12px; +} +.profile-head span { font-weight: 800; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.profile-head b { color: var(--profile-color); flex: 0 0 auto; } +.profile-items { display: grid; gap: 6px; } +.profile-row { min-width: 0; } +.profile-line { + display: flex; + justify-content: space-between; + gap: 8px; + color: rgba(170,222,255,.74); + font-size: 11px; + line-height: 1.18; +} +.profile-line span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.profile-line b { color: #ffffff; font-size: 11px; flex: 0 0 auto; } +.profile-track { + position: relative; + height: 4px; + margin-top: 3px; + overflow: hidden; + border-radius: 999px; + background: rgba(88,166,255,.12); +} +.profile-track span { + position: relative; + display: block; + height: 100%; + border-radius: inherit; + overflow: hidden; + background: linear-gradient(90deg, rgba(43,231,255,.38), var(--profile-color)); + background: linear-gradient(90deg, color-mix(in srgb, var(--profile-color) 48%, transparent), var(--profile-color)); + box-shadow: 0 0 12px rgba(43,231,255,.28); +} +.profile-track span:after { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient(90deg, transparent, rgba(255,255,255,.72), transparent); + transform: translateX(-120%); + animation: profileFlow 2.6s ease-in-out infinite; +} +.funnel { display: grid; gap: 9px; padding: 6px 0; } +.funnel-row { + display: grid; + grid-template-columns: 68px minmax(70px, 1fr) auto; + align-items: center; + gap: 8px; + color: rgba(217,247,255,.78); + font-size: 12px; +} +.funnel-row span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.funnel-row b { color: #ffffff; font-size: 12px; } +.funnel-bar { + position: relative; + height: 20px; + border: 1px solid; + border-radius: 4px; + justify-self: center; + min-width: 28px; + overflow: hidden; +} +.funnel-bar:after { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient(90deg, transparent, rgba(255,255,255,.38), transparent); + transform: translateX(-120%); + animation: funnelFlow 3s ease-in-out infinite; +} +.loop-diagram { + min-height: 166px; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + position: relative; +} +.loop-diagram:before { + content: ""; + position: absolute; + inset: 50% 18% auto; + height: 1px; + background: linear-gradient(90deg, rgba(43,231,255,.1), rgba(43,231,255,.75), rgba(46,230,166,.1)); + background-size: 220% 100%; + box-shadow: 0 0 12px rgba(43,231,255,.26); + animation: lineFlow 2.8s linear infinite; +} +.loop-node { + min-height: 72px; + display: grid; + place-items: center; + align-content: center; + gap: 4px; + border: 1px solid rgba(88,166,255,.24); + border-radius: 8px; + background: rgba(8,28,50,.62); +} +.loop-node b { color: #ffffff; font-size: 22px; overflow-wrap: anywhere; } +.loop-node span { color: rgba(170,222,255,.7); font-size: 12px; } +.loop-node.primary { border-color: rgba(43,231,255,.45); animation: loopNodePulse 3s ease-in-out infinite; } +.loop-node.primary b { color: #2be7ff; } +.loop-node.warn b { color: #ffb020; } +.loop-node.hot b { color: #ff4d6d; } +.gauge-wrap { position: relative; display: grid; place-items: center; margin-top: 2px; } +.gauge { width: 152px; height: 88px; overflow: visible; } +.gauge-value { filter: drop-shadow(0 0 8px rgba(46,230,166,.45)); animation: gaugePulse 2.7s ease-in-out infinite; } +.gauge-label { position: absolute; top: 36px; display: grid; text-align: center; } +.gauge-label b { color: #2ee6a6; font-size: 20px; } +.gauge-label span { color: rgba(170,222,255,.65); font-size: 12px; } +.rank-list { + flex: 1; + min-height: 0; + display: grid; + grid-template-rows: repeat(var(--rank-count), minmax(30px, 1fr)); + align-content: start; + gap: 4px; +} +.rank-row { + display: grid; + grid-template-columns: 26px minmax(0, 1fr) auto; + align-items: center; + gap: 7px; + min-height: 31px; + padding: 4px 8px; + border-radius: 6px; + background: rgba(88,166,255,.08); +} +.rank-row span { color: #ffd166; font-size: 11px; } +.rank-row b { color: rgba(217,247,255,.88); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.rank-row em { color: #ffffff; font-size: 12px; font-style: normal; } +.rank-summary { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 6px; + margin-top: auto; + padding-top: 8px; + border-top: 1px solid rgba(88,166,255,.14); +} +.rank-stat { + min-width: 0; + min-height: 46px; + display: grid; + align-content: center; + gap: 4px; + padding: 7px 6px; + border: 1px solid rgba(88,166,255,.16); + border-radius: 6px; + background: rgba(8,26,46,.48); + text-align: center; +} +.rank-stat span { + color: rgba(170,222,255,.58); + font-size: 10px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.rank-stat b { + color: #2be7ff; + font-size: 14px; + line-height: 1; +} +.empty { color: rgba(170,222,255,.58); font-size: 12px; padding: 12px; text-align: center; } +.error-banner { + margin-top: 10px; + padding: 9px 12px; + border: 1px solid rgba(255,77,109,.38); + border-radius: 8px; + color: #ffd1dc; + background: rgba(255,77,109,.12); + font-size: 13px; +} +@keyframes markSweep { + 0%, 20% { transform: translateX(0) rotate(18deg); opacity: 0; } + 42% { opacity: .72; } + 72%, 100% { transform: translateX(165px) rotate(18deg); opacity: 0; } +} +@keyframes statusPulse { + 0%, 100% { transform: scale(1); box-shadow: 0 0 10px rgba(255,209,102,.72); } + 50% { transform: scale(1.28); box-shadow: 0 0 16px rgba(255,209,102,1), 0 0 24px rgba(43,231,255,.28); } +} +@keyframes nodePulse { + 0%, 100% { box-shadow: 0 0 12px rgba(46,230,166,.22); } + 50% { box-shadow: 0 0 18px rgba(46,230,166,.48), 0 0 0 4px rgba(46,230,166,.08); } +} +@keyframes barFlow { + from { transform: translateX(-120%); } + to { transform: translateX(120%); } +} +@keyframes sparkTrace { + to { stroke-dashoffset: 0; } +} +@keyframes sparkGlow { + from { filter: drop-shadow(0 0 5px rgba(43,231,255,.32)); opacity: .84; } + to { filter: drop-shadow(0 0 12px rgba(43,231,255,.72)); opacity: 1; } +} +@keyframes flowValuePulse { + 0%, 100% { box-shadow: inset 0 0 18px rgba(88,166,255,.06); } + 50% { box-shadow: inset 0 0 24px rgba(43,231,255,.14), 0 0 12px rgba(43,231,255,.1); } +} +@keyframes arrowFlow { + from { background-position: 180% 0; } + to { background-position: -40% 0; } +} +@keyframes arrowFlowDown { + from { background-position: 0 180%; } + to { background-position: 0 -40%; } +} +@keyframes arrowDot { + from { left: 0; opacity: 0; transform: scale(.72); } + 20% { opacity: .95; transform: scale(1); } + 80% { opacity: .95; transform: scale(1); } + to { left: calc(100% - 4px); opacity: 0; transform: scale(.72); } +} +@keyframes arrowDotDown { + from { top: 0; opacity: 0; transform: scale(.72); } + 20% { opacity: .95; transform: scale(1); } + 80% { opacity: .95; transform: scale(1); } + to { top: calc(100% - 4px); opacity: 0; transform: scale(.72); } +} +@keyframes donutSlicePulse { + 0%, 100% { opacity: .82; filter: drop-shadow(0 0 3px rgba(43,231,255,.18)); } + 50% { opacity: 1; filter: drop-shadow(0 0 9px rgba(43,231,255,.42)); } +} +@keyframes donutActivePulse { + 0%, 100% { opacity: .95; filter: drop-shadow(0 0 8px rgba(43,231,255,.42)); } + 50% { opacity: 1; filter: drop-shadow(0 0 15px rgba(43,231,255,.72)); } +} +@keyframes radarGridPulse { + 0%, 100% { opacity: .72; } + 50% { opacity: 1; } +} +@keyframes radarValuePulse { + 0%, 100% { opacity: .78; } + 50% { opacity: 1; } +} +@keyframes funnelFlow { + 0%, 22% { transform: translateX(-120%); opacity: 0; } + 48% { opacity: .82; } + 100% { transform: translateX(120%); opacity: 0; } +} +@keyframes profileFlow { + 0%, 22% { transform: translateX(-120%); opacity: 0; } + 45% { opacity: .74; } + 100% { transform: translateX(120%); opacity: 0; } +} +@keyframes lineFlow { + from { background-position: 180% 0; } + to { background-position: -40% 0; } +} +@keyframes loopNodePulse { + 0%, 100% { box-shadow: 0 0 0 rgba(43,231,255,0); } + 50% { box-shadow: 0 0 22px rgba(43,231,255,.18); } +} +@keyframes gaugePulse { + 0%, 100% { opacity: .82; filter: drop-shadow(0 0 7px rgba(46,230,166,.34)); } + 50% { opacity: 1; filter: drop-shadow(0 0 14px rgba(46,230,166,.68)); } +} +@media (prefers-reduced-motion: reduce) { + .brand-mark:after, + .panel-title i, + .source-node.active, + .source-track span:after, + .spark-line, + .flow-strip span, + .flow-strip i, + .flow-strip i:before, + .donut-arc, + .donut-arc.active, + .radar-grid, + .radar-value, + .profile-track span:after, + .funnel-bar:after, + .loop-diagram:before, + .loop-node.primary, + .gauge-value { + animation: none; + } +} +@media (max-width: 1280px) { + .screen-grid { + grid-template-columns: minmax(0, 1fr) minmax(315px, 350px); + grid-template-areas: + "center right" + "left left"; + } + .right-col { height: auto; align-content: start; } + .left-col { grid-template-columns: repeat(3, minmax(0, 1fr)); } + .profile-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); } +} +@media (max-width: 980px) { + .adtd-root { margin: 0; padding: 0 10px; } + .adtd-header, .screen-grid, .dashboard-grid, .right-col, .profile-grid { grid-template-columns: 1fr; } + .screen-grid { grid-template-areas: "center" "right" "left"; } + .left-col { grid-template-columns: 1fr; } + .header-center, .header-tools { justify-content: flex-start; } + .header-tools, .date-range { flex-wrap: wrap; } + .date-input { width: min(150px, 100%); } + .ai-stage { grid-template-columns: 1fr; } + .flow-strip { grid-template-columns: 1fr; } + .flow-strip i { + height: 18px; + width: 2px; + justify-self: center; + background: linear-gradient(180deg, rgba(43,231,255,.18), rgba(43,231,255,.9), rgba(46,230,166,.2)); + background-size: 100% 220%; + animation-name: arrowFlowDown; + } + .flow-strip i:before { top: 0; left: -3px; animation-name: arrowDotDown; } + .flow-strip i:after { right: -4px; top: auto; bottom: -1px; border-left: 5px solid transparent; border-right: 5px solid transparent; border-top: 7px solid #2be7ff; border-bottom: 0; } + .donut-wrap { grid-template-columns: 1fr; } + .right-col .panel:last-child { display: block; } + .right-col .panel:last-child .panel-body { display: block; } +} +`; diff --git a/.flocks/flockshub/plugins/webuis/soc_ui/soc_dashboard/src/index.tsx b/.flocks/flockshub/plugins/webuis/soc_ui/soc_dashboard/src/index.tsx new file mode 100644 index 000000000..12c3418bb --- /dev/null +++ b/.flocks/flockshub/plugins/webuis/soc_ui/soc_dashboard/src/index.tsx @@ -0,0 +1,3 @@ +import Page from './Page'; + +export default Page; diff --git a/.flocks/flockshub/plugins/webuis/soc_ui/soc_overview/api/handlers.py b/.flocks/flockshub/plugins/webuis/soc_ui/soc_overview/api/handlers.py new file mode 100644 index 000000000..f9c70edcc --- /dev/null +++ b/.flocks/flockshub/plugins/webuis/soc_ui/soc_overview/api/handlers.py @@ -0,0 +1,1290 @@ +import json +import math +import os +import re +import sqlite3 +import time +from collections import Counter +from dataclasses import dataclass +from datetime import datetime, timedelta +from pathlib import Path + + +DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") +SQL_IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") +CONTRACTS_ROOT = Path(__file__).resolve().parents[4] +ACCESS_CONFIG_PATH = CONTRACTS_ROOT / "access" / "soc_alerts.json" +DEFAULT_DATA_SOURCE = "sqlite" +DEFAULT_SQLITE_DB = Path.home() / ".flocks" / "data" / "soc.db" +DEFAULT_SQLITE_TABLE = "alert_records" +DEFAULT_SQLITE_RECORD_COLUMN = "record_json" +DEFAULT_SQLITE_DATE_COLUMN = "asset_date" +DEFAULT_SQLITE_EVENT_TIME_COLUMN = "event_time" +SIMULATED_TIME_BUCKETS = 24 + +WORKFLOW_DB = Path.home() / ".flocks" / "data" / "workflow.db" + +_workflow_stats_cache: dict = {} +_cache_updated_at: float = 0 +_CACHE_TTL: float = 30.0 + + +@dataclass(frozen=True) +class _RecordSource: + path: Path + role: str + date: str + data_source: str + record_count: int = 0 + start_time: int | None = None + end_time: int | None = None + + +def _get_workflow_call_count(workflow_name: str, date: str = None) -> int: + global _workflow_stats_cache, _cache_updated_at + now = time.time() + cache_key = f"{workflow_name}:{date or 'total'}" + + if now - _cache_updated_at < _CACHE_TTL and cache_key in _workflow_stats_cache: + return _workflow_stats_cache[cache_key] + + empty = {"callCount": 0, "dupCount": 0, "uniqueCount": 0} + if not WORKFLOW_DB.is_file(): + return empty + + try: + if date: + start = int(datetime.strptime(date, "%Y-%m-%d").timestamp() * 1000) + end = start + 86400 * 1000 + with sqlite3.connect(WORKFLOW_DB) as conn: + rows = conn.execute( + """ + SELECT output_results + FROM workflow_executions + WHERE workflow_id = ? AND started_at >= ? AND started_at < ? + """, + (workflow_name, start, end), + ).fetchall() + dup_count = 0 + unique_count = 0 + for (output_text,) in rows: + try: + output = json.loads(output_text or "{}") + except Exception: + output = {} + stats = output.get("stats") if isinstance(output.get("stats"), dict) else {} + raw = _safe_int(stats.get("raw_count")) + if "after_dedup_count" in stats and stats.get("after_dedup_count") is not None: + unique_count += _safe_int(stats.get("after_dedup_count")) + else: + unique_count += raw + if output.get("is_duplicate") is True: + dup_count += 1 + result_dict = { + "callCount": len(rows), + "dupCount": dup_count, + "uniqueCount": unique_count, + } + else: + with sqlite3.connect(WORKFLOW_DB) as conn: + row = conn.execute( + "SELECT call_count FROM workflow_stats WHERE workflow_id = ?", + (workflow_name,), + ).fetchone() + result_dict = {"callCount": _safe_int(row[0] if row else 0), "dupCount": 0, "uniqueCount": 0} + + _workflow_stats_cache[cache_key] = result_dict + _cache_updated_at = now + return result_dict + except Exception: + return _workflow_stats_cache.get(cache_key, empty) + + +SOURCE_DEFS = [ + ("ndr", "NDR 网络流量", ("ndr", "tdp", "network")), + ("edr", "EDR 主机告警", ("edr", "hids", "linux")), + ("waf", "WAF Web 防护", ("waf", "web")), + ("ids", "IDS/IPS 入侵检测", ("ids", "ips", "skyeye")), + ("cloud", "云日志", ("cloud", "aliyun", "qcloud")), + ("vuln", "漏洞情报", ("vuln", "cve", "qingteng")), + ("other", "其他接入", ("other", "unknown", "none")), +] + +PHASE_LABELS = { + "recon": "侦察探测", + "exploit": "漏洞利用", + "post_exploit": "后渗透", + "control": "控制通信", + "unknown": "未知阶段", +} + +DIRECTION_LABELS = { + "in": "入站", + "out": "出站", + "lateral": "横向", + "unknown": "未知方向", +} + +RESULT_LABELS = { + "success": "攻击成功", + "succeeded": "攻击成功", + "failed": "攻击失败", + "blocked": "已阻断", + "attack_success": "攻击成功", + "attack": "攻击行为", + "attack_failed": "攻击失败", + "benign": "良性", + "unknown": "待确认", +} + + +def get_stats(ctx, request): + start_time, end_time = _normalize_time_range( + request.query_params.get("startTime"), + request.query_params.get("endTime"), + ) + date = _normalize_date( + request.query_params.get("date") + or _date_from_epoch(end_time) + or _latest_asset_date() + ) + if start_time is not None and end_time is not None: + start_date = _date_from_epoch(start_time) or date + end_date = _date_from_epoch(end_time) or date + if start_date > end_date: + start_date, end_date = end_date, start_date + else: + start_date, end_date = _normalize_range( + request.query_params.get("startDate"), + request.query_params.get("endDate"), + date, + ) + started = time.time() + + denoise_files, denoise_locations = [], [] + triage_files, triage_locations = [], [] + + asset_files = _find_asset_files(start_date, end_date, start_time, end_time) + asset_denoise_files = [path for path in asset_files if _asset_file_role(path) == "denoise"] + asset_triage_files = [path for path in asset_files if _asset_file_role(path) == "triage"] + sample_mode = bool(asset_denoise_files or asset_triage_files) + if asset_denoise_files: + denoise_files = asset_denoise_files + if asset_triage_files: + triage_files = asset_triage_files + + workflow_stats = _get_workflow_call_count("stream_alert_denoise", date=start_date) + denoise = _read_denoise(denoise_files) + triage = _read_triage(triage_files) + if triage["totalRecords"] == 0 and denoise["totalRaw"] > 0: + triage = _simulate_triage_from_denoise(denoise_files) + sources = _build_sources(denoise["sourceCounter"] or triage["sourceCounter"]) + closed_loop = _build_closed_loop(triage) + pipeline = _build_pipeline(denoise, triage) + date_range = _build_date_range(start_date, end_date, asset_files) + event_range = _build_event_range(date_range, denoise, triage) + + return { + "date": start_date, + "dateRange": date_range, + "eventRange": event_range, + "generatedAt": datetime.now().isoformat(timespec="seconds"), + "latencyMs": round((time.time() - started) * 1000), + "sourceStatus": { + "workflowStatsDb": _display_path(WORKFLOW_DB), + "workflowStats": workflow_stats, + "sampleMode": sample_mode, + "sampleFile": ", ".join(_source_label(path) for path in asset_files) if sample_mode else "", + "assets": { + "path": _display_path(_active_source_path()), + "exists": _active_source_exists(), + "dataSource": _active_data_source(), + "config": _display_path(ACCESS_CONFIG_PATH), + "fileCount": len(asset_files), + "availableDates": _available_asset_dates(), + "selectedDates": date_range["fileDates"], + }, + "assetFiles": [_file_brief(path) for path in asset_files], + "denoise": denoise_locations, + "triage": triage_locations, + "denoiseFiles": [_file_brief(path) for path in denoise_files], + "triageFiles": [_file_brief(path) for path in triage_files], + "missing": [] if sample_mode else [ + item + for item in denoise_locations + triage_locations + if not item["exists"] or item["fileCount"] == 0 + ], + }, + "denoise": _without_counters(denoise), + "triage": _without_counters(triage), + "pipeline": pipeline, + "sources": sources, + "closedLoop": closed_loop, + "fieldStats": _build_field_stats(denoise_files), + "attackProfile": _build_attack_profile(denoise, triage), + "verdicts": [ + {"key": "attack_success", "label": "攻击成功", "value": triage["attackSuccess"], "color": "#ff4d6d"}, + {"key": "attack", "label": "攻击行为", "value": triage["attack"], "color": "#ffb020"}, + {"key": "attack_failed", "label": "攻击失败", "value": triage["attackFailed"], "color": "#2ee6a6"}, + {"key": "benign", "label": "良性", "value": triage["benign"], "color": "#58a6ff"}, + {"key": "unknown", "label": "未知", "value": triage["unknown"], "color": "#9b8cff"}, + ], + "topThreats": _counter_items(triage["threatCounter"] or denoise["threatCounter"], 14), + "riskLevels": _counter_items(triage["riskCounter"], 5), + "timeline": { + "labels": _series_labels(max(len(denoise["seriesRaw"]), len(triage["seriesTotal"]))), + "window": _timeline_window(start_date, end_date, len(denoise["seriesRaw"])), + "denoiseRaw": denoise["seriesRaw"], + "denoiseUnique": denoise["seriesUnique"], + "triageTotal": triage["seriesTotal"], + "triageAttack": triage["seriesAttack"], + }, + } + + +def _simulate_triage_from_denoise(paths): + total_records = 0 + parse_errors = 0 + source_counter = Counter() + threat_counter = Counter() + risk_counter = Counter() + verdict_counter = Counter() + profile_counters = _new_profile_counters() + event_start = None + event_end = None + series_total = [] + series_attack = [] + + for path in paths: + file_total = 0 + file_attack = 0 + for obj in _iter_source_records(path): + if obj is None: + parse_errors += 1 + continue + if obj.get("_type") == "file_header" or obj.get("is_duplicate") is True: + continue + total_records += 1 + file_total += 1 + verdict = _dedup_record_verdict(obj) + verdict_counter[verdict] += 1 + if verdict in {"attack_success", "attack", "attack_failed"}: + file_attack += 1 + source_counter[_norm(obj.get("_source_type") or obj.get("source_type") or obj.get("device_type"))] += 1 + threat_counter[_norm(obj.get("_threat_type") or obj.get("threat_name") or obj.get("threat_type"))] += 1 + risk_counter[_norm(obj.get("threat_level") or obj.get("threat_severity") or obj.get("risk_level"))] += 1 + _update_profile_counters(obj, profile_counters) + event_start, event_end = _merge_record_time(event_start, event_end, obj) + series_total.append(file_total) + series_attack.append(file_attack) + + attack_success = verdict_counter["attack_success"] + attack = verdict_counter["attack"] + attack_failed = verdict_counter["attack_failed"] + attack_total = attack_success + attack + attack_failed + benign = verdict_counter["benign"] + unknown = verdict_counter["unknown"] + new_triaged = round(total_records * 0.22) + cache_hit = round(total_records * 0.68) + followers_reused = max(total_records - new_triaged - cache_hit, 0) + + series_total = _expand_series(series_total, total_records, seed=17) + series_attack = _expand_series(series_attack, attack_total, seed=19) + + return { + "totalRecords": total_records, + "batchTotal": total_records, + "newTriaged": new_triaged, + "cacheHit": cache_hit, + "triageFailed": 0, + "followersReused": followers_reused, + "attackTotal": attack_total, + "attackSuccess": attack_success, + "attack": attack, + "attackFailed": attack_failed, + "benign": benign, + "unknown": unknown, + "attackRate": _ratio(attack_total, total_records), + "successRate": _ratio(attack_success, attack_total), + "cacheRate": _ratio(cache_hit + followers_reused, total_records), + "coverageRate": _ratio(total_records, total_records), + "headers": 0, + "files": len(paths), + "parseErrors": parse_errors, + "eventStart": _format_event_time(event_start), + "eventEnd": _format_event_time(event_end), + "sourceCounter": source_counter, + "threatCounter": threat_counter, + "riskCounter": risk_counter, + "statusCounter": Counter({"simulated": total_records}), + **profile_counters, + "seriesTotal": series_total, + "seriesAttack": series_attack, + } + + +def _dedup_record_verdict(obj): + threat_level = _norm(obj.get("threat_level")) + threat_result = _norm(obj.get("threat_result")) + status = _safe_int(obj.get("rsp_status_code")) + body_len = _safe_int(obj.get("rsp_body_len")) + + if threat_level in {"benign", "info", "low"}: + return "benign" + if threat_result in {"success", "succeeded"}: + return "attack_success" + if threat_result in {"failed", "blocked"} or status in {401, 403, 404, 405, 406, 410}: + return "attack_failed" + if status == 200 and body_len > 0: + return "attack_success" + if threat_level == "attack" or obj.get("threat_name"): + return "attack" + return "unknown" + + +def _normalize_date(value): + if value and DATE_RE.match(str(value)): + return str(value) + return datetime.now().strftime("%Y-%m-%d") + + +def _normalize_range(start_value, end_value, fallback_date): + start_date = _normalize_date(start_value or fallback_date) + end_date = _normalize_date(end_value or start_date) + if start_date > end_date: + start_date, end_date = end_date, start_date + return start_date, end_date + + +def _normalize_time_range(start_value, end_value): + start_time = _safe_int(start_value) + end_time = _safe_int(end_value) + if start_time <= 0 or end_time <= 0: + return None, None + if start_time > end_time: + start_time, end_time = end_time, start_time + return start_time, end_time + + +def _date_from_epoch(value): + if not value: + return "" + try: + return datetime.fromtimestamp(int(value)).strftime("%Y-%m-%d") + except Exception: + return "" + + +def _date_span(start_date, end_date): + start = datetime.strptime(start_date, "%Y-%m-%d").date() + end = datetime.strptime(end_date, "%Y-%m-%d").date() + current = start + while current <= end: + yield current.strftime("%Y-%m-%d") + current += timedelta(days=1) + + +def _find_asset_files(start_date, end_date, start_time=None, end_time=None): + return _find_sqlite_sources(start_date, end_date, start_time, end_time) + + +def _asset_file_date(path): + if isinstance(path, _RecordSource): + return path.date + return "" + + +def _latest_asset_date(): + dates = _available_asset_dates() + return dates[-1] if dates else datetime.now().strftime("%Y-%m-%d") + + +def _available_asset_dates(): + return _available_sqlite_dates() + + +def _load_alerts_config(): + raw = {} + if ACCESS_CONFIG_PATH.is_file(): + try: + value = json.loads(ACCESS_CONFIG_PATH.read_text(encoding="utf-8")) + except Exception: + value = {} + if isinstance(value, dict): + raw = value + + sqlite_config = raw.get("sqlite") if isinstance(raw.get("sqlite"), dict) else {} + return { + "dataSource": DEFAULT_DATA_SOURCE, + "sqlite": { + "dbPath": _read_config_string( + os.environ.get("FLOCKS_SOC_ALERTS_SQLITE_DB"), + sqlite_config.get("dbPath"), + str(DEFAULT_SQLITE_DB), + ), + "table": _read_config_string(sqlite_config.get("table"), DEFAULT_SQLITE_TABLE), + "recordColumn": _read_config_string(sqlite_config.get("recordColumn"), DEFAULT_SQLITE_RECORD_COLUMN), + "dateColumn": _read_config_string(sqlite_config.get("dateColumn"), DEFAULT_SQLITE_DATE_COLUMN), + "eventTimeColumn": _read_config_string( + sqlite_config.get("eventTimeColumn"), + DEFAULT_SQLITE_EVENT_TIME_COLUMN, + ), + }, + } + + +def _active_data_source(): + return "sqlite" + + +def _read_config_string(*values): + for value in values: + if isinstance(value, str) and value.strip(): + return value.strip() + return "" + + +def _resolve_config_path(value, fallback): + text = _read_config_string(value, str(fallback)) + path = Path(text).expanduser() + if path.is_absolute(): + return path + return (ACCESS_CONFIG_PATH.parent / path).resolve() + + +def _sqlite_settings(): + config = _load_alerts_config()["sqlite"] + return { + "db_path": _resolve_config_path(config.get("dbPath"), DEFAULT_SQLITE_DB), + "table": _sql_identifier(config.get("table"), DEFAULT_SQLITE_TABLE), + "record_column": _sql_identifier(config.get("recordColumn"), DEFAULT_SQLITE_RECORD_COLUMN), + "date_column": _sql_identifier(config.get("dateColumn"), DEFAULT_SQLITE_DATE_COLUMN), + "event_time_column": _sql_identifier( + config.get("eventTimeColumn"), + DEFAULT_SQLITE_EVENT_TIME_COLUMN, + ), + } + + +def _sql_identifier(value, fallback): + text = _read_config_string(value, fallback) + if not SQL_IDENTIFIER_RE.match(text): + text = fallback + return f'"{text}"' + + +def _active_source_path(): + return _sqlite_settings()["db_path"] + + +def _active_source_exists(): + path = _active_source_path() + return path.is_file() + + +def _find_sqlite_sources(start_date, end_date, start_time=None, end_time=None): + settings = _sqlite_settings() + db_path = settings["db_path"] + if not db_path.is_file(): + return [] + + where = [f"{settings['date_column']} BETWEEN ? AND ?"] + params = [start_date, end_date] + if start_time is not None and end_time is not None: + where.append(f"{settings['event_time_column']} BETWEEN ? AND ?") + params.extend([start_time, end_time]) + + query = ( + f"SELECT {settings['date_column']} AS asset_date, COUNT(*) AS record_count " + f"FROM {settings['table']} " + f"WHERE {' AND '.join(where)} " + f"GROUP BY {settings['date_column']} " + f"ORDER BY {settings['date_column']}" + ) + try: + with sqlite3.connect(db_path) as conn: + rows = conn.execute(query, params).fetchall() + except Exception: + return [] + + sources = [] + for asset_date, record_count in rows: + asset_date = str(asset_date or "") + if not DATE_RE.match(asset_date): + continue + sources.append( + _RecordSource( + path=db_path, + role="denoise", + date=asset_date, + data_source="sqlite", + record_count=int(record_count or 0), + start_time=start_time, + end_time=end_time, + ) + ) + return sources + + +def _available_sqlite_dates(): + settings = _sqlite_settings() + db_path = settings["db_path"] + if not db_path.is_file(): + return [] + query = ( + f"SELECT DISTINCT {settings['date_column']} AS asset_date " + f"FROM {settings['table']} " + f"WHERE {settings['date_column']} IS NOT NULL " + f"ORDER BY {settings['date_column']}" + ) + try: + with sqlite3.connect(db_path) as conn: + rows = conn.execute(query).fetchall() + except Exception: + return [] + return [str(row[0]) for row in rows if DATE_RE.match(str(row[0]))] + + +def _build_date_range(start_date, end_date, asset_files): + file_dates = sorted({date for date in (_asset_file_date(path) for path in asset_files) if date}) + return { + "start": start_date, + "end": end_date, + "label": start_date if start_date == end_date else f"{start_date} 至 {end_date}", + "availableDates": _available_asset_dates(), + "fileDates": file_dates, + } + + +def _build_event_range(date_range, denoise, triage): + values = [ + _parse_event_time(denoise.get("eventStart")), + _parse_event_time(denoise.get("eventEnd")), + _parse_event_time(triage.get("eventStart")), + _parse_event_time(triage.get("eventEnd")), + ] + values = [value for value in values if value] + if not values: + return { + "start": "", + "end": "", + "label": date_range["label"], + "source": "dateRange", + } + + start = min(values) + end = max(values) + return { + "start": _format_event_time(start), + "end": _format_event_time(end), + "label": _format_event_range_label(start, end), + "source": "recordTime", + } + + +def _timeline_window(start_date, end_date, series_length): + if start_date != end_date: + days = len(list(_date_span(start_date, end_date))) + return f"{days} 天范围聚合" + if series_length >= SIMULATED_TIME_BUCKETS: + return "近 24 小时分布" + return "按批次统计" + + +def _asset_file_role(path): + if isinstance(path, _RecordSource): + return path.role + name = path.name.lower() + if "triage" in name or "研判" in name: + return "triage" + return "denoise" + + +def _read_denoise(paths, workflow_call_count: int = 0): + total_raw = 0 + duplicates = 0 + parse_errors = 0 + headers = [] + source_counter = Counter() + threat_counter = Counter() + profile_counters = _new_profile_counters() + event_start = None + event_end = None + series_raw = [] + series_unique = [] + + for path in paths: + file_raw = 0 + file_duplicates = 0 + for obj in _iter_source_records(path): + if obj is None: + parse_errors += 1 + continue + if obj.get("_type") == "file_header": + headers.append(obj) + continue + file_raw += 1 + if obj.get("is_duplicate") is True: + file_duplicates += 1 + source_counter[_norm(obj.get("_source_type") or obj.get("source_type") or obj.get("device_type"))] += 1 + threat_counter[_norm(obj.get("_threat_type") or obj.get("threat_name") or obj.get("threat_type"))] += 1 + _update_profile_counters(obj, profile_counters) + event_start, event_end = _merge_record_time(event_start, event_end, obj) + total_raw += file_raw + duplicates += file_duplicates + series_raw.append(file_raw) + series_unique.append(max(file_raw - file_duplicates, 0)) + + total_unique = max(total_raw - duplicates, 0) + series_raw = _expand_series(series_raw, total_raw, seed=7) + series_unique = _expand_series(series_unique, total_unique, seed=11) + + return { + "totalRaw": total_raw, + "totalUnique": total_unique, + "duplicates": duplicates, + "duplicateRate": _ratio(duplicates, total_raw), + "uniqueRate": _ratio(total_unique, total_raw), + "headers": len(headers), + "files": len(paths), + "parseErrors": parse_errors, + "eventStart": _format_event_time(event_start), + "eventEnd": _format_event_time(event_end), + "sourceCounter": source_counter, + "threatCounter": threat_counter, + **profile_counters, + "seriesRaw": series_raw, + "seriesUnique": series_unique, + "workflowCallCount": workflow_call_count, + } + + +def _read_triage(paths): + total_records = 0 + parse_errors = 0 + headers = [] + verdict_counter = Counter() + source_counter = Counter() + threat_counter = Counter() + risk_counter = Counter() + status_counter = Counter() + profile_counters = _new_profile_counters() + event_start = None + event_end = None + header_sums = Counter() + fallback_new = 0 + fallback_cache = 0 + fallback_failed = 0 + fallback_followers = 0 + extra_success = 0 + series_total = [] + series_attack = [] + + for path in paths: + file_total = 0 + file_attack = 0 + for obj in _iter_source_records(path): + if obj is None: + parse_errors += 1 + continue + if obj.get("_type") == "file_header": + headers.append(obj) + for key in ( + "batch_total", + "batch_triaged", + "batch_cache_hit", + "batch_triage_failed", + "batch_followers_reused", + ): + header_sums[key] += _safe_int(obj.get(key)) + continue + + total_records += 1 + file_total += 1 + verdict = _norm(obj.get("attack_verdict") or "unknown") + if verdict not in {"attack_success", "attack", "attack_failed", "benign", "unknown"}: + verdict = "unknown" + verdict_counter[verdict] += 1 + if obj.get("attack_success") is True and verdict != "attack_success": + extra_success += 1 + if verdict in {"attack_success", "attack", "attack_failed"}: + file_attack += 1 + + source = _norm(obj.get("_source_type") or obj.get("source_type") or obj.get("device_type")) + source_counter[source] += 1 + threat_counter[_norm(obj.get("_threat_type") or obj.get("threat_name") or obj.get("threat_type"))] += 1 + risk_counter[_norm(obj.get("risk_level") or obj.get("threat_level") or obj.get("threat_severity"))] += 1 + _update_profile_counters(obj, profile_counters) + event_start, event_end = _merge_record_time(event_start, event_end, obj) + triage_source = _norm(obj.get("triage_source")) + triage_status = _norm(obj.get("triage_status")) + status_counter[triage_status or triage_source] += 1 + + if triage_source == "cache" or triage_status == "cached": + fallback_cache += 1 + elif triage_source in {"follower", "followers", "follower_reused"} or triage_status == "follower_reused": + fallback_followers += 1 + elif triage_status in {"failed", "error"}: + fallback_failed += 1 + else: + fallback_new += 1 + + series_total.append(file_total) + series_attack.append(file_attack) + + has_batch_fields = any( + _safe_int(header_sums[key]) > 0 + for key in ("batch_triaged", "batch_cache_hit", "batch_triage_failed", "batch_followers_reused") + ) + new_triaged = fallback_new + cache_hit = fallback_cache + triage_failed = fallback_failed + followers_reused = fallback_followers + + attack_success = verdict_counter["attack_success"] + extra_success + attack = verdict_counter["attack"] + attack_failed = verdict_counter["attack_failed"] + attack_total = attack_success + attack + attack_failed + benign = verdict_counter["benign"] + unknown = verdict_counter["unknown"] + + series_total = _expand_series(series_total, total_records, seed=23) + series_attack = _expand_series(series_attack, attack_total, seed=29) + + return { + "totalRecords": total_records, + "batchTotal": header_sums["batch_total"], + "newTriaged": new_triaged, + "cacheHit": cache_hit, + "triageFailed": triage_failed, + "followersReused": followers_reused, + "attackTotal": attack_total, + "attackSuccess": attack_success, + "attack": attack, + "attackFailed": attack_failed, + "benign": benign, + "unknown": unknown, + "attackRate": _ratio(attack_total, total_records), + "successRate": _ratio(attack_success, attack_total), + "cacheRate": _ratio(cache_hit + followers_reused, total_records), + "coverageRate": _ratio(total_records - triage_failed, total_records), + "headers": len(headers), + "files": len(paths), + "parseErrors": parse_errors, + "eventStart": _format_event_time(event_start), + "eventEnd": _format_event_time(event_end), + "sourceCounter": source_counter, + "threatCounter": threat_counter, + "riskCounter": risk_counter, + "statusCounter": status_counter, + **profile_counters, + "seriesTotal": series_total, + "seriesAttack": series_attack, + } + + +def _new_profile_counters(): + return { + "phaseCounter": Counter(), + "directionCounter": Counter(), + "resultCounter": Counter(), + "portCounter": Counter(), + "protocolCounter": Counter(), + "severityCounter": Counter(), + "responseCounter": Counter(), + } + + +def _update_profile_counters(obj, counters): + counters["phaseCounter"][_norm(obj.get("threat_phase") or obj.get("attack_phase") or obj.get("kill_chain_phase"))] += 1 + counters["directionCounter"][_norm(obj.get("direction") or obj.get("traffic_direction"))] += 1 + counters["resultCounter"][_norm(obj.get("threat_result") or obj.get("attack_verdict"))] += 1 + counters["protocolCounter"][_norm(obj.get("net_type") or obj.get("net_app_proto") or obj.get("protocol"))] += 1 + counters["severityCounter"][_norm(obj.get("threat_severity") or obj.get("threat_level") or obj.get("risk_level"))] += 1 + counters["responseCounter"][_norm(obj.get("rsp_status_code") or obj.get("status_code"))] += 1 + + port_value = obj.get("dport") or obj.get("dst_port") or obj.get("destination_port") + port = str(_safe_int(port_value)) if _safe_int(port_value) > 0 else _norm(port_value) + counters["portCounter"][port] += 1 + + +def _expand_series(values, total, *, seed): + values = [max(_safe_int(value), 0) for value in values if _safe_int(value) > 0] + total = _safe_int(total) + if total <= 0: + return [] + if len(values) >= 8: + return values + return _simulate_time_series(total, SIMULATED_TIME_BUCKETS, seed=seed) + + +def _simulate_time_series(total, buckets, *, seed): + if total <= 0 or buckets <= 0: + return [] + spike_a = (seed * 3 + 5) % buckets + spike_b = (seed * 5 + 11) % buckets + weights = [] + for hour in range(buckets): + workday = 1.0 if 8 <= hour <= 22 else 0.34 + wave = 1.0 + 0.46 * math.sin((hour + seed) * 0.68) + 0.22 * math.sin((hour + seed) * 1.31) + spike = 1.0 + if hour == spike_a: + spike += 1.05 + if hour == spike_b: + spike += 0.72 + if 14 <= hour <= 16: + spike += 0.45 + weights.append(max(0.08, workday * wave * spike)) + + weight_sum = sum(weights) or 1 + exact = [total * weight / weight_sum for weight in weights] + series = [int(value) for value in exact] + remainder = total - sum(series) + order = sorted(range(buckets), key=lambda index: exact[index] - series[index], reverse=True) + for index in order[:remainder]: + series[index] += 1 + return series + + +def _series_labels(length): + if length == SIMULATED_TIME_BUCKETS: + return [f"{hour:02d}:00" for hour in range(SIMULATED_TIME_BUCKETS)] + return [f"B{index + 1:02d}" for index in range(length)] + + + +def _build_field_stats(paths): + total = 0 + duplicates = 0 + source_ips = set() + destination_ips = set() + destination_ports = set() + hosts = set() + urls = set() + rules = set() + source_ip_counter = Counter() + destination_ip_counter = Counter() + host_counter = Counter() + url_counter = Counter() + rule_counter = Counter() + port_counter = Counter() + status_counter = Counter() + direction_counter = Counter() + protocol_counter = Counter() + threat_type_counter = Counter() + threat_result_counter = Counter() + threat_phase_counter = Counter() + + for path in paths: + for obj in _iter_source_records(path): + if obj is None or obj.get("_type") == "file_header": + continue + total += 1 + if obj.get("is_duplicate") is True: + duplicates += 1 + + sip = _field_text(obj.get("sip")) + dip = _field_text(obj.get("dip")) + host = _field_text(obj.get("req_host")) + url = _field_text(obj.get("req_http_url")) + rule = _field_text(obj.get("threat_rule_id")) + dport = _field_text(obj.get("dport")) + status_code = _field_text(obj.get("rsp_status_code")) + direction = _norm(obj.get("direction") or "unknown") + protocol = _norm(obj.get("net_type") or obj.get("net_app_proto") or "unknown") + threat_type = _norm(obj.get("threat_type") or obj.get("_threat_type") or obj.get("threat_name")) + threat_result = _norm(obj.get("threat_result") or "unknown") + threat_phase = _norm(obj.get("threat_phase") or "unknown") + + if sip: + source_ips.add(sip) + source_ip_counter[sip] += 1 + if dip: + destination_ips.add(dip) + destination_ip_counter[dip] += 1 + if host: + hosts.add(host) + host_counter[host] += 1 + if url: + urls.add(url) + url_counter[url] += 1 + if rule: + rules.add(rule) + rule_counter[rule] += 1 + if dport: + destination_ports.add(dport) + port_counter[dport] += 1 + if status_code: + status_counter[status_code] += 1 + if direction and direction != "none": + direction_counter[direction] += 1 + if protocol and protocol != "none": + protocol_counter[protocol] += 1 + if threat_type and threat_type != "none": + threat_type_counter[threat_type] += 1 + if threat_result and threat_result != "none": + threat_result_counter[threat_result] += 1 + if threat_phase and threat_phase != "none": + threat_phase_counter[threat_phase] += 1 + + return { + "totalRecords": total, + "duplicates": duplicates, + "uniqueRecords": max(total - duplicates, 0), + "uniqueSourceIps": len(source_ips), + "uniqueDestinationIps": len(destination_ips), + "uniqueDestinationPorts": len(destination_ports), + "uniqueHosts": len(hosts), + "uniqueUrls": len(urls), + "uniqueRules": len(rules), + "topSourceIps": _counter_items(source_ip_counter, 8), + "topDestinationIps": _counter_items(destination_ip_counter, 8), + "topHosts": _counter_items(host_counter, 8), + "topUrls": _counter_items(url_counter, 8), + "topRules": _counter_items(rule_counter, 8), + "ports": _counter_items(port_counter, 8), + "statusCodes": _counter_items(status_counter, 8), + "directions": _counter_items(direction_counter, 8), + "protocols": _counter_items(protocol_counter, 8), + "threatTypes": _counter_items(threat_type_counter, 8), + "threatResults": _counter_items(threat_result_counter, 8), + "threatPhases": _counter_items(threat_phase_counter, 8), + } + + +def _field_text(value): + if value is None: + return "" + text = str(value).strip() + if not text or text.lower() == "none": + return "" + return text + +def _build_sources(counter): + total = sum(counter.values()) + rows = [] + for key, label, aliases in SOURCE_DEFS: + count = 0 + for source, value in counter.items(): + if source in aliases or any(alias in source for alias in aliases): + count += value + rows.append( + { + "key": key, + "label": label, + "value": count, + "rate": _ratio(count, total), + "active": count > 0, + } + ) + + known = sum(item["value"] for item in rows) + unknown = max(total - known, 0) + if unknown: + rows.append({"key": "unknown", "label": "未归类来源", "value": unknown, "rate": _ratio(unknown, total), "active": True}) + return rows + + +def _build_closed_loop(triage): + total = triage["totalRecords"] + auto_closed = triage["attackFailed"] + triage["benign"] + manual = triage["unknown"] + pending = triage["triageFailed"] + triage["unknown"] + resolved = max(total - pending, 0) + return { + "autoClosed": auto_closed, + "resolved": resolved, + "manualDecision": manual, + "pending": pending, + "resolutionRate": _ratio(resolved, total), + } + + +def _build_pipeline(denoise, triage): + raw = denoise.get("workflowCallCount") or denoise["totalRaw"] + unique = denoise["totalUnique"] + triage_total = triage["totalRecords"] + attack_total = triage["attackTotal"] + reused = triage["cacheHit"] + triage["followersReused"] + duplicates = raw - unique + return { + "raw": raw, + "unique": unique, + "triageTotal": triage_total, + "attackTotal": attack_total, + "reductionSaved": duplicates, + "llmSaved": reused, + "uniqueRate": _ratio(unique, raw), + "workloadReuseRate": _ratio(reused, triage_total), + "coverageRate": _ratio(unique, raw), + "attackRate": _ratio(attack_total, triage_total), + "successRate": _ratio(triage["attackSuccess"], attack_total) if attack_total > 0 else 0, + } + + +def _build_attack_profile(denoise, triage): + return [ + _profile_group( + "phase", + "攻击阶段", + _profile_counter(denoise, triage, "phaseCounter"), + 4, + "#9b8cff", + lambda value: PHASE_LABELS.get(value, value), + ), + _profile_group( + "direction", + "流量方向", + _profile_counter(denoise, triage, "directionCounter"), + 4, + "#2be7ff", + lambda value: DIRECTION_LABELS.get(value, value), + ), + _profile_group( + "result", + "结果状态", + _profile_counter(denoise, triage, "resultCounter"), + 4, + "#2ee6a6", + lambda value: RESULT_LABELS.get(value, value), + ), + _profile_group( + "port", + "重点端口", + _profile_counter(denoise, triage, "portCounter"), + 4, + "#ffb020", + _port_label, + ), + ] + + +def _profile_counter(denoise, triage, key): + triage_counter = triage.get(key) or Counter() + if sum(triage_counter.values()) > 0: + return triage_counter + return denoise.get(key) or Counter() + + +def _profile_group(key, label, counter, limit, color, labeler): + total = sum(counter.values()) + return { + "key": key, + "label": label, + "total": total, + "color": color, + "items": [ + { + "key": value, + "label": labeler(value), + "value": count, + "rate": _ratio(count, total), + } + for value, count in counter.most_common(limit) + if value and value != "none" + ], + } + + +def _port_label(value): + if value == "unknown": + return "未知端口" + if str(value).isdigit(): + return f"{value}/TCP" + return str(value) + + +def _iter_source_records(path): + if isinstance(path, _RecordSource) and path.data_source == "sqlite": + yield from _iter_sqlite_records(path) + return + + +def _iter_sqlite_records(source): + settings = _sqlite_settings() + where = [f"{settings['date_column']} = ?"] + params = [source.date] + if source.start_time is not None and source.end_time is not None: + where.append(f"{settings['event_time_column']} BETWEEN ? AND ?") + params.extend([source.start_time, source.end_time]) + query = ( + f"SELECT {settings['record_column']} AS record_json " + f"FROM {settings['table']} " + f"WHERE {' AND '.join(where)} " + f"ORDER BY {settings['event_time_column']}, rowid" + ) + try: + with sqlite3.connect(settings["db_path"]) as conn: + rows = conn.execute(query, params).fetchall() + except Exception: + return + + for row in rows: + try: + payload = json.loads(row[0]) + except Exception: + yield None + continue + if isinstance(payload, dict): + yield payload + elif isinstance(payload, list): + yield from _iter_json_payload(payload) + else: + yield None + + +def _iter_json_payload(payload): + if isinstance(payload, list): + for item in payload: + yield item if isinstance(item, dict) else None + return + + if not isinstance(payload, dict): + yield None + return + + records = None + for key in ("records", "data", "items", "rows"): + value = payload.get(key) + if isinstance(value, list): + records = value + break + + if records is None: + yield payload + return + + header = { + key: value + for key, value in payload.items() + if key not in {"records", "data", "items", "rows"} + } + if header: + yield {"_type": "file_header", **header} + for item in records: + yield item if isinstance(item, dict) else None + + +def _without_counters(payload): + return { + key: value + for key, value in payload.items() + if not isinstance(value, Counter) + } + + +def _counter_items(counter, limit): + total = sum(counter.values()) + return [ + {"label": label, "value": value, "rate": _ratio(value, total)} + for label, value in counter.most_common(limit) + if label + ] + + +def _file_brief(path): + if isinstance(path, _RecordSource): + stat = path.path.stat() if path.path.exists() else None + return { + "name": f"{path.path.name}:{path.date}", + "path": _display_path(path.path), + "date": path.date, + "role": path.role, + "dataSource": path.data_source, + "recordCount": path.record_count, + "sizeBytes": stat.st_size if stat else 0, + "modifiedAt": datetime.fromtimestamp(stat.st_mtime).isoformat(timespec="seconds") if stat else "", + } + stat = path.stat() + return { + "name": path.name, + "path": _display_path(path), + "bytes": stat.st_size, + "modifiedAt": datetime.fromtimestamp(stat.st_mtime).isoformat(timespec="seconds"), + } + + +def _display_path(path): + try: + return "~/" + str(Path(path).resolve().relative_to(Path.home())).replace("\\", "/") + except Exception: + return str(path) + + +def _source_label(path): + if isinstance(path, _RecordSource): + return f"{_display_path(path.path)}:{path.date}" + return _display_path(path) + + +def _merge_record_time(current_start, current_end, obj): + value = None + for key in ("time", "event_time", "timestamp", "created_at", "occur_time", "start_time"): + value = _parse_event_time(obj.get(key)) + if value: + break + if not value: + return current_start, current_end + if current_start is None or value < current_start: + current_start = value + if current_end is None or value > current_end: + current_end = value + return current_start, current_end + + +def _parse_event_time(value): + if value is None or value == "": + return None + if isinstance(value, datetime): + return value.replace(tzinfo=None) + try: + number = float(value) + if number > 1_000_000_000_000: + number = number / 1000 + if number > 10_000_000: + return datetime.fromtimestamp(number).replace(tzinfo=None) + except Exception: + pass + try: + text = str(value).strip().replace("Z", "+00:00") + return datetime.fromisoformat(text).replace(tzinfo=None) + except Exception: + return None + + +def _format_event_time(value): + if not value: + return "" + return value.isoformat(sep=" ", timespec="seconds") + + +def _format_event_range_label(start, end): + if start.date() == end.date(): + return f"{start:%Y-%m-%d %H:%M} - {end:%H:%M}" + return f"{start:%Y-%m-%d %H:%M} 至 {end:%Y-%m-%d %H:%M}" + + +def _safe_int(value): + try: + if value is None or value == "": + return 0 + return int(value) + except Exception: + return 0 + + +def _ratio(part, total): + part = _safe_int(part) + total = _safe_int(total) + if total <= 0: + return 0 + return round(part / total, 4) + + +def _norm(value): + if value is None: + return "unknown" + text = str(value).strip() + if not text: + return "unknown" + return text.lower() diff --git a/.flocks/flockshub/plugins/webuis/soc_ui/soc_overview/api/routes.yaml b/.flocks/flockshub/plugins/webuis/soc_ui/soc_overview/api/routes.yaml new file mode 100644 index 000000000..5c0d6b949 --- /dev/null +++ b/.flocks/flockshub/plugins/webuis/soc_ui/soc_overview/api/routes.yaml @@ -0,0 +1,6 @@ +routes: + - method: GET + path: /stats + handler: handlers.get_stats + timeoutMs: 30000 + description: Alert denoise and triage dashboard statistics diff --git a/.flocks/flockshub/plugins/webuis/soc_ui/soc_overview/manifest.json b/.flocks/flockshub/plugins/webuis/soc_ui/soc_overview/manifest.json new file mode 100644 index 000000000..b3b6c4f60 --- /dev/null +++ b/.flocks/flockshub/plugins/webuis/soc_ui/soc_overview/manifest.json @@ -0,0 +1,12 @@ +{ + "id": "soc-overview", + "title": "SOC 总览", + "titleEn": "SOC Overview", + "route": "/contracts/webui/soc-overview", + "icon": "Shield", + "order": 10, + "enabled": true, + "placement": "home.after", + "entry": "src/index.tsx", + "updatedAt": 0 +} diff --git a/.flocks/flockshub/plugins/webuis/soc_ui/soc_overview/src/index.tsx b/.flocks/flockshub/plugins/webuis/soc_ui/soc_overview/src/index.tsx new file mode 100644 index 000000000..dafc71b62 --- /dev/null +++ b/.flocks/flockshub/plugins/webuis/soc_ui/soc_overview/src/index.tsx @@ -0,0 +1,817 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { api } from '@flocks/webui-contract-sdk'; + +type TimeRangeKey = '15m' | '1h' | '2h' | '24h' | 'today' | '7d' | '30d'; +type TimeFilterMode = 'relative' | 'custom'; +type TimePanelTab = 'auto' | 'custom'; +type RefreshKey = 'off' | '5s' | '15s' | '1m' | '5m' | '1h'; + +interface ChoiceOption { + value: T; + label: string; +} + +interface TimeFilterState { + mode: TimeFilterMode; + range: TimeRangeKey; + start: string; + end: string; +} + +interface CounterItem { + key?: string; + label: string; + value: number; + rate?: number; +} + +interface FieldStats { + totalRecords?: number; + duplicates?: number; + uniqueRecords?: number; + uniqueSourceIps?: number; + uniqueDestinationIps?: number; + uniqueDestinationPorts?: number; + uniqueHosts?: number; + uniqueUrls?: number; + uniqueRules?: number; + topSourceIps?: CounterItem[]; + topDestinationIps?: CounterItem[]; + topHosts?: CounterItem[]; + topUrls?: CounterItem[]; + topRules?: CounterItem[]; + ports?: CounterItem[]; + statusCodes?: CounterItem[]; + directions?: CounterItem[]; + protocols?: CounterItem[]; + threatTypes?: CounterItem[]; + threatResults?: CounterItem[]; + threatPhases?: CounterItem[]; +} + +interface Stats { + date?: string; + dateRange?: { label?: string; start?: string; end?: string }; + eventRange?: { label?: string; start?: string; end?: string }; + generatedAt?: string; + denoise?: { + totalRaw?: number; + totalUnique?: number; + duplicates?: number; + duplicateRate?: number; + }; + triage?: { + totalRecords?: number; + attackSuccess?: number; + attack?: number; + attackFailed?: number; + unknown?: number; + }; + topThreats?: CounterItem[]; + fieldStats?: FieldStats; +} + +const EMPTY_FIELD_STATS: Required = { + totalRecords: 0, + duplicates: 0, + uniqueRecords: 0, + uniqueSourceIps: 0, + uniqueDestinationIps: 0, + uniqueDestinationPorts: 0, + uniqueHosts: 0, + uniqueUrls: 0, + uniqueRules: 0, + topSourceIps: [], + topDestinationIps: [], + topHosts: [], + topUrls: [], + topRules: [], + ports: [], + statusCodes: [], + directions: [], + protocols: [], + threatTypes: [], + threatResults: [], + threatPhases: [], +}; + +const EMPTY_STATS: Required = { + date: '', + dateRange: { label: '', start: '', end: '' }, + eventRange: { label: '', start: '', end: '' }, + generatedAt: '', + denoise: { totalRaw: 0, totalUnique: 0, duplicates: 0, duplicateRate: 0 }, + triage: { totalRecords: 0, attackSuccess: 0, attack: 0, attackFailed: 0, unknown: 0 }, + topThreats: [], + fieldStats: EMPTY_FIELD_STATS, +}; + +const LABELS: Record = { + exploit: '漏洞利用', + recon: '侦察探测', + post_exploit: '后渗透', + control: '控制通信', + tunneling: '隧道通信', + file: '文件风险', + c2: '控制通信', + trojan: '木马', + ransom: '勒索', + shell: '命令执行', + botnet: '僵尸网络', + success: '攻击成功', + failed: '攻击失败', + unknown: '待确认', + in: '入站', + out: '出站', + lateral: '横向', +}; + +const TIME_RANGE_OPTIONS: ChoiceOption[] = [ + { value: '15m', label: '最近15分钟' }, + { value: '2h', label: '最近2小时' }, + { value: '24h', label: '最近24小时' }, + { value: 'today', label: '今天' }, + { value: '7d', label: '最近7天' }, + { value: '30d', label: '最近30天' }, +]; + +const REFRESH_OPTIONS: ChoiceOption[] = [ + { value: '5s', label: '5秒' }, + { value: '15s', label: '15秒' }, + { value: '1m', label: '1分钟' }, + { value: '5m', label: '5分钟' }, + { value: '1h', label: '1小时' }, + { value: 'off', label: '关闭' }, +]; + +const REFRESH_INTERVAL_MS: Record = { + off: 0, + '5s': 5_000, + '15s': 15_000, + '1m': 60_000, + '5m': 300_000, + '1h': 3_600_000, +}; + +const DEFAULT_TIME_RANGE: TimeRangeKey = '7d'; + +function list(value: unknown): CounterItem[] { + return Array.isArray(value) ? value.filter((item) => item && typeof item.label === 'string') : []; +} + +function mergeFieldStats(value: FieldStats | undefined): Required { + return { + ...EMPTY_FIELD_STATS, + ...(value || {}), + topSourceIps: list(value?.topSourceIps), + topDestinationIps: list(value?.topDestinationIps), + topHosts: list(value?.topHosts), + topUrls: list(value?.topUrls), + topRules: list(value?.topRules), + ports: list(value?.ports), + statusCodes: list(value?.statusCodes), + directions: list(value?.directions), + protocols: list(value?.protocols), + threatTypes: list(value?.threatTypes), + threatResults: list(value?.threatResults), + threatPhases: list(value?.threatPhases), + }; +} + +function mergeStats(raw: Stats | undefined): Required { + const value = raw || {}; + return { + ...EMPTY_STATS, + ...value, + dateRange: { ...EMPTY_STATS.dateRange, ...(value.dateRange || {}) }, + eventRange: { ...EMPTY_STATS.eventRange, ...(value.eventRange || {}) }, + denoise: { ...EMPTY_STATS.denoise, ...(value.denoise || {}) }, + triage: { ...EMPTY_STATS.triage, ...(value.triage || {}) }, + topThreats: list(value.topThreats), + fieldStats: mergeFieldStats(value.fieldStats), + }; +} + +function formatNumber(value: number | undefined) { + return new Intl.NumberFormat('zh-CN').format(Math.round(Number(value || 0))); +} + +function percent(value: number | undefined) { + return `${Math.round(Number(value || 0) * 1000) / 10}%`; +} + +function pad2(value: number) { + return String(value).padStart(2, '0'); +} + +function toLocalInputValue(date: Date) { + return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}T${pad2(date.getHours())}:${pad2(date.getMinutes())}`; +} + +function parseLocalInputValue(value: string) { + if (!value) return null; + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? null : parsed; +} + +function startOfToday(now: Date) { + return new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0); +} + +function resolveRelativeWindow(range: TimeRangeKey, now = new Date()): [Date, Date] { + const end = new Date(now); + if (range === 'today') return [startOfToday(now), end]; + const spans: Record, number> = { + '15m': 15 * 60 * 1000, + '1h': 60 * 60 * 1000, + '2h': 2 * 60 * 60 * 1000, + '24h': 24 * 60 * 60 * 1000, + '7d': 7 * 24 * 60 * 60 * 1000, + '30d': 30 * 24 * 60 * 60 * 1000, + }; + return [new Date(end.getTime() - spans[range]), end]; +} + +function createRelativeTimeFilter(range: TimeRangeKey = DEFAULT_TIME_RANGE): TimeFilterState { + const [start, end] = resolveRelativeWindow(range); + return { mode: 'relative', range, start: toLocalInputValue(start), end: toLocalInputValue(end) }; +} + +function resolveTimeWindow(filter: TimeFilterState, now = new Date()): [Date, Date] | null { + if (filter.mode === 'relative') return resolveRelativeWindow(filter.range, now); + const start = parseLocalInputValue(filter.start); + const end = parseLocalInputValue(filter.end); + if (!start || !end) return null; + return start <= end ? [start, end] : [end, start]; +} + +function timeFilterParams(filter: TimeFilterState) { + const window = resolveTimeWindow(filter); + if (!window) return {}; + const [start, end] = window; + return { + startTime: Math.floor(start.getTime() / 1000), + endTime: Math.floor(end.getTime() / 1000), + }; +} + +function timeFilterLabel(filter: TimeFilterState) { + if (filter.mode === 'relative') { + if (filter.range === '1h') return '最近1小时'; + return TIME_RANGE_OPTIONS.find((option) => option.value === filter.range)?.label || '最近7天'; + } + const window = resolveTimeWindow(filter); + if (!window) return '精确时间'; + const [start, end] = window; + const format = (date: Date) => `${date.getFullYear()}/${pad2(date.getMonth() + 1)}/${pad2(date.getDate())} ${pad2(date.getHours())}:${pad2(date.getMinutes())}`; + return `${format(start)} 至 ${format(end)}`; +} + +function refreshLabel(value: RefreshKey) { + return REFRESH_OPTIONS.find((option) => option.value === value)?.label || '关闭'; +} + +function labelOf(item: CounterItem) { + return LABELS[item.key || item.label] || LABELS[item.label] || item.label; +} + +function truncate(value: string, max = 34) { + return value.length > max ? `${value.slice(0, max)}...` : value; +} + +function count(items: CounterItem[], key: string) { + return items.find((item) => item.key === key || item.label === key)?.value || 0; +} + +export default function SocOverviewPage() { + const [stats, setStats] = useState(EMPTY_STATS); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [timeFilter, setTimeFilter] = useState(() => createRelativeTimeFilter(DEFAULT_TIME_RANGE)); + const [refreshKey, setRefreshKey] = useState('off'); + const [timeMenuOpen, setTimeMenuOpen] = useState(false); + + const load = useCallback(async (activeTimeFilter: TimeFilterState) => { + setLoading(true); + setError(''); + try { + const response = await api.page.get('/stats', { params: timeFilterParams(activeTimeFilter) }); + setStats(mergeStats(response.data)); + } catch (err) { + setError(err instanceof Error ? err.message : 'SOC 总览数据加载失败'); + } finally { + setLoading(false); + } + }, []); + + const refresh = useCallback(() => { + void load(timeFilter); + }, [load, timeFilter]); + + useEffect(() => { + void load(timeFilter); + }, [load, timeFilter]); + + useEffect(() => { + const intervalMs = REFRESH_INTERVAL_MS[refreshKey]; + if (!intervalMs) return undefined; + const timer = window.setInterval(() => { + void load(timeFilter); + }, intervalMs); + return () => window.clearInterval(timer); + }, [load, refreshKey, timeFilter]); + + useEffect(() => { + if (!timeMenuOpen) return undefined; + const closeOnOutsidePress = (event: MouseEvent | TouchEvent) => { + const target = event.target; + if (target instanceof Element && target.closest('[data-soc-menu-root="true"]')) return; + setTimeMenuOpen(false); + }; + document.addEventListener('mousedown', closeOnOutsidePress, true); + document.addEventListener('touchstart', closeOnOutsidePress, true); + return () => { + document.removeEventListener('mousedown', closeOnOutsidePress, true); + document.removeEventListener('touchstart', closeOnOutsidePress, true); + }; + }, [timeMenuOpen]); + + const applyTimeRefresh = useCallback((nextTimeFilter: TimeFilterState, nextRefreshKey: RefreshKey) => { + setTimeFilter(nextTimeFilter); + setRefreshKey(nextRefreshKey); + setTimeMenuOpen(false); + }, []); + + + const cards = useMemo(() => [ + { label: '原始告警', value: stats.denoise.totalRaw, hint: `重复 ${formatNumber(stats.denoise.duplicates)} 条` }, + { label: '有效告警', value: stats.denoise.totalUnique, hint: `去重率 ${percent(stats.denoise.duplicateRate)}` }, + { label: '攻击源地址', value: stats.fieldStats.uniqueSourceIps, hint: '字段 sip' }, + { label: '目标地址', value: stats.fieldStats.uniqueDestinationIps, hint: '字段 dip' }, + { label: 'HTTP 主机', value: stats.fieldStats.uniqueHosts, hint: '字段 req_host' }, + { label: 'URL 样本', value: stats.fieldStats.uniqueUrls, hint: '字段 req_http_url' }, + { label: '威胁规则', value: stats.fieldStats.uniqueRules, hint: '字段 threat_rule_id' }, + { label: '目标端口', value: stats.fieldStats.uniqueDestinationPorts, hint: '字段 dport' }, + ], [stats]); + + const resultTotal = Math.max(stats.denoise.totalUnique, 1); + const success = stats.triage.attackSuccess || count(stats.fieldStats.threatResults, 'success'); + const failed = stats.triage.attackFailed || count(stats.fieldStats.threatResults, 'failed'); + const unknown = Math.max(0, stats.denoise.totalUnique - success - failed) || count(stats.fieldStats.threatResults, 'unknown'); + + return ( +
+ +
+
+

SOC 网络告警概览

+

基于真实告警字段统计源地址、目标地址、HTTP 请求和威胁规则。

+
+
+ setTimeMenuOpen((open) => !open)} + onApply={applyTimeRefresh} + onClose={() => setTimeMenuOpen(false)} + /> + +
+
+ + {error &&
{error}
} + +
+ {cards.map((card) => ( +
+ {card.label} + {formatNumber(card.value)} + {card.hint} +
+ ))} +
+ +
+
+
+

告警研判结果

+

按 threat_result 与研判结论聚合。

+
+ {formatNumber(resultTotal)} 条有效告警 +
+
+ + + +
+
+ 攻击成功 {formatNumber(success)} + 待确认 {formatNumber(unknown)} + 攻击失败 {formatNumber(failed)} +
+
+ +
+ + + + + + +
+
+ ); +} + +function TimeRefreshPopover({ + value, + refreshValue, + open, + onToggle, + onApply, + onClose, +}: { + value: TimeFilterState; + refreshValue: RefreshKey; + open: boolean; + onToggle: () => void; + onApply: (timeFilter: TimeFilterState, refresh: RefreshKey) => void; + onClose: () => void; +}) { + const [tab, setTab] = useState(value.mode === 'custom' ? 'custom' : 'auto'); + const [range, setRange] = useState(value.range); + const [refresh, setRefresh] = useState(refreshValue); + const [start, setStart] = useState(value.start); + const [end, setEnd] = useState(value.end); + + useEffect(() => { + if (!open) return; + const window = resolveTimeWindow(value) || resolveRelativeWindow(value.range); + setTab(value.mode === 'custom' ? 'custom' : 'auto'); + setRange(value.range); + setRefresh(refreshValue); + setStart(toLocalInputValue(window[0])); + setEnd(toLocalInputValue(window[1])); + }, [open, refreshValue, value]); + + const chooseRange = (next: TimeRangeKey) => { + const window = resolveRelativeWindow(next); + setRange(next); + setStart(toLocalInputValue(window[0])); + setEnd(toLocalInputValue(window[1])); + }; + + const confirm = () => { + onApply(tab === 'custom' ? { mode: 'custom', range, start, end } : createRelativeTimeFilter(range), refresh); + }; + + const currentTimeLabel = timeFilterLabel(value); + const optionClass = (selected: boolean) => `time-option${selected ? ' selected' : ''}`; + + return ( +
+ + {open && ( +
+
+ {[ + ['auto', '自动刷新'], + ['custom', '精确时间'], + ].map(([key, label]) => ( + + ))} +
+
+ {tab === 'auto' ? ( + <> +
+ +
+ {TIME_RANGE_OPTIONS.map((option) => ( + + ))} +
+
+
+ +
+ {REFRESH_OPTIONS.map((option) => ( + + ))} +
+
+ + ) : ( + <> +
+ + +
+
+ {[ + ['1h', '1小时'], + ['24h', '24小时'], + ['today', '今天'], + ['7d', '最近7天'], + ['30d', '最近30天'], + ].map(([key, label]) => ( + + ))} +
+ + )} +
+
+ + +
+
+ )} +
+ ); +} + +function Panel({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+

{title}

+ {children} +
+ ); +} + +function RankList({ rows, mono = false }: { rows: CounterItem[]; mono?: boolean }) { + return ( +
+ {rows.length ? rows.map((item, index) => ( +
+ {String(index + 1).padStart(2, '0')} + {truncate(labelOf(item), mono ? 44 : 30)} + {formatNumber(item.value)} +
+ )) :
暂无数据
} +
+ ); +} + +function TileList({ rows }: { rows: CounterItem[] }) { + return ( +
+ {rows.slice(0, 9).map((item) => ( +
+ {labelOf(item)} + {formatNumber(item.value)} +
+ ))} + {!rows.length &&
暂无数据
} +
+ ); +} + +function ProgressList({ rows }: { rows: CounterItem[] }) { + const total = Math.max(1, rows.reduce((value, item) => value + item.value, 0)); + return ( +
+ {rows.map((item) => ( +
+ {labelOf(item)} + + {formatNumber(item.value)} +
+ ))} + {!rows.length &&
暂无数据
} +
+ ); +} + +function SplitRanks({ leftTitle, leftRows, rightTitle, rightRows, mono = false }: { leftTitle: string; leftRows: CounterItem[]; rightTitle: string; rightRows: CounterItem[]; mono?: boolean }) { + return ( +
+

{leftTitle}

+

{rightTitle}

+
+ ); +} + +const CSS = ` +.soc-overview-root { + min-height: 100%; + overflow: auto; + padding: 24px; + background: #f6f7fb; + color: #111827; + font-family: Inter, "Microsoft YaHei", "PingFang SC", Arial, sans-serif; +} +.soc-overview-root * { box-sizing: border-box; } +.soc-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 20px; + margin-bottom: 18px; +} +.soc-header h1 { margin: 0; font-size: 24px; line-height: 1.25; font-weight: 750; letter-spacing: 0; } +.soc-header p { margin: 8px 0 0; color: #667085; font-size: 14px; } + .soc-actions { position: relative; display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 8px; color: #475467; font-size: 13px; } + .soc-actions > span, .soc-actions > button { + height: 34px; + display: inline-flex; + align-items: center; + border: 1px solid #d9dee8; + border-radius: 6px; + background: #fff; + padding: 0 12px; + } + .soc-actions > button { cursor: pointer; color: #111827; font-weight: 600; } + .time-filter { position: relative; z-index: 30; } + .time-trigger { + min-height: 34px; + max-width: min(760px, calc(100vw - 64px)); + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + border: 1px solid #d9dee8; + border-radius: 6px; + background: #fff; + padding: 6px 12px; + color: #344054; + font-size: 13px; + cursor: pointer; + transition: border-color .16s ease, background .16s ease; + } + .time-trigger.open { border-color: #2563eb; } + .time-trigger:hover { border-color: #9fb0c7; } + .time-trigger span { display: inline-flex; align-items: center; white-space: nowrap; } + .time-trigger b { color: #2563eb; font-weight: 650; } + .time-trigger i { width: 1px; height: 16px; background: #e2e8f0; } + .time-trigger em { color: #64748b; font-style: normal; font-size: 14px; line-height: 1; } + .time-panel { + position: absolute; + top: calc(100% + 8px); + right: 0; + width: min(360px, calc(100vw - 32px)); + overflow: hidden; + border: 1px solid #d9e2ef; + border-radius: 8px; + background: #fff; + z-index: 40; + } + .time-tabs { + display: inline-grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 2px; + width: calc(100% - 24px); + margin: 12px; + border-radius: 6px; + background: #f1f5f9; + padding: 2px; + } + .time-tabs button { + height: 30px; + border: 0; + border-radius: 5px; + background: transparent; + color: #64748b; + font-size: 13px; + font-weight: 650; + cursor: pointer; + } + .time-tabs button.active { background: #fff; color: #1d4ed8; } + .time-panel-body { + display: grid; + gap: 12px; + border-top: 1px solid #e2e8f0; + padding: 12px; + } + .time-panel-body label, + .time-inputs span { + display: block; + margin-bottom: 6px; + color: #94a3b8; + font-size: 12px; + font-weight: 650; + } + .time-options { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; } + .time-option, + .time-shortcuts button { + height: 28px; + border: 1px solid transparent; + border-radius: 5px; + background: #f8fafc; + color: #64748b; + font-size: 12px; + font-weight: 650; + white-space: nowrap; + cursor: pointer; + } + .time-option:hover, + .time-shortcuts button:hover { background: #eef2f7; color: #334155; } + .time-option.selected { border-color: #2563eb; background: #2563eb; color: #fff; } + .time-inputs { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; } + .time-inputs input { + width: 100%; + height: 32px; + border: 1px solid #cbd5e1; + border-radius: 5px; + background: #fff; + padding: 0 8px; + color: #334155; + font-size: 12px; + outline: none; + } + .time-inputs input:focus { border-color: #2563eb; box-shadow: 0 0 0 2px #dbeafe; } + .time-shortcuts { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 6px; } + .time-shortcuts button { padding: 0 4px; } + .time-panel-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + border-top: 1px solid #e2e8f0; + background: #f8fafc; + padding: 10px 12px; + } + .time-panel-actions button { + height: 30px; + min-width: 64px; + border: 1px solid #cbd5e1; + border-radius: 5px; + background: #fff; + color: #334155; + font-size: 13px; + cursor: pointer; + } + .time-panel-actions button.primary { border-color: #2563eb; background: #2563eb; color: #fff; font-weight: 650; } + .soc-error { margin-bottom: 16px; border: 1px solid #fed7aa; background: #fff7ed; color: #c2410c; border-radius: 6px; padding: 10px 12px; font-size: 13px; } +.metric-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; } +.metric-card, .result-card, .panel-card { + border: 1px solid #e2e8f0; + border-radius: 8px; + background: #fff; +} +.metric-card { min-height: 112px; padding: 18px; } +.metric-card span { color: #667085; font-size: 13px; } +.metric-card b { display: block; margin-top: 10px; color: #111827; font-size: 28px; line-height: 1; } +.metric-card small { display: block; margin-top: 10px; color: #98a2b3; font-size: 12px; } +.result-card { margin-top: 14px; padding: 18px; } +.section-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; } +.section-head h2, .panel-head h2 { margin: 0; color: #111827; font-size: 15px; font-weight: 700; } +.section-head p { margin: 6px 0 0; color: #667085; font-size: 13px; } +.section-head > b { color: #475467; font-size: 13px; } +.result-bar { display: flex; height: 14px; overflow: hidden; margin-top: 18px; border-radius: 999px; background: #edf2f7; } +.result-bar .success { background: #ef4444; } +.result-bar .unknown { background: #f59e0b; } +.result-bar .failed { background: #cbd5e1; } +.result-legend { display: flex; flex-wrap: wrap; gap: 18px; margin-top: 12px; color: #667085; font-size: 13px; } +.result-legend span { display: inline-flex; align-items: center; gap: 7px; } +.result-legend i { width: 9px; height: 9px; border-radius: 50%; } +.result-legend .success { background: #ef4444; } +.result-legend .unknown { background: #f59e0b; } +.result-legend .failed { background: #cbd5e1; } +.panel-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin-top: 14px; } +.panel-card { min-height: 288px; padding: 0 16px 16px; } +.panel-head { height: 48px; display: flex; align-items: center; border-bottom: 1px solid #edf2f7; margin-bottom: 14px; } +.rank-list { display: grid; gap: 8px; } +.rank-list div { display: grid; grid-template-columns: 34px minmax(0, 1fr) auto; align-items: center; gap: 10px; min-height: 34px; border: 1px solid #edf2f7; border-radius: 6px; background: #fbfcfe; padding: 7px 10px; } +.rank-list span { color: #94a3b8; font-size: 12px; font-weight: 700; } +.rank-list b { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #344054; font-size: 13px; } +.rank-list b.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; } +.rank-list em { color: #111827; font-size: 13px; font-style: normal; font-weight: 700; } +.tile-list { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; } +.tile-list div { min-height: 72px; display: grid; align-content: center; justify-items: center; gap: 8px; border-radius: 6px; background: #f8fafc; color: #475467; } +.tile-list b { font-size: 13px; } +.tile-list span { color: #111827; font-size: 18px; font-weight: 700; } +.progress-list { display: grid; gap: 13px; } +.progress-list div { display: grid; grid-template-columns: 88px minmax(0, 1fr) 62px; align-items: center; gap: 12px; color: #475467; font-size: 13px; } +.progress-list i { height: 8px; overflow: hidden; border-radius: 999px; background: #edf2f7; } +.progress-list b { display: block; height: 100%; border-radius: inherit; background: #2563eb; } +.progress-list em { color: #111827; text-align: right; font-style: normal; font-weight: 700; } +.split-ranks { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; } +.split-ranks h3 { margin: 0 0 10px; color: #667085; font-size: 13px; font-weight: 700; } +.empty { min-height: 80px; display: grid; place-items: center; color: #98a2b3; font-size: 13px; } +@media (max-width: 1180px) { .metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .panel-grid { grid-template-columns: 1fr; } } +@media (max-width: 720px) { .soc-overview-root { padding: 16px; } .soc-header { flex-direction: column; } .metric-grid, .split-ranks { grid-template-columns: 1fr; } } +`; diff --git a/.flocks/flockshub/plugins/webuis/soc_ui/workspace.json b/.flocks/flockshub/plugins/webuis/soc_ui/workspace.json new file mode 100644 index 000000000..4988a545e --- /dev/null +++ b/.flocks/flockshub/plugins/webuis/soc_ui/workspace.json @@ -0,0 +1,34 @@ +{ + "id": "soc_ui", + "title": "SOC 工作区", + "titleEn": "SOC Workspace", + "icon": "ShieldCheck", + "order": 10, + "enabled": true, + "placement": "sceneWorkspace", + "defaultPageId": "soc-overview", + "sections": [ + { + "id": "posture", + "label": "态势", + "labelEn": "Posture", + "pageIds": [ + "soc-dashboard" + ], + "defaultPageId": "soc-dashboard", + "contentPadding": "none", + "themeOverride": "dark" + }, + { + "id": "operations", + "label": "告警运营", + "labelEn": "Alert Operations", + "pageIds": [ + "soc-overview", + "soc-alerts" + ], + "defaultPageId": "soc-overview", + "contentPadding": "none" + } + ] +} diff --git a/.flocks/flockshub/plugins/workflows/stream_alert_denoise/config.json b/.flocks/flockshub/plugins/workflows/stream_alert_denoise/config.json new file mode 100644 index 000000000..55e551f36 --- /dev/null +++ b/.flocks/flockshub/plugins/workflows/stream_alert_denoise/config.json @@ -0,0 +1,42 @@ +{ + "version": 1, + "kind": "workflow.integration-config", + "workflow": { + "id": "stream_alert_denoise", + "name": "stream_alert_denoise", + "source": "global", + "category": "default" + }, + "triggers": [ + { + "id": "syslog_main", + "type": "syslog", + "name": "Syslog实时告警流", + "enabled": true, + "config": { + "host": "0.0.0.0", + "port": 514, + "protocol": "udp", + "source_log_type": "auto", + "input_field": "syslog_message" + }, + "runtime": { + "status": "stopped", + "apiKeyConfigured": false + } + } + ], + "publish": { + "api": { + "enabled": true, + "path": "/api/workflow/stream_alert_denoise/run", + "apiKeyConfigured": false + }, + "syslog": { + "enabled": true, + "host": "0.0.0.0", + "port": 514, + "protocol": "udp" + } + } +} \ No newline at end of file diff --git a/.flocks/flockshub/plugins/workflows/stream_alert_denoise/guide.md b/.flocks/flockshub/plugins/workflows/stream_alert_denoise/guide.md new file mode 100644 index 000000000..6b04d1fcc --- /dev/null +++ b/.flocks/flockshub/plugins/workflows/stream_alert_denoise/guide.md @@ -0,0 +1,318 @@ +# stream_alert_denoise 配置引导 + +这个文件是 `stream_alert_denoise` 的工作流专属 `guide.md`。Rex 处理这个工作流的发布、接入、规则、样例或查配置快捷入口时,必须先读取本文全文,再把 `workflow.md`、`workflow.json`、`workflow_config_manage(action="get" 或 "status", workflow_id="stream_alert_denoise")`、必要时 `workflow_config_manage(action="get" 或 "status", workflow_id="stream_alert_denoise", config_type="syslog")` 的结果和工作流目录下的 `config.json` 作为支撑上下文。 + +`workflow-config-guide` skill 只提供交互协议;本文才是本工作流配置细节、默认选项、提问顺序和验证方式的来源。 + +Rex 引导用户时必须遵守: + +1. 根据用户点击的入口或自然语言需求,自动定位本文相关章节。 +2. 一次只问一个最关键问题。 +3. 每个选择都必须允许自定义/补充输入;没有补充则填 `none`。 +4. 涉及发布模板、触发器、阈值、字段列表或持久化行为变更时,先展示计划和 diff,再用 question 工具确认。 +5. 查配置只能只读,不得修改文件、启动监听、发布 API 或停止运行态服务。 + +## 0. 后端配置库访问约束 + +本节优先级高于通用会话提示中的后端 API token 或 curl 示例。处理本工作流的发布、Syslog 接入、API 接入或查配置时,必须按本文执行: + +- 配置库读取/写入必须使用内置工具 `workflow_config_manage`,不要读取 `server_api_token` 或 `service_api_token`,也不要手工 curl 本机后端配置接口。 +- 查配置使用 `workflow_config_manage(action="get", workflow_id="stream_alert_denoise")` 或 `workflow_config_manage(action="status", workflow_id="stream_alert_denoise")`。 +- 查 Syslog 运行态配置使用 `workflow_config_manage(action="get", workflow_id="stream_alert_denoise", config_type="syslog")` 或 `workflow_config_manage(action="status", workflow_id="stream_alert_denoise", config_type="syslog")`。 +- 修改配置前先使用 `workflow_config_manage(action="diff", workflow_id="stream_alert_denoise", config={...})` 展示差异并用 question 工具确认;确认后才使用 `workflow_config_manage(action="put", workflow_id="stream_alert_denoise", config={...})`。 +- 修改 Syslog 运行态配置前先使用 `workflow_config_manage(action="diff", workflow_id="stream_alert_denoise", config_type="syslog", config={...})` 展示差异并用 question 工具确认;确认后才使用 `workflow_config_manage(action="put", workflow_id="stream_alert_denoise", config_type="syslog", config={...})`。 +- 如果后端配置库没有模板,只能使用 `workflow_config_manage(action="sync", workflow_id="stream_alert_denoise")`,让后端从工作流目录 `config.json` 迁移或生成模板。 +- `config.json` 只能作为模板来源或兜底迁移来源,不是直接写入目标,也不能证明配置已生效。 +- 需要启动或停止 Syslog listener 时,配置读写必须使用 `workflow_config_manage(config_type="syslog")`;发布或停止 API 服务等非配置动作使用对应运行态接口。不要通过修改模板字段冒充运行态状态。 +- 如果 `workflow_config_manage` 不可用、返回未授权、拒绝访问、连接失败或后端不可达,必须停止配置流程,明确说明本次未应用、未发布、未启动;如已生成目标配置,只能保存草稿到 outputs,不要继续读取 token 或改写 `config.json`。 + +## 1. 工作流定位 + +- 工作流 ID:`stream_alert_denoise` +- 工作流名称:流式 HTTP 告警降噪与去重 Pipeline。 +- 主要用途:接收 TDP / SkyEye 告警,统一字段,过滤扫描、非 HTTP 或低价值噪声,再用 URI 归一化 + 5-gram MinHash LSH 做跨批次去重。 +- 当前状态:`meta.json` 标记为 `active`。 +- 下游关系:`stream_alert_triage` 会读取本工作流写出的 `dedup_result_NNN.jsonl`,并基于 `dedup_key` 做 leader/follower 研判复用。 + +本工作流适合: + +- 安全设备通过 Syslog 实时推送单条告警。 +- 上游系统通过 API 提交 `alerts` 批次。 +- 用 `alert_file` 回放历史 JSON 告警。 +- 为下游 `stream_alert_triage` 提供带 `dedup_key`、`is_duplicate`、`_lsh_cluster_id` 的增强告警文件。 + +本工作流不负责: + +- 调查资产、用户、攻击链或处置结果。 +- 生成攻击研判报告。 +- 直接阻断、封禁或处置攻击。 +- 保存明文 API Key、密码、token。 + +## 2. AI 引导方式 + +如果用户点击的是发布/接入入口,优先判断输入模式和运行入口;如果用户点击的是规则入口,优先判断是否需要改过滤或去重参数;如果用户点击的是样例入口,优先让用户提供一条最小告警样例。 + +推荐提问顺序: + +1. 你要用哪种输入模式:Syslog 实时流、API 批量、文件回放,还是同时保留 Syslog 和 API? +2. 告警来源是否固定为 TDP 或 SkyEye,还是继续自动识别? +3. 是否保持默认过滤和去重规则? +4. 是否需要用样例做轻量验证? +5. 是否应用到发布配置、保存草稿,或暂不修改? + +如果用户只问“查一下现在怎么配的”,不要提问,直接按第 9 节只读检查。 + +## 3. 输入模式 + +工作流代码支持三种业务输入,解析优先级固定为: + +1. `syslog_message` 或 `syslog`:单条 Syslog 告警。 +2. `alerts` 或 `alert_list`:批量告警列表。 +3. `alert_file`:JSON 告警文件路径。 + +发布/触发层当前配置来自工作流目录下的 `config.json` 兜底模板: + +- `kind`: `workflow.integration-config` +- `publish.api.enabled`: `true` +- `publish.api.path`: `/api/workflow/stream_alert_denoise/run` +- `publish.syslog.enabled`: `true` +- `publish.syslog.host`: `0.0.0.0` +- `publish.syslog.port`: `514` +- `publish.syslog.protocol`: `udp` +- `triggers[0].id`: `syslog_main` +- `triggers[0].type`: `syslog` +- `triggers[0].config.input_field`: `syslog_message` +- `triggers[0].runtime.status`: `stopped` +- `apiKeyConfigured`: `false` + +输入模式建议: + +| 模式 | 适用场景 | 推荐配置 | +| --- | --- | --- | +| Syslog 实时流 | TDP / SkyEye 等设备持续推送单条告警 | 保留 Syslog trigger,默认 UDP `0.0.0.0:514`,输入字段 `syslog_message` | +| API 批量调用 | 上游系统或测试脚本提交 `alerts` 列表 | 保留 API 发布路径 `/api/workflow/stream_alert_denoise/run` | +| 文件回放 | 已有历史 JSON 文件,需要离线清洗去重 | 可以不发布触发器,使用手动测试或 API 传 `alert_file` | +| Syslog + API | 既要实时流,也要批量补录 | 保留当前模板中的 Syslog 和 API 两条入口 | + +默认推荐:保留 Syslog + API。当前 `config.json` 已经声明两者都启用,但运行态状态仍应以后端配置和运行接口为准。 + +互斥关系: + +- 业务输入在单次运行中按优先级互斥:同时传 `syslog_message` 和 `alerts` 时优先处理 `syslog_message`。 +- 发布能力可以同时存在:Syslog listener 和 API publish 可以同时声明。 + +## 4. 来源形态 + +支持来源: + +- TDP 原始 JSON 或扁平字段。 +- SkyEye 原始 JSON 或扁平字段。 +- Syslog 包裹的 TDP / SkyEye JSON,JSON 放在 `syslog_message.message`。 +- 混合批次,单条告警会再次按字段特征识别。 + +来源识别规则: + +| 线索 | 识别结果 | +| --- | --- | +| `source_log_type` 显式为 `tdp` 或 `skyeye` | 使用显式值 | +| Syslog `app_name` 或 `hostname` 包含 `tdp` | TDP | +| Syslog `app_name` 或 `hostname` 包含 `skyeye` | SkyEye | +| JSON 有嵌套 `net`、`behave_uuid`、`flow_id`、`net_real_src_ip`、`net_http_url`、`threat_suuid` | TDP | +| JSON 有 `uri`、`vuln_name`、`attack_result`、`attack_flag` | SkyEye | +| 仍无法判断 | 默认 TDP | + +归一化后的关键字段: + +| 标准字段 | 含义 | +| --- | --- | +| `sip` | 源 IP | +| `dip` | 目的 IP | +| `sport` / `dport` | 源/目的端口 | +| `net_type` / `net_app_proto` | 网络类型或应用协议 | +| `req_http_url` | HTTP 请求 URL | +| `req_host` / `req_user_agent` | HTTP Host 和 User-Agent | +| `req_body` / `rsp_body` | 请求体和响应体 | +| `threat_name` / `threat_type` | 威胁名称和类型 | +| `_source_type` | `tdp` 或 `skyeye` | +| `_syslog_meta` | Syslog 元数据,仅 Syslog 输入时存在 | + +如果用户要接入 TDP 平台做告警检索、下载 PCAP 或页面调查,必须另行遵循 `tdp-use` skill;本文只描述本工作流处理 TDP 格式告警的输入字段。 + +## 5. 输出去向 + +工作流返回: + +| 输出字段 | 说明 | +| --- | --- | +| `enriched_alerts` | 过滤和去重后的告警列表,去除了部分大体积 header/body 字段以降低运行历史体积 | +| `unique_alerts` | 本批次每个 `dedup_key` 的第一条代表告警 | +| `stats` | 原始数量、归一化数量、过滤数量、去重统计、LSH 状态统计、输出路径等 | +| `dedup_summary` | 一句话处理摘要 | +| `input_mode` | 本次实际输入模式 | +| `dedup_key` | 第一条增强告警的去重 key;无输出时为空字符串 | +| `is_duplicate` | 第一条增强告警是否为历史重复 | +| `output_path` | 本次最后一个写入的 JSONL 文件;如果本批全是历史重复则为空 | +| `output_paths` | 本次实际写入的 JSONL 文件列表 | + +每条 `enriched_alert` 追加: + +- `dedup_key` +- `is_duplicate` +- `_lsh_cluster_id` +- `_source_type` +- `_process_type` +- `_threat_type` + +结果文件路径: + +```text +~/.flocks/workspace/workflows/stream_alert_denoise//dedup_result_NNN.jsonl +``` + +写入规则: + +- 每个文件第一行是 `{ "_type": "file_header", "created_at", "date", "workflow", "seq" }`。 +- 每个文件最多 10000 条告警记录,不含首行 header。 +- `.dedup_counter.json` 记录当前文件序号和条数。 +- 只持久化跨批次首见告警,即 `is_duplicate=false` 的告警。 +- 历史重复告警仍会出现在本次 API 返回的 `enriched_alerts` 里,但不会再次写入 JSONL。 + +## 6. 处理规则 + +默认参数: + +| 参数 | 默认值 | 说明 | +| --- | --- | --- | +| `source_log_type` | 自动识别,失败默认 `tdp` | 可显式指定 `tdp` 或 `skyeye` | +| `filter_enabled` | `true` | 是否启用过滤 | +| `dedup_enabled` | `true` | 是否启用 LSH 去重和状态持久化 | +| `threshold` | `0.7` | MinHash LSH 相似度阈值,越高越严格 | +| `strict_fields` | `["sip", "dip"]` | 生成 `dedup_key` 前必须精确参与的字段 | +| `lsh_fields` | `["req_http_url", "req_body", "rsp_body"]` | 参与 URI 归一化和 5-gram 相似度的字段 | +| `max_field_len` | `500` | 每个参与字段截断长度 | +| `max_dedup_keys` | `100000` | 持久化去重 key 和 LSH cluster 上限,小于 1 时回退 100000 | + +过滤规则: + +- TDP 告警:保留非扫描、HTTP、方向为 `in`、`out` 或 `lateral` 的告警。 +- SkyEye 告警:扫描类走 `alert_scan_direction_in`,非扫描类按 HTTP 入向处理。 +- 扫描判断:`threat_name` 包含“扫描”且不包含 `webshell`。 +- HTTP 判断:`application_layer_protocol`、`net_type` 或 `net_app_proto` 任一字段包含 `http`。 +- `filter_enabled=false` 时,所有归一化告警进入去重,`_process_type=filter_disabled`。 + +去重规则: + +- URI 会归一化日期、UUID、目录穿越、NULL、`chr$...`、数字比较、32 位十六进制串等高变形片段。 +- MinHash 使用 128 permutations 和 5-gram shingles。 +- `dedup_key = MD5(strict_fields 文本 + lsh_cluster_id)`。 +- LSH 状态保存在: + +```text +~/.flocks/workspace/workflows/stream_alert_denoise/lsh_state_np128_th70.pkl +~/.flocks/workspace/workflows/stream_alert_denoise/lsh_state_np128_th70.append.log +~/.flocks/workspace/workflows/stream_alert_denoise/lsh_state_np128_th70.lock +``` + +低层参数默认隐藏,不主动询问:`max_field_len`、MinHash seed、`NUM_PERM`、counter 文件名、append-log compact 阈值。只有用户明确要求调优或排障时再解释。 + +## 7. 样例验证 + +Syslog 最小样例: + +```json +{ + "syslog_message": { + "hostname": "tdp-sensor", + "app_name": "tdp", + "timestamp": "2026-05-12T10:00:00", + "severity": 6, + "facility": 16, + "format": "rfc3164", + "message": "{\"id\":\"AZtRkZkzj\",\"net\":{\"http\":{\"url\":\"/admin\"}},\"threat\":{\"name\":\"SQL注入\"}}" + } +} +``` + +API 批量最小样例: + +```json +{ + "source_log_type": "tdp", + "filter_enabled": true, + "dedup_enabled": true, + "threshold": 0.7, + "alerts": [ + { + "net_real_src_ip": "1.2.3.4", + "net_dest_ip": "10.0.0.1", + "direction": "in", + "net_type": "http", + "net_http_url": "/admin/login.php?id=1 OR 1=1", + "net_http_reqs_body": "username=admin&password=123456", + "net_http_resp_body": "root@localhost", + "threat_name": "SQL注入攻击", + "threat_type": "web攻击" + } + ] +} +``` + +轻量验证优先检查: + +1. `receive_alert` 是否识别为 `syslog`、`alerts` 或 `alert_file`。 +2. `source_log_type_reason` 是否合理。 +3. `normalize` 是否产出 `sip`、`dip`、`req_http_url`、`threat_name`。 +4. `filter_logs` 是否符合保留/过滤预期。 +5. `dedup_and_write` 是否生成稳定 `dedup_key` 和 `_lsh_cluster_id`。 +6. `output_paths` 是否指向当日 `dedup_result_NNN.jsonl`,或者在全重复时为空。 + +验证注意: + +- 如果只想验证字段映射和过滤,建议先用 `dedup_enabled=false`,避免改动生产 LSH 状态。 +- 如果要验证跨批次去重,必须说明会更新本工作流的 LSH 状态文件,并用 question 工具确认。 +- 不要为了样例验证启动或停止 Syslog/API 服务,除非用户明确要求。 + +## 8. 应用方式 + +发布配置模板的生效来源: + +1. 优先用 `workflow_config_manage(action="get", workflow_id="stream_alert_denoise")` 读取后端 Storage/SQL 的生效配置。 +2. 如果库里没有,调用 `workflow_config_manage(action="sync", workflow_id="stream_alert_denoise")`,由后端读取工作流目录下的 `config.json` 并迁移到 Storage/SQL。 +3. 如果要读取或修改 Syslog listener 配置,必须使用 `workflow_config_manage(action="get" -> "diff" -> "put", workflow_id="stream_alert_denoise", config_type="syslog")`。 +4. `config.json` 是导入/兜底模板,不是运行态开关。 +5. 不要直接写 `config.json` 来表示发布、接入或触发配置已经生效。 +6. 不要读取 `server_api_token`,不要用 curl 调 `/api/workflow/stream_alert_denoise/syslog-config` 读取或写入 Syslog 配置。 +7. 启停、发布、取消发布等非配置运行态动作必须调用运行时接口,不要通过修改 `config.json` 完成。 +8. 如果 `workflow_config_manage` 或后端配置库不可用,只能把目标配置保存为草稿到 outputs,并明确说明未应用、未发布、未启动。 + +应用变更前必须展示: + +- 计划。 +- publish / triggers / 参数模板 diff。 +- Syslog 配置变更时必须展示 `workflow_config_manage(config_type="syslog")` 生成的 diff。 +- 影响说明,特别是是否会改变 LSH 状态、JSONL 写入或运行态服务。 +- question 工具确认:应用、保存草稿或暂不修改。 +- 用户确认应用后,使用 `workflow_config_manage(action="put", workflow_id="stream_alert_denoise", config_type="", config={...})` 写入完整配置。 + +当前 `config.json` 已经是 runtime 消费的结构:`kind: workflow.integration-config`,顶层包含 `publish` 和 `triggers`。不要生成旧的 `publishTemplates` wrapper。 + +## 9. 查配置 + +只读检查顺序: + +1. 读取本文。 +2. 读取 `workflow.md` 和 `workflow.json`。 +3. 调用 `workflow_config_manage(action="get", workflow_id="stream_alert_denoise")` 或 `workflow_config_manage(action="status", workflow_id="stream_alert_denoise")`。 +4. 调用 `workflow_config_manage(action="get", workflow_id="stream_alert_denoise", config_type="syslog")` 或 `workflow_config_manage(action="status", workflow_id="stream_alert_denoise", config_type="syslog")`。 +5. 如后端无配置,再查看工作流目录下 `config.json` 是否只是兜底模板。 +6. 汇总已配置项、缺失项和最推荐下一步。 + +查配置时重点报告: + +- API 发布是否启用、路径是什么、是否已配置 API Key。 +- Syslog trigger 是否存在、host/port/protocol/input_field 是什么、运行态是否 started。 +- 当前建议输入模式。 +- 默认过滤和去重规则。 +- 最近一次输出路径只能作为运行结果线索,不应从 guide 或 config 里臆测。 + +查配置不得修改文件、启动监听、发布 API、停止服务或写运行态状态。 diff --git a/.flocks/flockshub/plugins/workflows/stream_alert_denoise/manifest.json b/.flocks/flockshub/plugins/workflows/stream_alert_denoise/manifest.json new file mode 100644 index 000000000..bed88fab5 --- /dev/null +++ b/.flocks/flockshub/plugins/workflows/stream_alert_denoise/manifest.json @@ -0,0 +1,68 @@ +{ + "schemaVersion": "hub.plugin.v1", + "id": "stream_alert_denoise", + "type": "workflow", + "name": "Stream Alert Denoise", + "nameCn": "流式HTTP降噪工作流", + "description": "Streaming alert denoise and deduplication pipeline with Syslog/API input.", + "descriptionCn": "流式告警降噪与去重工作流,支持 Syslog 和 API 输入。", + "version": "1.0.0", + "author": "Flocks Team", + "license": "MIT", + "homepage": "", + "category": "workflow-automation", + "tags": [ + "siem", + "ndr", + "web-security" + ], + "useCases": [ + "alert-triage", + "log-analysis", + "security-reporting" + ], + "domains": [ + "security-ops" + ], + "capabilities": [ + "workflow", + "scheduled-task" + ], + "trust": "official", + "source": { + "kind": "bundled", + "path": "plugins/workflows/stream_alert_denoise" + }, + "compatibility": { + "flocks": ">=0.8.0", + "os": [ + "darwin", + "linux", + "windows" + ] + }, + "dependencies": { + "skills": [], + "tools": [], + "python": [], + "external": [] + }, + "permissions": { + "tools": [], + "network": false, + "shell": false, + "filesystem": "read-write" + }, + "risk": { + "level": "medium", + "reasons": [] + }, + "entrypoints": [ + "workflow.json", + "workflow.md", + "guide.md", + "config.json" + ], + "components": [], + "checksums": {} +} diff --git a/.flocks/flockshub/plugins/workflows/stream_alert_denoise/meta.json b/.flocks/flockshub/plugins/workflows/stream_alert_denoise/meta.json new file mode 100644 index 000000000..fd54fc9f6 --- /dev/null +++ b/.flocks/flockshub/plugins/workflows/stream_alert_denoise/meta.json @@ -0,0 +1,11 @@ +{ + "name": "stream_alert_denoise", + "nameCn": "流式HTTP降噪工作流", + "description": "Streaming-friendly HTTP alert deduplication pipeline: supports syslog real-time single alerts, alerts list, or alert_file path. Normalizes (TDP/Skyeye auto-detection, mixed batches) -> filters (remove scans / non-HTTP) -> deduplicates (URI normalization + 5-gram Jaccard MinHash LSH). Each output alert carries the full normalized fields plus dedup annotation: dedup_key (MD5), is_duplicate (cross-batch), _lsh_cluster_id, _source_type, _process_type. Results appended to JSONL files: ~/.flocks/workspace/workflows/stream_alert_denoise//dedup_result_NNN.jsonl (max 10,000 records per file; each file starts with a timestamp header line).", + "category": "default", + "status": "active", + "createdBy": null, + "createdAt": 1780892155315, + "updatedAt": 1782910584761, + "id": "stream_alert_denoise" +} diff --git a/.flocks/flockshub/plugins/workflows/stream_alert_denoise/workflow.json b/.flocks/flockshub/plugins/workflows/stream_alert_denoise/workflow.json new file mode 100644 index 000000000..12f97ed24 --- /dev/null +++ b/.flocks/flockshub/plugins/workflows/stream_alert_denoise/workflow.json @@ -0,0 +1,110 @@ +{ + "name": "stream_alert_denoise", + "nameCn": "流式HTTP降噪工作流", + "description": "Streaming-friendly HTTP alert deduplication pipeline: supports syslog real-time single alerts, alerts list, or alert_file path. Normalizes (TDP/Skyeye auto-detection, mixed batches) -> filters (remove scans / non-HTTP) -> deduplicates (URI normalization + 5-gram Jaccard MinHash LSH). Each output alert carries the full normalized fields plus dedup annotation: dedup_key (MD5), is_duplicate (cross-batch), _lsh_cluster_id, _source_type, _process_type. Results appended to JSONL files: ~/.flocks/workspace/workflows/stream_alert_denoise//dedup_result_NNN.jsonl (max 10,000 records per file; each file starts with a timestamp header line).", + "description_cn": "流式 HTTP 告警去重 Pipeline。支持三种输入:syslog 实时单条、alerts 批次列表、alert_file 文件路径。处理流程:归一化 → 过滤 → 去重(URI 归一化 + 5-gram MinHash LSH,跨批次持久化,FIFO LRU)。输出告警保留全部归一化字段,追加去重字段:dedup_key、is_duplicate、_lsh_cluster_id 等。结果追加写入 ~/.flocks/workspace/workflows/stream_alert_denoise//dedup_result_NNN.jsonl,每文件最多 10,000 条(不含首行 header),超出时自动新建序号文件,每个文件首行为含时间戳的 header JSON 行。", + "start": "receive_alert", + "nodes": [ + { + "id": "receive_alert", + "type": "python", + "description": "Parse incoming alert(s): syslog_message (single alert, RFC3164/5424) > alerts list > alert_file. Auto-detects source_log_type (TDP/Skyeye) from syslog app_name/hostname, then JSON field signatures, then defaults to 'tdp'.", + "code": "\nimport json\nimport os\n\n# Input priority: syslog_message > alerts > alert_file\nalerts_input = []\ninput_mode = 'unknown'\n_syslog_msg = None\n\nsyslog_msg = inputs.get('syslog_message') or inputs.get('syslog')\nif syslog_msg and isinstance(syslog_msg, dict):\n raw_text = syslog_msg.get('message', '')\n if raw_text:\n try:\n alert = json.loads(raw_text)\n alert['_syslog_meta'] = {\n 'hostname': syslog_msg.get('hostname', ''),\n 'app_name': syslog_msg.get('app_name', ''),\n 'timestamp': syslog_msg.get('timestamp', ''),\n 'severity': syslog_msg.get('severity'),\n 'facility': syslog_msg.get('facility'),\n 'format': syslog_msg.get('format', ''),\n }\n alerts_input = [alert]\n input_mode = 'syslog'\n _syslog_msg = syslog_msg\n print(f'[receive] syslog mode: host={syslog_msg.get(\"hostname\")!r} '\n f'app={syslog_msg.get(\"app_name\")!r} '\n f'severity={syslog_msg.get(\"severity\")} '\n f'format={syslog_msg.get(\"format\")!r}')\n except (json.JSONDecodeError, TypeError) as _e:\n print(f'[receive] WARNING: syslog.message not valid JSON ({_e}), '\n f'raw={raw_text[:120]!r}')\n else:\n print('[receive] WARNING: syslog_message.message is empty, skipping')\n\nif not alerts_input:\n alerts_input = inputs.get('alerts', inputs.get('alert_list', []))\n if alerts_input:\n input_mode = 'alerts'\n\nif not alerts_input:\n alert_file = inputs.get('alert_file', '')\n if alert_file:\n alert_file = os.path.expanduser(str(alert_file))\n try:\n with open(alert_file, 'r', encoding='utf-8') as _f:\n alerts_input = json.load(_f)\n input_mode = 'alert_file'\n print(f'[receive] file mode: loaded {len(alerts_input) if isinstance(alerts_input, list) else 1} alerts')\n except Exception as _e:\n print(f'[receive] WARNING: failed to load alert_file={alert_file!r}: {_e}')\n alerts_input = []\n\nif isinstance(alerts_input, str):\n try:\n alerts_input = json.loads(alerts_input)\n except Exception:\n alerts_input = []\nif isinstance(alerts_input, dict) and 'data' in alerts_input:\n alerts_input = alerts_input.get('data', [])\nif not isinstance(alerts_input, list):\n alerts_input = [alerts_input] if alerts_input else []\n\nif not alerts_input:\n print('[receive] WARNING: no alerts (syslog_message, alerts, alert_file all empty)')\n\n# ── Source log type resolution ────────────────────────────────────────────────\ndef _detect_from_syslog_meta(sm):\n for field in ('app_name', 'hostname'):\n val = str(sm.get(field, '') or '').lower()\n if 'skyeye' in val:\n return 'skyeye', f'syslog.{field}={sm.get(field)!r}'\n if 'tdp' in val:\n return 'tdp', f'syslog.{field}={sm.get(field)!r}'\n return None, None\n\ndef _detect_from_alert_json(alert):\n if not isinstance(alert, dict):\n return None, None\n if isinstance(alert.get('net'), dict):\n return 'tdp', 'alert has nested net dict (TDP)'\n if any(k in alert for k in ('behave_uuid', 'flow_id')):\n return 'tdp', 'alert has behave_uuid/flow_id (TDP)'\n if any(k in alert for k in ('net_real_src_ip', 'net_http_url', 'threat_suuid')):\n return 'tdp', 'alert has pre-flattened TDP fields'\n if any(k in alert for k in ('uri', 'vuln_name', 'attack_result', 'attack_flag')):\n return 'skyeye', 'alert has uri/vuln_name/attack_result (Skyeye)'\n return None, None\n\nexplicit_type = str(inputs.get('source_log_type', '') or '').lower()\nif explicit_type in ('tdp', 'skyeye'):\n source_log_type = explicit_type\n source_log_type_reason = 'explicit input parameter'\nelif input_mode == 'syslog' and _syslog_msg:\n source_log_type, reason = _detect_from_syslog_meta(_syslog_msg)\n if source_log_type:\n source_log_type_reason = f'syslog metadata: {reason}'\n else:\n first_alert = alerts_input[0] if alerts_input else {}\n source_log_type, reason = _detect_from_alert_json(first_alert)\n if source_log_type:\n source_log_type_reason = f'JSON field detection: {reason}'\n else:\n source_log_type = 'tdp'\n source_log_type_reason = 'fallback default'\nelse:\n first_alert = alerts_input[0] if alerts_input else {}\n source_log_type, reason = _detect_from_alert_json(first_alert)\n if source_log_type:\n source_log_type_reason = f'JSON field detection: {reason}'\n else:\n source_log_type = 'tdp'\n source_log_type_reason = 'default'\n\nprint(f'[receive] source_log_type={source_log_type!r} reason={source_log_type_reason!r}')\n\nfilter_enabled = bool(inputs.get('filter_enabled', True))\ndedup_enabled = bool(inputs.get('dedup_enabled', True))\nthreshold = float(inputs.get('threshold', 0.7))\nstrict_fields = inputs.get('strict_fields', ['sip', 'dip'])\nlsh_fields = inputs.get('lsh_fields', ['req_http_url', 'req_body', 'rsp_body'])\nmax_field_len = int(inputs.get('max_field_len', 500))\nmax_dedup_keys = int(inputs.get('max_dedup_keys', 100000))\nif max_dedup_keys < 1:\n max_dedup_keys = 100000\n\nprint(f'[receive] input_mode={input_mode} raw_alerts={len(alerts_input)} '\n f'filter_enabled={filter_enabled} dedup_enabled={dedup_enabled} '\n f'max_dedup_keys={max_dedup_keys}')\n\noutputs['raw_alerts'] = alerts_input\noutputs['input_mode'] = input_mode\noutputs['source_log_type'] = source_log_type\noutputs['source_log_type_reason'] = source_log_type_reason\noutputs['filter_enabled'] = filter_enabled\noutputs['dedup_enabled'] = dedup_enabled\noutputs['dedup_threshold'] = threshold\noutputs['strict_fields'] = strict_fields\noutputs['lsh_fields'] = lsh_fields\noutputs['max_field_len'] = max_field_len\noutputs['max_dedup_keys'] = max_dedup_keys\noutputs['stats'] = {'raw_count': len(alerts_input)}\n" + }, + { + "id": "normalize", + "type": "python", + "description": "Normalize TDP and Skyeye alerts into a unified schema. Per-alert type detection via field signatures; falls back to batch_hint. Carries _syslog_meta and _source_type to downstream nodes.", + "code": "\nimport uuid\n\nHTTP_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'HEAD', 'OPTIONS', 'PATCH', 'TRACE']\n\nTDP_FIELD_MAP = {\n 'customer_uuid': 'customer_uuid',\n 'device_id': 'device_id',\n 'id': 'id',\n 'time': 'time',\n 'direction': 'direction',\n 'sip': 'net_real_src_ip',\n 'dip': 'net_dest_ip',\n 'sport': 'net_src_port',\n 'dport': 'net_dest_port',\n 'net_type': 'net_type',\n 'net_app_proto': 'net_app_proto',\n 'req_http_url': 'net_http_url',\n 'req_user_agent':'net_http_reqs_user_agent',\n 'req_host': 'net_http_reqs_host',\n 'req_line': 'net_http_reqs_line',\n 'req_header': 'net_http_reqs_header',\n 'req_body': 'net_http_reqs_body',\n 'req_cookie': 'net_http_reqs_cookie',\n 'req_body_len': 'net_http_reqs_content_length',\n 'rsp_status_code': 'net_http_status',\n 'rsp_line': 'net_http_resp_line',\n 'rsp_header': 'net_http_resp_header',\n 'rsp_body': 'net_http_resp_body',\n 'rsp_body_len': 'net_http_resp_content_length',\n 'net_bytes_toclient': 'net_bytes_toclient',\n 'net_bytes_toserver': 'net_bytes_toserver',\n 'threat_rule_id': 'threat_suuid',\n 'threat_name': 'threat_name',\n 'threat_msg': 'threat_msg',\n 'threat_ioc': 'threat_ioc',\n 'threat_level': 'threat_level',\n 'threat_severity': 'threat_severity',\n 'threat_phase': 'threat_phase',\n 'threat_type': 'threat_type',\n 'threat_result': 'threat_result',\n 'threat_confidence': 'threat_confidence',\n 'connection_established': 'established',\n 'asset_group_name': 'dest_assets_group_name',\n 'asset_name': 'dest_assets_latestName',\n}\n\nSKYEYE_FIELD_MAP = {\n 'id': 'none',\n 'time': 'time',\n 'direction': 'none',\n 'sip': 'sip',\n 'dip': 'dip',\n 'sport': 'sport',\n 'dport': 'dport',\n 'net_type': 'none',\n 'net_app_proto': 'none',\n 'req_http_url': 'uri',\n 'req_user_agent':'agent',\n 'req_host': 'host',\n 'req_line': 'none',\n 'req_header': 'req_header',\n 'req_body': 'req_body',\n 'req_cookie': 'none',\n 'req_body_len': 'none',\n 'rsp_status_code': 'rsp_status',\n 'rsp_line': 'none',\n 'rsp_header': 'rsp_header',\n 'rsp_body': 'rsp_body',\n 'rsp_body_len': 'rsp_body_len',\n 'threat_rule_id': 'rule_id',\n 'threat_name': 'vuln_name',\n 'threat_msg': 'vuln_desc',\n 'threat_ioc': 'none',\n 'threat_level': 'none',\n 'threat_severity': 'severity',\n 'threat_phase': 'none',\n 'threat_type': 'vuln_type',\n 'threat_tactic_id': 'attck_tactic',\n 'threat_technique_id': 'attck_tech',\n 'threat_result': 'attack_result',\n 'threat_confidence': 'confidence',\n 'connection_established': 'established',\n 'real_attack': 'attack_flag',\n}\n\ndef flatten_dict(d, prefix=''):\n res = {}\n for k, v in d.items():\n if isinstance(v, dict):\n res.update(flatten_dict(v, f'{prefix}{k}_'))\n else:\n res[f'{prefix}{k}'] = v\n return res\n\ndef make_uuid(norm):\n return str(uuid.uuid3(uuid.NAMESPACE_DNS, ''.join(str(v) for v in norm.values())))\n\ndef detect_alert_type(alert, batch_hint):\n if isinstance(alert.get('net'), dict):\n return 'tdp'\n if any(k in alert for k in ('behave_uuid', 'flow_id')):\n return 'tdp'\n if any(k in alert for k in ('net_real_src_ip', 'net_http_url', 'threat_suuid')):\n return 'tdp'\n if any(k in alert for k in ('uri', 'vuln_name', 'attack_result', 'attack_flag')):\n return 'skyeye'\n return batch_hint\n\ndef normalize_single(alert, source_type):\n flat = flatten_dict(alert)\n field_map = TDP_FIELD_MAP if source_type == 'tdp' else SKYEYE_FIELD_MAP\n norm = {}\n for std_key, raw_key in field_map.items():\n norm[std_key] = flat.get(raw_key, 'none') if raw_key != 'none' else 'none'\n if norm.get('id') in ('none', None, ''):\n norm['id'] = make_uuid(norm)\n if norm.get('net_type') in ('none', None, ''):\n method = flat.get('method', 'none')\n norm['net_type'] = 'http' if method in HTTP_METHODS else ('none' if method == 'none' else 'other')\n norm['_source_type'] = source_type\n # Carry syslog metadata if present\n if '_syslog_meta' in alert:\n norm['_syslog_meta'] = alert['_syslog_meta']\n return norm\n\nraw_alerts = inputs.get('raw_alerts', [])\nstats = dict(inputs.get('stats', {}))\nbatch_hint = str(inputs.get('source_log_type', 'tdp') or 'tdp').lower()\nif batch_hint not in ('tdp', 'skyeye'):\n batch_hint = 'tdp'\n\ntype_counts = {'tdp': 0, 'skyeye': 0}\nnormalized = []\nfor alert in raw_alerts:\n src_type = detect_alert_type(alert, batch_hint)\n type_counts[src_type] = type_counts.get(src_type, 0) + 1\n normalized.append(normalize_single(alert, src_type))\n\nstats['normalized_count'] = len(normalized)\nstats['normalize_type_counts'] = type_counts\nprint(f'[normalize] {len(raw_alerts)} alerts -> {len(normalized)} normalized '\n f'(tdp={type_counts.get(\"tdp\",0)}, skyeye={type_counts.get(\"skyeye\",0)}, '\n f'batch_hint={batch_hint!r})')\n\noutputs['normalized_alerts'] = normalized\noutputs['stats'] = stats\nfor k in ['input_mode', 'source_log_type', 'filter_enabled', 'dedup_enabled',\n 'dedup_threshold', 'strict_fields', 'lsh_fields', 'max_field_len', 'max_dedup_keys']:\n outputs[k] = inputs.get(k)\n" + }, + { + "id": "filter_logs", + "type": "python", + "description": "Filter: classify into 9 process_types, keep non-scan HTTP alerts (direction in/out/lateral). Adds _process_type and _threat_type fields. When filter_enabled=False, all alerts pass through.", + "code": "\nnormalized_alerts = inputs.get('normalized_alerts', [])\nfilter_enabled = inputs.get('filter_enabled', True)\nbatch_hint = str(inputs.get('source_log_type', 'tdp') or 'tdp').lower()\nif batch_hint not in ('tdp', 'skyeye'):\n batch_hint = 'tdp'\nstats = dict(inputs.get('stats', {}))\n\ndef is_scan_alert(threat_name):\n tnl = str(threat_name or '').lower()\n return ('扫描' in tnl) and ('webshell' not in tnl)\n\ndef get_threat_type(alert):\n src = alert.get('_source_type') or batch_hint\n if src == 'skyeye':\n return str(alert.get('threat_type', 'general') or 'general')\n return str(alert.get('threat_name', 'general') or 'general')\n\ndef is_http(alert):\n for field in ('application_layer_protocol', 'net_type', 'net_app_proto'):\n val = str(alert.get(field, '') or '').lower()\n if val and val != 'none' and 'http' in val:\n return True\n return False\n\ndef get_process_type(alert):\n src = alert.get('_source_type') or batch_hint\n threat_name = alert.get('threat_name', '')\n direction = str(alert.get('direction', '') or '').lower()\n scan = is_scan_alert(threat_name)\n http = is_http(alert)\n if src == 'skyeye':\n return 'alert_scan_direction_in' if scan else 'alert_not_scan_http_direction_in'\n if scan:\n return f'alert_scan_direction_{direction}' if direction in ('in', 'out', 'lateral') else 'alert_scan_direction_in'\n if http:\n return f'alert_not_scan_http_direction_{direction}' if direction in ('in', 'out', 'lateral') else 'alert_not_scan_http_direction_in'\n return f'alert_not_scan_not_http_direction_{direction}' if direction in ('in', 'out', 'lateral') else 'alert_not_process'\n\nNEED_ANALYSIS = {\n 'alert_not_scan_http_direction_in',\n 'alert_not_scan_http_direction_out',\n 'alert_not_scan_http_direction_lateral',\n}\n\nfiltered = []\nprocess_type_counts = {}\nfor alert in normalized_alerts:\n alert = dict(alert)\n if filter_enabled:\n ptype = get_process_type(alert)\n need = ptype in NEED_ANALYSIS\n threat_type = get_threat_type(alert)\n else:\n ptype = 'filter_disabled'\n need = True\n threat_type = get_threat_type(alert)\n process_type_counts[ptype] = process_type_counts.get(ptype, 0) + 1\n alert['_process_type'] = ptype\n alert['_threat_type'] = threat_type\n if need:\n filtered.append(alert)\n\nprint(f'[filter] input={len(normalized_alerts)}, kept={len(filtered)}')\nprint(f'[filter] process_type_counts={process_type_counts}')\n\nstats['after_filter_count'] = len(filtered)\nstats['filter_removed_count'] = len(normalized_alerts) - len(filtered)\nstats['filter_process_type_counts'] = process_type_counts\n\noutputs['filtered_alerts'] = filtered\noutputs['stats'] = stats\nfor k in ['input_mode', 'dedup_enabled', 'dedup_threshold', 'strict_fields',\n 'lsh_fields', 'max_field_len', 'max_dedup_keys']:\n outputs[k] = inputs.get(k)\n" + }, + { + "id": "dedup_and_write", + "type": "python", + "description": "Dedup (terminal): URI normalization + MinHash LSH (128 perms, 5-gram). LSH state persisted to ~/.flocks/workspace/workflows/stream_alert_denoise/ (atomic write, file lock, FIFO LRU eviction). Each output alert = normalized fields + dedup_key + is_duplicate + _lsh_cluster_id. Appends enriched alerts to JSONL files under ~/.flocks/workspace/workflows/stream_alert_denoise//dedup_result_NNN.jsonl. Each new file begins with a header line {_type:file_header, created_at, ...}; max 10,000 alert records per file, auto-increments sequence number on rollover.", + "code": "\nimport os\nimport re\nimport sys\nimport gc as _gc_module\nimport json\nimport pickle\nimport hashlib\nimport datetime\nimport threading\nimport types\nfrom datasketch import MinHash, MinHashLSH\n\nIS_WINDOWS = sys.platform == 'win32'\nif IS_WINDOWS:\n import msvcrt # noqa: F401\nelse:\n import fcntl # noqa: F401\n\nMINHASH_SEED = 2024\nNUM_PERM = 128\nWORKFLOW_NAME = 'stream_alert_denoise'\nLSH_CLUSTER_WARN_THRESHOLD = 100000\n\n# ── Process-level in-memory LSH state cache ──────────────────────────────────\n# Previously the ~649 MB pickle was loaded from disk on EVERY syslog message,\n# causing linear memory growth (Python GC cannot free old objects fast enough\n# under high throughput). We now keep the live MinHashLSH + lsh_cache +\n# dedup_key_cache in sys.modules between exec() invocations. A\n# threading.Lock serialises concurrent workflow threads; the file lock\n# (fcntl/msvcrt) still guards cross-process disk writes.\n_MEM_CACHE_KEY = f'_flocks_lsh_cache_{WORKFLOW_NAME}'\nif _MEM_CACHE_KEY not in sys.modules:\n _m = types.ModuleType(_MEM_CACHE_KEY)\n _m.lsh_index = None\n _m.lsh_cache = {}\n _m.dedup_key_cache = {}\n _m.next_cluster_id = 0\n _m.threshold = None\n _m.state_mtime = 0.0\n _m.append_mtime = 0.0\n _m.initialized = False\n _m.permutations = None\n _m.lock = threading.Lock()\n sys.modules[_MEM_CACHE_KEY] = _m\n_mem = sys.modules[_MEM_CACHE_KEY]\nif not hasattr(_mem, 'append_mtime'):\n _mem.append_mtime = 0.0\nif not hasattr(_mem, 'permutations'):\n _mem.permutations = None\n\ndef normalize_uri(uri):\n uri = str(uri or '')\n uri = re.sub(r'\\d{4}-\\d{2}-\\d{2}', 'DATETIME', uri)\n uri = re.sub(r'[\\da-f]{8}-[\\da-f]{4}-[\\da-f]{4}-[\\da-f]{4}-[\\da-f]{12}', 'UUID', uri, flags=re.IGNORECASE)\n uri = re.sub(r'(\\.\\./)+', 'TRAVERSAL', uri)\n uri = re.sub(r'\\bNULL\\b', 'NULL_REPLACED', uri)\n uri = re.sub(r'chr\\$\\d+\\$\\|\\|chr\\$\\d+\\$', 'CHR_SEQUENCE', uri)\n uri = re.sub(r'\\b\\d+={1,2}\\d+\\b', 'NUMBER_COMPARISON', uri)\n uri = re.sub(r'\\b[a-fA-F0-9]{32}\\b', 'HEXADECIMAL CHARACTERS', uri)\n return uri\n\ndef gen_minhash(text, permutations):\n shingles = [text[i:i+5] for i in range(len(text) - 4)]\n m = MinHash(num_perm=NUM_PERM, seed=MINHASH_SEED, permutations=permutations)\n for s in shingles:\n m.update(s.encode('utf-8'))\n return m\n\ndef get_state_paths(threshold):\n from flocks.config import Config\n flocks_root = Config().get_global().data_dir.parent\n state_dir = str(flocks_root / 'workspace' / 'workflows' / WORKFLOW_NAME)\n os.makedirs(state_dir, exist_ok=True)\n base = os.path.join(state_dir, f'lsh_state_np{NUM_PERM}_th{int(threshold * 100)}')\n return base + '.pkl', base + '.lock', base + '.append.log'\n\ndef get_output_dir():\n from flocks.config import Config\n from pathlib import Path\n flocks_root = Config().get_global().data_dir.parent\n date_str = datetime.datetime.now().strftime('%Y-%m-%d')\n out_dir = flocks_root / 'workspace' / 'workflows' / WORKFLOW_NAME / date_str\n out_dir.mkdir(parents=True, exist_ok=True)\n return str(out_dir)\n\ndef acquire_lock(lock_path):\n fh = open(lock_path, 'w+')\n try:\n if IS_WINDOWS:\n fh.write('L'); fh.flush(); fh.seek(0)\n while True:\n try:\n msvcrt.locking(fh.fileno(), msvcrt.LK_LOCK, 1); break\n except OSError:\n continue\n else:\n fcntl.flock(fh.fileno(), fcntl.LOCK_EX)\n except BaseException:\n try:\n fh.close()\n except Exception:\n pass\n raise\n return fh\n\ndef release_lock(fh):\n try:\n if IS_WINDOWS:\n try:\n fh.seek(0); msvcrt.locking(fh.fileno(), msvcrt.LK_UNLCK, 1)\n except OSError:\n pass\n else:\n fcntl.flock(fh.fileno(), fcntl.LOCK_UN)\n finally:\n fh.close()\n\ndef load_state(state_path, append_path, threshold, max_keys=100000):\n lsh_index = None\n lsh_cache = {}\n dedup_key_cache = {}\n next_cid = 0\n if os.path.exists(state_path) and os.path.getsize(state_path) > 0:\n try:\n with open(state_path, 'rb') as f:\n state = pickle.load(f)\n if state.get('num_perm') != NUM_PERM or state.get('threshold') != threshold:\n print('[dedup] state params mismatch, starting fresh')\n else:\n raw_lsh_cache = state['lsh_cache']\n seen_raw = state.get('dedup_key_cache', {})\n raw_dedup_cache = {k: None for k in seen_raw} if isinstance(seen_raw, set) else (dict(seen_raw) if isinstance(seen_raw, dict) else {})\n oversized = len(raw_lsh_cache) > max_keys\n if oversized:\n # Snapshot grew beyond limit (no eviction in old versions).\n # Rebuild LSH index from the NEWEST max_keys clusters only to\n # avoid loading millions of entries into RAM all at once.\n keep_cids = set(list(raw_lsh_cache.keys())[-max_keys:])\n lsh_cache = {cid: mh for cid, mh in raw_lsh_cache.items() if cid in keep_cids}\n lsh_index = MinHashLSH(threshold=threshold, num_perm=NUM_PERM)\n for cid, mh in lsh_cache.items():\n try: lsh_index.insert(cid, mh)\n except Exception: pass\n dedup_key_cache = dict(list(raw_dedup_cache.items())[-max_keys:])\n print(f'[dedup] snapshot truncated {len(raw_lsh_cache)}→{len(lsh_cache)} clusters '\n f'(was over limit {max_keys})')\n del raw_lsh_cache, raw_dedup_cache, state\n _gc_module.collect()\n else:\n lsh_index = state['lsh_index']\n lsh_cache = raw_lsh_cache\n dedup_key_cache = raw_dedup_cache\n next_cid = (max(lsh_cache.keys()) + 1) if lsh_cache else 0\n print(f'[dedup] loaded snapshot: {len(lsh_cache)} clusters, {len(dedup_key_cache)} dedup_keys, next_cid={next_cid}')\n except Exception as e:\n print(f'[dedup] failed to load snapshot ({e}), starting fresh')\n lsh_index = None\n lsh_cache = {}\n dedup_key_cache = {}\n next_cid = 0\n if lsh_index is None:\n lsh_index = MinHashLSH(threshold=threshold, num_perm=NUM_PERM)\n lsh_cache = {}\n dedup_key_cache = {}\n next_cid = 0\n # Replay incremental append-log: only first-seen clusters/keys are stored there.\n if os.path.exists(append_path) and os.path.getsize(append_path) > 0:\n replayed = 0\n try:\n with open(append_path, 'rb') as f:\n while True:\n try:\n rec = pickle.load(f)\n except EOFError:\n break\n except Exception as _re:\n print(f'[dedup] append-log truncated at record {replayed} ({_re}), stopping replay')\n break\n if rec[0] == 'c':\n _, cid, mh = rec\n if cid not in lsh_cache:\n try:\n lsh_index.insert(cid, mh)\n except Exception:\n pass\n lsh_cache[cid] = mh\n if cid + 1 > next_cid:\n next_cid = cid + 1\n elif rec[0] == 'k':\n dedup_key_cache[rec[1]] = None\n replayed += 1\n except Exception as _e:\n print(f'[dedup] failed to replay append-log ({_e})')\n if replayed:\n print(f'[dedup] replayed {replayed} append-log records ({len(lsh_cache)} clusters, {len(dedup_key_cache)} keys)')\n return lsh_index, lsh_cache, dedup_key_cache, next_cid\n\ndef evict_oldest(lsh_index, lsh_cache, dedup_key_cache, max_keys):\n evicted_keys = evicted_clusters = 0\n excess = len(dedup_key_cache) - max_keys\n if excess > 0:\n for k in list(dedup_key_cache.keys())[:excess]:\n del dedup_key_cache[k]\n evicted_keys = excess\n excess = len(lsh_cache) - max_keys\n if excess > 0:\n for cid in list(lsh_cache.keys())[:excess]:\n try: lsh_index.remove(cid)\n except (KeyError, ValueError): pass\n del lsh_cache[cid]\n evicted_clusters = excess\n return evicted_keys, evicted_clusters\n\ndef dump_state_atomic(state_path, lsh_index, lsh_cache, dedup_key_cache, threshold, next_cluster_id):\n tmp = state_path + '.tmp'\n try:\n state = {\n 'lsh_index': lsh_index, 'lsh_cache': lsh_cache,\n 'dedup_key_cache': dedup_key_cache, 'next_cluster_id': next_cluster_id,\n 'num_perm': NUM_PERM, 'threshold': threshold,\n }\n with open(tmp, 'wb') as f:\n pickle.dump(state, f); f.flush(); os.fsync(f.fileno())\n os.replace(tmp, state_path)\n print(f'[dedup] state saved: {len(lsh_cache)} clusters, {len(dedup_key_cache)} dedup_keys')\n except Exception as e:\n print(f'[dedup] failed to save state: {e}')\n if os.path.exists(tmp):\n try: os.remove(tmp)\n except Exception: pass\n\ndef append_deltas(append_path, new_clusters, new_keys):\n # Persist ONLY this run's first-seen clusters + dedup_keys (append-only, O(new)).\n if not new_clusters and not new_keys:\n return\n try:\n with open(append_path, 'ab') as f:\n for _cid, _mh in new_clusters:\n pickle.dump(('c', _cid, _mh), f)\n for _dk in new_keys:\n pickle.dump(('k', _dk), f)\n f.flush(); os.fsync(f.fileno())\n except Exception as e:\n print(f'[dedup] failed to append deltas: {e}')\n\ndef compact_state(state_path, append_path, lsh_index, lsh_cache, dedup_key_cache, threshold, next_cluster_id):\n # Fold the append-log back into a fresh full snapshot, then drop the log.\n dump_state_atomic(state_path, lsh_index, lsh_cache, dedup_key_cache, threshold, next_cluster_id)\n try:\n if os.path.exists(append_path):\n os.remove(append_path)\n except Exception as e:\n print(f'[dedup] failed to truncate append-log: {e}')\n\n# ── Main ──────────────────────────────────────────────────────────────────────\n\nfiltered_alerts = inputs.get('filtered_alerts', [])\ninput_mode = inputs.get('input_mode', 'unknown')\ndedup_enabled = inputs.get('dedup_enabled', True)\nthreshold = float(inputs.get('dedup_threshold', 0.7))\nstrict_fields = inputs.get('strict_fields', ['sip', 'dip'])\nlsh_fields = inputs.get('lsh_fields', ['req_http_url', 'req_body', 'rsp_body'])\nmax_len = int(inputs.get('max_field_len', 500))\nmax_dedup_keys = int(inputs.get('max_dedup_keys', 100000))\nif max_dedup_keys < 1:\n max_dedup_keys = 100000\nstats = dict(inputs.get('stats', {}))\n\nif _mem.permutations is None:\n _mem.permutations = MinHash(num_perm=NUM_PERM, seed=MINHASH_SEED).permutations\n_permutations = _mem.permutations\nstate_path, lock_path, append_path = get_state_paths(threshold)\nevicted_keys = evicted_clusters = 0\n\nwith _mem.lock:\n # ── Load or reuse in-memory LSH state ────────────────────────────────────\n if dedup_enabled:\n disk_state_mtime = os.path.getmtime(state_path) if os.path.exists(state_path) else 0.0\n disk_append_mtime = os.path.getmtime(append_path) if os.path.exists(append_path) else 0.0\n cache_stale = (\n not _mem.initialized\n or _mem.threshold != threshold\n or disk_state_mtime > _mem.state_mtime + 0.5\n or disk_append_mtime > _mem.append_mtime + 0.5\n )\n if cache_stale:\n print(f'[dedup] cache miss (initialized={_mem.initialized}, '\n f'stale_by={disk_state_mtime - _mem.state_mtime:.1f}s), loading from disk')\n # Drop old references before load so GC can immediately reclaim\n # the ~649 MB state rather than waiting for the next cycle.\n old_lsh = _mem.lsh_index\n old_cache = _mem.lsh_cache\n _mem.lsh_index = None\n _mem.lsh_cache = {}\n _mem.dedup_key_cache = {}\n del old_lsh, old_cache\n _gc_module.collect()\n lsh_index, lsh_cache, dedup_key_cache, next_cluster_id = load_state(state_path, append_path, threshold, max_dedup_keys)\n if lsh_index is None:\n lsh_index = MinHashLSH(threshold=threshold, num_perm=NUM_PERM)\n lsh_cache = {}\n dedup_key_cache = {}\n next_cluster_id = 0\n _mem.lsh_index = lsh_index\n _mem.lsh_cache = lsh_cache\n _mem.dedup_key_cache = dedup_key_cache\n _mem.next_cluster_id = next_cluster_id\n _mem.threshold = threshold\n _mem.state_mtime = disk_state_mtime\n _mem.append_mtime = disk_append_mtime\n _mem.initialized = True\n else:\n print(f'[dedup] cache hit: {len(_mem.lsh_cache)} clusters, {len(_mem.dedup_key_cache)} keys')\n lsh_index = _mem.lsh_index\n lsh_cache = _mem.lsh_cache\n dedup_key_cache = _mem.dedup_key_cache\n next_cluster_id = _mem.next_cluster_id\n else:\n lsh_index, lsh_cache, dedup_key_cache, next_cluster_id = None, {}, {}, 0\n\n _cid_box = [next_cluster_id]\n _new_clusters = []\n def query_most_similar(minhash):\n sim_keys = lsh_index.query(minhash)\n if sim_keys:\n candidates = sim_keys[:100]\n sims = [minhash.jaccard(lsh_cache[k]) for k in candidates]\n return candidates[sims.index(max(sims))]\n cluster_id = _cid_box[0]\n _cid_box[0] += 1\n lsh_index.insert(cluster_id, minhash)\n lsh_cache[cluster_id] = minhash\n _new_clusters.append((cluster_id, minhash))\n return cluster_id\n\n enriched = []\n _new_keys = []\n for alert in filtered_alerts:\n alert = dict(alert)\n text_strict = '. '.join(str(alert.get(f, ''))[:max_len] for f in strict_fields)\n text_lsh = normalize_uri('. '.join(str(alert.get(f, ''))[:max_len] for f in lsh_fields))\n\n if not dedup_enabled:\n dk = hashlib.md5(f'{text_strict}. {text_lsh}'.encode('utf-8')).hexdigest()\n alert['_lsh_cluster_id'] = None\n alert['dedup_key'] = dk\n alert['is_duplicate'] = dk in dedup_key_cache\n dedup_key_cache[dk] = None\n enriched.append(alert)\n continue\n\n mh = gen_minhash(text_lsh.lower(), _permutations)\n cluster_id = query_most_similar(mh)\n alert['_lsh_cluster_id'] = cluster_id\n\n dk = hashlib.md5(f'{text_strict}. {cluster_id}'.encode('utf-8')).hexdigest()\n already = dk in dedup_key_cache\n if already:\n del dedup_key_cache[dk]\n else:\n _new_keys.append(dk)\n dedup_key_cache[dk] = None\n alert['dedup_key'] = dk\n alert['is_duplicate'] = already\n enriched.append(alert)\n\n if dedup_enabled:\n evicted_keys, evicted_clusters = evict_oldest(lsh_index, lsh_cache, dedup_key_cache, max_dedup_keys)\n if evicted_keys or evicted_clusters:\n print(f'[dedup] LRU eviction: dropped {evicted_keys} keys, {evicted_clusters} clusters')\n if len(lsh_cache) > LSH_CLUSTER_WARN_THRESHOLD or len(dedup_key_cache) > LSH_CLUSTER_WARN_THRESHOLD:\n print(f'[dedup] WARNING: persisted state holds {len(lsh_cache)} clusters '\n f'and {len(dedup_key_cache)} dedup_keys (warn={LSH_CLUSTER_WARN_THRESHOLD})')\n # Write to disk under file lock to protect against concurrent processes\n _lock_fh = acquire_lock(lock_path)\n try:\n # Incremental persistence: append ONLY this run's newly-created clusters\n # and first-seen dedup_keys (O(new) per message instead of O(N) full pickle).\n append_deltas(append_path, _new_clusters, _new_keys)\n # Periodic compaction: when the append-log grows comparable to the snapshot,\n # fold it into a fresh full snapshot and truncate the log (bounds disk + replay).\n _snap_size = os.path.getsize(state_path) if os.path.exists(state_path) else 0\n _app_size = os.path.getsize(append_path) if os.path.exists(append_path) else 0\n if (_snap_size == 0 and lsh_cache) or _app_size > max(_snap_size, 4 * 1024 * 1024):\n compact_state(state_path, append_path, lsh_index, lsh_cache, dedup_key_cache, threshold, _cid_box[0])\n print(f'[dedup] compacted append-log into snapshot ({len(lsh_cache)} clusters, {len(dedup_key_cache)} keys)')\n finally:\n release_lock(_lock_fh)\n _mem.next_cluster_id = _cid_box[0]\n _mem.state_mtime = os.path.getmtime(state_path) if os.path.exists(state_path) else _mem.state_mtime\n _mem.append_mtime = os.path.getmtime(append_path) if os.path.exists(append_path) else 0.0\n\n# ── Unique alerts (first seen per dedup_key) ──────────────────────────────────\nseen_keys = {}\nunique_alerts = []\nfor a in enriched:\n k = a['dedup_key']\n if k not in seen_keys:\n seen_keys[k] = a\n unique_alerts.append(a)\n\ndup_count = len(enriched) - len(unique_alerts)\nprint(f'[dedup] input={len(filtered_alerts)}, enriched={len(enriched)}, unique={len(unique_alerts)}, duplicates={dup_count}')\n\nstats['after_dedup_count'] = len(enriched)\nstats['unique_key_count'] = len(unique_alerts)\nstats['dedup_removed_count'] = dup_count\nstats['dedup_ratio'] = round(dup_count / len(enriched), 4) if enriched else 0.0\nstats['dedup_state_persisted'] = bool(dedup_enabled)\nif dedup_enabled:\n stats['lsh_total_clusters'] = len(lsh_cache)\n stats['lsh_total_dedup_keys'] = len(dedup_key_cache)\n stats['lsh_max_dedup_keys'] = max_dedup_keys\n stats['lsh_evicted_keys'] = evicted_keys\n stats['lsh_evicted_clusters'] = evicted_clusters\n\nif dedup_enabled:\n summary = (\n f'stream_alert_denoise done: raw={stats.get(\"raw_count\", 0)}'\n f' -> normalized={stats.get(\"normalized_count\", 0)}'\n f' -> filtered={stats.get(\"after_filter_count\", 0)}'\n f' -> enriched={len(enriched)}, unique={len(unique_alerts)} (compression {stats[\"dedup_ratio\"]:.1%})'\n f' | clusters={len(lsh_cache)}, keys={len(dedup_key_cache)}, max={max_dedup_keys}'\n )\nelse:\n summary = (\n f'stream_alert_denoise done (dedup_enabled=False): '\n f'raw={stats.get(\"raw_count\", 0)}'\n f' -> filtered={stats.get(\"after_filter_count\", 0)}'\n f' -> enriched={len(enriched)}'\n )\nprint(f'[dedup] {summary}')\n\n# ── Write enriched alerts to JSONL (counter sidecar replaces O(N) scan) ───────\n# dedup_result_001.jsonl, 002.jsonl ... each starts with a file_header line.\n# A lightweight sidecar (.dedup_counter.json) tracks the active file seq/count\n# so we never scan the whole file on every execution.\nMAX_RECORDS_PER_FILE = 10000\n_JSONL_PREFIX = 'dedup_result'\n_COUNTER_FILE = '.dedup_counter.json'\n\ndef _get_counter(out_dir):\n path = os.path.join(out_dir, _COUNTER_FILE)\n try:\n with open(path, 'r', encoding='utf-8') as _f:\n d = json.load(_f)\n return int(d.get('seq', 0)), int(d.get('count', 0))\n except Exception:\n return 0, 0\n\ndef _set_counter(out_dir, seq, count):\n path = os.path.join(out_dir, _COUNTER_FILE)\n tmp = path + '.tmp'\n try:\n with open(tmp, 'w', encoding='utf-8') as _f:\n json.dump({'seq': seq, 'count': count}, _f)\n os.replace(tmp, path)\n except Exception:\n pass\n\ndef _find_active_file(out_dir):\n seq, count = _get_counter(out_dir)\n if seq > 0:\n path = os.path.join(out_dir, f'{_JSONL_PREFIX}_{seq:03d}.jsonl')\n if os.path.exists(path):\n return path, count, seq\n # Sidecar missing/stale: one-time recovery scan\n import glob as _glob\n existing = sorted(_glob.glob(os.path.join(out_dir, _JSONL_PREFIX + '_*.jsonl')))\n if not existing:\n return None, 0, 0\n latest = existing[-1]\n try:\n seq = int(os.path.basename(latest).replace(_JSONL_PREFIX + '_', '').replace('.jsonl', ''))\n except ValueError:\n seq = len(existing)\n count = 0\n try:\n with open(latest, 'r', encoding='utf-8') as _f:\n for _line in _f:\n if _line.strip() and '\"_type\"' not in _line:\n count += 1\n except Exception:\n pass\n _set_counter(out_dir, seq, count)\n return latest, count, seq\n\ndef _write_jsonl(out_dir, alerts, now):\n written = []\n active_path, active_count, seq = _find_active_file(out_dir)\n remaining = list(alerts)\n while remaining:\n available = MAX_RECORDS_PER_FILE - active_count\n if available <= 0 or active_path is None:\n seq += 1\n active_path = os.path.join(out_dir, f'{_JSONL_PREFIX}_{seq:03d}.jsonl')\n active_count = 0\n available = MAX_RECORDS_PER_FILE\n header = {\n '_type': 'file_header',\n 'created_at': now.isoformat(),\n 'date': now.strftime('%Y-%m-%d'),\n 'workflow': WORKFLOW_NAME,\n 'seq': seq,\n }\n with open(active_path, 'w', encoding='utf-8') as _hf:\n _hf.write(json.dumps(header, ensure_ascii=False) + '\\n')\n batch = remaining[:available]\n remaining = remaining[available:]\n with open(active_path, 'a', encoding='utf-8') as _af:\n for _alert in batch:\n _af.write(json.dumps(_alert, ensure_ascii=False) + '\\n')\n active_count += len(batch)\n if active_path not in written:\n written.append(active_path)\n if remaining:\n active_path = None\n active_count = 0\n if written:\n _set_counter(out_dir, seq, active_count)\n return written\n\n# Persist ONLY genuinely first-seen alerts (cross-batch is_duplicate=False).\n# NOTE: unique_alerts is only batch-local dedup; in single-alert syslog streaming\n# it is always length 1, so filtering by is_duplicate is what actually drops repeats.\n_persisted_alerts = [a for a in enriched if not a.get('is_duplicate')]\n_now = datetime.datetime.now()\ntry:\n _out_dir = get_output_dir()\n _written_paths = _write_jsonl(_out_dir, _persisted_alerts, _now) if _persisted_alerts else []\n _out_path = _written_paths[-1] if _written_paths else ''\n print(f'[dedup] wrote {len(_persisted_alerts)} first-seen records (skipped {len(enriched)-len(_persisted_alerts)} duplicates) -> {_written_paths}')\n stats['output_path'] = _out_path\n stats['output_paths'] = _written_paths\n outputs['output_path'] = _out_path\n outputs['output_paths'] = _written_paths\nexcept Exception as _we:\n import traceback\n print(f'[dedup] WARNING: failed to write JSONL: {_we}\\n{traceback.format_exc()}')\n outputs['output_path'] = ''\n outputs['output_paths'] = []\n\n# ── Outputs ───────────────────────────────────────────────────────────────────\n# Strip large body/header fields from in-memory run result to reduce flocks\n# run-history memory footprint. Full data is already persisted to JSONL.\n_HEAVY_OUTPUT_FIELDS = {\n 'net_http_reqs_header', 'net_http_resp_header',\n 'net_http_resp_body', 'net_http_reqs_body',\n 'net_http_resp_line', 'net_http_reqs_line',\n 'net_http_reqs_cookie',\n}\ndef _slim_alert(a):\n return {k: v for k, v in a.items() if k not in _HEAVY_OUTPUT_FIELDS}\noutputs['enriched_alerts'] = [_slim_alert(a) for a in enriched]\noutputs['unique_alerts'] = [_slim_alert(a) for a in unique_alerts]\noutputs['stats'] = stats\noutputs['dedup_summary'] = summary\noutputs['input_mode'] = input_mode\n\nif enriched:\n outputs['dedup_key'] = enriched[0].get('dedup_key', '')\n outputs['is_duplicate'] = enriched[0].get('is_duplicate', False)\nelse:\n outputs['dedup_key'] = ''\n outputs['is_duplicate'] = False\n" + } + ], + "edges": [ + { + "from": "receive_alert", + "to": "normalize", + "order": 0 + }, + { + "from": "normalize", + "to": "filter_logs", + "order": 0 + }, + { + "from": "filter_logs", + "to": "dedup_and_write", + "order": 0 + } + ], + "metadata": { + "node_timeout_s": 300, + "sampleInputs": { + "source_log_type": "tdp", + "filter_enabled": true, + "dedup_enabled": true, + "threshold": 0.7, + "max_dedup_keys": 100000, + "_comment_syslog": "Syslog mode: POST /api/workflow/{id}/syslog-config {enabled:true, protocol:'udp', port:5140, inputKey:'syslog_message'}. The TDP/Skyeye alert JSON must be in syslog message body.", + "syslog_message": { + "raw": "<134>May 12 10:00:00 tdp-sensor tdp: {\"id\":\"AZtRkZkzj\",\"net\":{}}", + "facility": 16, + "severity": 6, + "timestamp": "2026-05-12T10:00:00", + "hostname": "tdp-sensor", + "app_name": "tdp", + "message": "{\"id\":\"AZtRkZkzj\",\"net\":{\"http\":{\"url\":\"/admin\"}},\"threat\":{\"name\":\"SQL注入\"}}", + "format": "rfc3164" + }, + "_comment_batch": "Or pass 'alerts' (list) or 'alert_file' (path to JSON file)", + "alerts": [ + { + "net_real_src_ip": "1.2.3.4", + "net_dest_ip": "10.0.0.1", + "direction": "in", + "net_type": "http", + "net_http_url": "/admin/login.php?id=1 OR 1=1", + "net_http_reqs_body": "username=admin&password=123456", + "net_http_resp_body": "root@localhost", + "threat_name": "SQL注入攻击", + "threat_type": "web攻击" + } + ] + } + }, + "triggers": [ + { + "id": "syslog-default", + "name": "Syslog 实时告警流", + "type": "syslog", + "enabled": false, + "source": { + "protocol": "udp", + "host": "0.0.0.0", + "port": 5140, + "format": "auto" + }, + "mapping": { + "syslog_message": "$.body" + }, + "inputs": {}, + "concurrency": { + "policy": "allow", + "maxParallel": 1, + "queueSize": 100 + }, + "runtime": {}, + "testSamples": [] + } + ] +} diff --git a/.flocks/flockshub/plugins/workflows/stream_alert_denoise/workflow.md b/.flocks/flockshub/plugins/workflows/stream_alert_denoise/workflow.md new file mode 100644 index 000000000..4217ff517 --- /dev/null +++ b/.flocks/flockshub/plugins/workflows/stream_alert_denoise/workflow.md @@ -0,0 +1,292 @@ +# stream_alert_denoise + +## 1. 功能概述 + +`stream_alert_denoise` 是一个 HTTP 的告警降噪工作流。 + +它主要解决三件事: + +- 把 TDP / SkyEye 来源的告警整理成统一字段。 +- 过滤掉扫描、非 HTTP 或低价值噪声。 +- 判断告警是否重复,只把更值得关注的告警输出并写入结果文件。 + +适用场景: + +- 安全设备通过 Syslog 实时推送单条告警。 +- 通过 API 批量提交告警列表。 +- 从 JSON 文件读取历史告警做批量处理。 + +不适合做的事: + +- 不负责调查告警背后的资产、用户或攻击链。 +- 不直接处置攻击,只做告警清洗、过滤、去重和结果落盘。 +- 不保存明文密钥;发布鉴权和运行时状态应由配置和数据库管理。 + +## 2. 总体流程 + +工作流按下面顺序处理告警: + +```text +receive_alert -> normalize -> filter_logs -> dedup_and_write +``` + +| 顺序 | 节点 | 作用 | +| --- | --- | --- | +| 1 | `receive_alert` | 接收输入,判断输入模式和来源类型。 | +| 2 | `normalize` | 把 TDP / SkyEye 的不同字段统一成标准告警字段。 | +| 3 | `filter_logs` | 按规则过滤扫描、非 HTTP 或低价值日志。 | +| 4 | `dedup_and_write` | 计算去重结果,标记重复告警,写入 JSONL 结果文件。 | + +可以把它理解成: + +```text +原始告警 + -> 识别来源 + -> 字段统一 + -> 噪声过滤 + -> 相似告警去重 + -> 返回增强告警 + 写入结果文件 +``` + +## 3. 输入说明 + +### 3.1 输入方式 + +三种输入方式按优先级解析: + +| 优先级 | 字段 | 用途 | +| --- | --- | --- | +| 1 | `syslog_message` | Syslog 实时单条告警。 | +| 2 | `alerts` | API 批量传入的告警列表。 | +| 3 | `alert_file` | 指向 JSON 告警文件的路径。 | + +如果同时传了多个输入字段,工作流优先处理 `syslog_message`。 + +### 3.2 常用输入参数 + +| 参数 | 默认值 | 说明 | +| --- | --- | --- | +| `source_log_type` | 自动识别 | 可手动指定 `tdp` 或 `skyeye`。 | +| `filter_enabled` | `true` | 是否启用过滤阶段。 | +| `dedup_enabled` | `true` | 是否启用跨批次去重。 | +| `threshold` | `0.7` | 相似度阈值;越高越严格。 | +| `strict_fields` | `["sip", "dip"]` | 去重时必须精确匹配的字段。 | +| `lsh_fields` | `["req_http_url", "req_body", "rsp_body"]` | 去重时参与相似度判断的字段。 | +| `max_dedup_keys` | `100000` | 持久化去重 key 的最大数量。 | + +### 3.3 Syslog 输入示例 + +```json +{ + "syslog_message": { + "hostname": "tdp-sensor", + "app_name": "tdp", + "timestamp": "2026-05-12T10:00:00", + "message": "{\"id\":\"AZtRkZkzj\",\"net\":{\"http\":{\"url\":\"/admin\"}},\"threat\":{\"name\":\"SQL注入\"}}" + } +} +``` + +### 3.4 批量输入示例 + +```json +{ + "source_log_type": "tdp", + "alerts": [ + { + "net_real_src_ip": "1.2.3.4", + "net_dest_ip": "10.0.0.1", + "net_type": "http", + "net_http_url": "/admin/login.php?id=1 OR 1=1", + "threat_name": "SQL注入攻击" + } + ] +} +``` + +## 4. 模块逻辑 + +### 4.1 receive_alert:接收和识别 + +这个节点负责回答两个问题: + +- 告警是从哪里来的? +- 它应该按 TDP 还是 SkyEye 格式解析? + +处理逻辑: + +1. 优先读取 `syslog_message`。 +2. 如果没有 syslog,则读取 `alerts`。 +3. 如果没有告警列表,则读取 `alert_file`。 +4. 使用 `source_log_type`、syslog 元数据或字段特征判断来源类型。 +5. 如果判断失败,默认按 `tdp` 处理。 + +你通常会在这里修改: + +- 新增输入方式。 +- 调整 Syslog 解析逻辑。 +- 修改 TDP / SkyEye 自动识别规则。 + +### 4.2 normalize:统一字段 + +这个节点负责把不同来源的原始字段翻译成统一字段。 + +统一后的关键字段包括: + +| 字段 | 含义 | +| --- | --- | +| `sip` | 源 IP。 | +| `dip` | 目的 IP。 | +| `req_http_url` | HTTP 请求 URL。 | +| `req_body` | 请求正文。 | +| `rsp_body` | 响应正文。 | +| `threat_name` | 威胁名称。 | +| `_source_type` | 来源类型,`tdp` 或 `skyeye`。 | +| `_syslog_meta` | Syslog 元数据,仅 Syslog 输入时存在。 | + +你通常会在这里修改: + +- 字段映射关系。 +- TDP / SkyEye 新版本字段兼容。 +- 默认值和缺失字段处理。 + +### 4.3 filter_logs:过滤噪声 + +这个节点负责判断哪些告警值得继续处理。 + +默认策略: + +- 保留 HTTP 告警。 +- 保留方向为 `in`、`out`、`lateral` 的非扫描告警。 +- 过滤扫描、非 HTTP 或低价值日志。 + +如果传入: + +```json +{ + "filter_enabled": false +} +``` + +则跳过过滤,所有归一化后的告警都会进入去重阶段。 + +你通常会在这里修改: + +- 哪些告警应该保留。 +- 哪些告警应该丢弃。 +- `_process_type` 和 `_threat_type` 的分类规则。 + +### 4.4 dedup_and_write:去重和写结果 + +这个节点负责判断告警是否重复,并写入结果文件。 + +去重分两层: + +- 精确层:默认要求 `sip` 和 `dip` 一致。 +- 相似层:对 `req_http_url`、`req_body`、`rsp_body` 做相似度判断。 + +默认阈值是 `threshold=0.7`: + +- 阈值越高,越不容易判定重复。 +- 阈值越低,越容易把相似告警归为同一类。 + +结果文件写入: + +```text +~/.flocks/workspace/workflows/stream_alert_denoise//dedup_result_NNN.jsonl +``` + +注意: + +- 首次出现的告警会写入 JSONL。 +- 历史重复告警不会再次写入文件,但会在返回结果里标记为重复。 +- LSH 去重状态会保存在工作流自己的状态文件中,不建议和其他工作流混用。 + +你通常会在这里修改: + +- 相似度阈值。 +- 哪些字段参与去重。 +- 结果文件格式。 +- 状态文件保存策略。 + +## 5. 输出说明 + +工作流主要输出这些字段: + +| 字段 | 含义 | +| --- | --- | +| `enriched_alerts` | 过滤和去重处理后的告警列表。 | +| `unique_alerts` | 每个去重 key 的代表性告警。 | +| `dedup_key` | 第一条告警的去重 key。 | +| `is_duplicate` | 第一条告警是否为历史重复。 | +| `stats` | 本次处理统计。 | +| `output_path` | 本次写入的最后一个结果文件。 | +| `output_paths` | 本次涉及的所有结果文件。 | +| `dedup_summary` | 一句话摘要。 | +| `input_mode` | 实际使用的输入模式。 | + +每条 `enriched_alert` 里最重要的增强字段: + +| 字段 | 含义 | +| --- | --- | +| `dedup_key` | 去重 key。 | +| `is_duplicate` | 是否重复。 | +| `_lsh_cluster_id` | 相似度聚类 ID。 | +| `_source_type` | 来源类型。 | +| `_process_type` | 过滤分类结果。 | + +## 6. 发布和配置 + +发布页面不直接从 `workflow.md` 决定展示什么能力,而是读取 `config.json` 模板和数据库中的运行时状态。 + +当前常见发布方式: + +- API:通过 `/api/workflow/stream_alert_denoise/run` 调用。 +- Syslog:监听端口接收实时告警。 +- Kafka / Schedule / Webhook:可按模板配置扩展。 + +编辑发布方式时: + +- 改发布模板:看 `config.json`。 +- 改运行启停状态:看发布页和后端运行时状态。 +- 不要把明文 API Key、密码、token 写进 `workflow.md` 或 `config.json`。 + +## 7. 怎么编辑这个工作流 + +按你想改的目标定位: + +| 修改目标 | 优先修改 | +| --- | --- | +| 输入来源、Syslog 格式、文件输入 | `receive_alert` | +| TDP / SkyEye 字段映射 | `normalize` | +| 过滤规则、保留规则、分类规则 | `filter_logs` | +| 去重阈值、去重字段、落盘格式 | `dedup_and_write` | +| 发布方式、API / Syslog 展示 | `config.json` | +| 流程结构、节点增删 | `workflow.json` 和本文档同步修改 | + +修改时的基本原则: + +- 改输入字段,要同步样例输入。 +- 改标准字段名,要同步所有下游节点。 +- 改过滤规则,要同步 `stats` 和测试样例预期。 +- 改去重逻辑,要说明历史去重结果是否会变化。 +- 改输出格式,要确认下游系统还能读取。 + +## 8. 验证方式 + +最小验证建议: + +1. 用一条正常 HTTP 告警跑通,确认有 `enriched_alerts`。 +2. 再跑一条相似告警,确认 `dedup_key` 稳定。 +3. 如果开启跨批次去重,确认第二次出现时 `is_duplicate=true`。 +4. 用一条应被过滤的扫描或非 HTTP 告警,确认过滤统计正确。 +5. 检查 `output_path` 指向的 JSONL 文件是否正常写入。 + +验收清单: + +- [ ] 输入能被正确识别为 syslog、alerts 或 alert_file。 +- [ ] TDP / SkyEye 字段能统一到标准字段。 +- [ ] 过滤逻辑符合预期。 +- [ ] 去重结果符合预期。 +- [ ] 输出字段和结果文件格式清晰。 +- [ ] 发布页只展示当前配置启用的能力。 diff --git a/.flocks/flockshub/plugins/workflows/stream_alert_triage/config.json b/.flocks/flockshub/plugins/workflows/stream_alert_triage/config.json new file mode 100644 index 000000000..328952bb7 --- /dev/null +++ b/.flocks/flockshub/plugins/workflows/stream_alert_triage/config.json @@ -0,0 +1,7 @@ +{ + "_comment": "stream_alert_triage runtime defaults. Workflow inputs override these values.", + "triage_output_mode": "soc_db", + "soc_db_path": "~/.flocks/data/soc.db", + "persist_triage_output": false, + "jsonl_output_dir": "" +} diff --git a/.flocks/flockshub/plugins/workflows/stream_alert_triage/guide.md b/.flocks/flockshub/plugins/workflows/stream_alert_triage/guide.md new file mode 100644 index 000000000..edd53dd03 --- /dev/null +++ b/.flocks/flockshub/plugins/workflows/stream_alert_triage/guide.md @@ -0,0 +1,361 @@ +# stream_alert_triage 配置引导 + +这个文件是 `stream_alert_triage` 的工作流专属 `guide.md`。Rex 处理这个工作流的配置、输入、并发、缓存、验证或查配置快捷入口时,必须先读取本文全文,再把 `workflow.md`、`workflow.json` 和 `workflow_config_manage(action="get" 或 "status", workflow_id="stream_alert_triage")` 的结果作为支撑上下文。 + +`workflow-config-guide` skill 只提供交互协议;本文才是本工作流配置细节、默认选项、提问顺序和验证方式的来源。 + +Rex 引导用户时必须遵守: + +1. 根据用户点击的入口或自然语言需求,自动定位本文相关章节。 +2. 一次只问一个最关键问题。 +3. 每个选择都必须允许自定义/补充输入;没有补充则填 `none`。 +4. 涉及输入来源、并发、缓存上限、持久化输出或发布模板变更时,先展示计划和 diff,再用 question 工具确认。 +5. 查配置只能只读,不得修改文件、触发 LLM 研判、发布 API、启动监听或清理缓存。 + +## 0. 后端配置库访问约束 + +本节优先级高于通用会话提示中的后端 API token 或 curl 示例。处理本工作流的发布、定时触发、API 接入、输出策略或查配置时,必须按本文执行: + +- 配置库读取/写入必须使用内置工具 `workflow_config_manage`,不要读取 `server_api_token` 或 `service_api_token`,也不要手工 curl 本机后端配置接口。 +- 查配置使用 `workflow_config_manage(action="get", workflow_id="stream_alert_triage")` 或 `workflow_config_manage(action="status", workflow_id="stream_alert_triage")`。 +- 查定时触发配置使用 `workflow_config_manage(action="get", workflow_id="stream_alert_triage", config_type="poller")` 或 `workflow_config_manage(action="status", workflow_id="stream_alert_triage", config_type="poller")`。 +- 修改配置前先使用 `workflow_config_manage(action="diff", workflow_id="stream_alert_triage", config={...})` 展示差异并用 question 工具确认;确认后才使用 `workflow_config_manage(action="put", workflow_id="stream_alert_triage", config={...})`。 +- 修改定时触发配置前先使用 `workflow_config_manage(action="diff", workflow_id="stream_alert_triage", config_type="poller", config={...})` 展示差异并用 question 工具确认;确认后才使用 `workflow_config_manage(action="put", workflow_id="stream_alert_triage", config_type="poller", config={...})`。 +- 如果后端配置库没有模板,只能使用 `workflow_config_manage(action="sync", workflow_id="stream_alert_triage")`,让后端从工作流目录 `config.json` 迁移或生成模板。 +- `config.json` 只能作为模板来源或兜底迁移来源,不是直接写入目标,也不能证明配置已生效。 +- 需要启动或停止 API 服务、定时触发或其它运行态能力时,必须使用对应运行态接口;不要通过修改模板字段冒充运行态状态。 +- 如果 `workflow_config_manage` 不可用、返回未授权、拒绝访问、连接失败或后端不可达,必须停止配置流程,明确说明本次未应用、未发布、未启动;如已生成目标配置,只能保存草稿到 outputs,不要继续读取 token 或改写 `config.json`。 + +## 1. 工作流定位 + +- 工作流 ID:`stream_alert_triage` +- 工作流名称:`stream_alert_denoise` 的下游批量研判 Pipeline。 +- 主要用途:读取 `stream_alert_denoise` 写出的 `dedup_result_NNN.jsonl`,按 `dedup_key` 做 leader/follower 分组,只对每组 leader 执行研判,followers 复用 leader 结果。 +- 当前状态:`meta.json` 标记为 `active`。 +- 当前发布状态:`workflow.json` 中 `triggers` 为空;工作流目录有 `config.json` 用于声明默认持久化策略。默认按手动运行或 API run 输入来引导。 + +本工作流适合: + +- 对降噪后的 HTTP 告警做攻击研判。 +- 复用 `stream_alert_denoise` 的 `dedup_key` 降低 LLM 调用。 +- 按日期重放某天全部去重结果。 +- 默认把研判后的完整告警写入 `~/.flocks/data/soc.db`,并保留 JSONL 可选输出,供归档或下游工作流消费。 + +本工作流不适合: + +- 直接接收原始 TDP / SkyEye 告警。 +- 执行上游过滤、字段归一化或 LSH 去重。 +- 调用或嵌入 `tdp_alert_triage` 子工作流。 +- 生成每条告警一个独立 markdown 报告文件。 + +## 2. AI 引导方式 + +如果用户点击输入入口,优先确认要读哪些 `dedup_result_NNN.jsonl`;如果用户点击规则入口,优先确认并发和缓存策略;如果用户点击样例入口,优先做文件格式检查,不要直接触发 LLM 研判。 + +推荐提问顺序: + +1. 你要读取上游本次输出文件、单个文件、某个日期,还是默认读取今天? +2. 是否显式设置 `concurrency=1`,还是确认提高到 2 到 5? +3. 是否保持 `max_triage_cache_size=100000` 和 `triage_output_mode=soc_db`,还是改为 JSONL / both / none? +4. 是否开启定时触发;默认建议每 3 分钟执行一次,并显式使用 `concurrency=1`。 +5. 是否只做轻量文件检查,还是确认执行真实研判? +6. 是否保存配置草稿、应用发布模板,或暂不修改? + +如果用户只问“查一下现在怎么配的”,不要提问,直接按第 9 节只读检查。 + +## 3. 输入模式 + +工作流代码支持四种输入定位方式,解析优先级固定为: + +1. `input_paths`: 显式 JSONL 文件路径列表,推荐直接使用 `stream_alert_denoise.outputs.output_paths`。 +2. `input_path`: 单个 JSONL 文件路径,通常来自 `stream_alert_denoise.outputs.output_path`。 +3. `input_date`: `YYYY-MM-DD`,自动读取该日 `stream_alert_denoise` 输出目录下全部 `dedup_result_*.jsonl`。 +4. 全部不传:默认读取今天目录下全部 `dedup_result_*.jsonl`。 + +上游默认输出目录: + +```text +~/.flocks/workspace/workflows/stream_alert_denoise//dedup_result_NNN.jsonl +``` + +输入模式建议: + +| 模式 | 适用场景 | 推荐输入 | +| --- | --- | --- | +| 上游本次输出 | 刚跑完 `stream_alert_denoise`,需要立即研判本批首见告警 | `input_paths = denoise.outputs.output_paths` | +| 单文件重放 | 只检查某个文件或某个序号文件 | `input_path = ".../dedup_result_001.jsonl"` | +| 按日期重放 | 对某天所有去重结果统一研判 | `input_date = "YYYY-MM-DD"` | +| 今日默认 | 调试或日常手动执行 | 不传 `input_*`,但先确认今天目录存在文件 | + +默认推荐:上游本次输出文件。如果用户没有上游结果,再推荐 `input_date`。 + +互斥关系: + +- `input_paths` 和 `input_path` 可以同时传,但会合并并按顺序去重。 +- 只要显式路径存在,就不会再按 `input_date` 自动发现。 +- 传入不存在的路径会被跳过,不会报错中止,但 `load_stats` 会显示实际读取为 0。 + +## 4. 来源形态 + +真实来源是 `stream_alert_denoise` 的 JSONL 输出。每个文件形态: + +- 第一行:`{"_type":"file_header", ...}`,`load_dedup_file` 会跳过。 +- 后续每行:一条 JSON 告警。 +- 关键字段:`dedup_key`、`is_duplicate`、`_lsh_cluster_id`、`_source_type`、`_process_type`、`sip`、`dip`、`req_http_url`、`req_body`、`rsp_body`、`threat_name`。 + +`load_dedup_file` 输出: + +| 字段 | 说明 | +| --- | --- | +| `enriched_alerts` | 从 JSONL 读出的告警列表 | +| `loaded_files` | 实际读取到的文件路径 | +| `load_stats` | 文件数、记录数、跳过 header 数、坏行数 | +| `concurrency` | 下游外层并发参数 | +| `max_triage_cache_size` | 下游研判缓存上限 | +| `input_date` | 实际日期字符串 | + +重要约束: + +- 如果告警缺少 `dedup_key`,该告警会作为独立 work unit 研判,无法和其它告警复用。 +- `is_duplicate` 不决定是否研判。缓存命中策略只依赖 `dedup_key`。 +- `stream_alert_denoise` 只会把跨批次首见告警写入 JSONL;因此常规情况下本工作流读取到的是适合继续研判的首见告警。 +- 如果用户手工构造 JSONL,必须保证每行是独立 JSON 对象,不能是整文件 JSON 数组。 + +## 5. 输出去向 + +工作流返回: + +| 输出字段 | 说明 | +| --- | --- | +| `enriched_alerts_with_triage` | 每条输入告警加上研判字段后的完整列表 | +| `triage_results` | 精简研判结果列表,不含 markdown 正文 | +| `triage_stats` | leader/follower、cache、并发、耗时、verdict 分布等统计 | +| `load_stats` | 输入文件加载统计 | +| `loaded_files` | 实际读取的上游文件 | +| `input_date` | 本次读取日期 | +| `triage_output_mode` | 本次生效的输出模式:`soc_db` / `jsonl` / `both` / `none` | +| `soc_db_result` / `soc_db_path` | 本次写入的 SOC DB 结果和路径 | +| `output_paths` | 本次写入的研判 JSONL 文件列表;未启用 JSONL 时为空 | +| `output_dir` | 研判 JSONL 结果目录;未启用 JSONL 时为空 | +| `summary_report` | markdown 总览文本 | +| `summary_path` | 总览 markdown 落盘路径 | +| `top_attack_verdict` / `top_risk_level` / `top_report_title` / `top_triage_report` | top-risk 告警研判字段 | + +每条告警追加: + +- `has_dedup_key` +- `triage_source` +- `triage_status` +- `attack_verdict` +- `risk_level` +- `report_title` +- `triage_report` +- `attack_success` +- `triage_ms` +- `triage_error` + +默认 SOC DB 输出: + +```text +~/.flocks/data/soc.db +``` + +默认写入表: + +```text +alert_records +``` + +可选研判 JSONL 输出目录: + +```text +~/.flocks/workspace/workflows/stream_alert_triage//triage_result_NNN.jsonl +``` + +写入规则: + +- `triage_output_mode=soc_db`:默认写入 `soc.db`,不写 JSONL。 +- `triage_output_mode=jsonl`:只写 `triage_result_NNN.jsonl`,不写 `soc.db`。 +- `triage_output_mode=both`:同时写 `soc.db` 和 JSONL。 +- `triage_output_mode=none`:不写 `soc.db` 和 JSONL,但仍可能写缓存。 +- 旧参数 `persist_triage_output=true` 仍兼容:当 `triage_output_mode=soc_db` 时会额外写 JSONL,相当于 `both`。 +- 每个文件第一行是 file header,包含 `workflow`、`seq`、`run_id`、`batch_total`、`batch_triaged`、`batch_followers_reused`、`batch_cache_hit`、`batch_triage_failed`。 +- 每个文件最多 10000 条告警记录。 +- `.triage_counter.json` 记录当前文件序号和条数。 +- 未启用 JSONL 时不写 `triage_result_NNN.jsonl`,但仍可能触发 LLM 和缓存写入,除非全部 cache 命中或没有输入。 + +总览报告输出: + +```text +~/.flocks/workspace/outputs//artifacts/stream_alert_triage_summary.md +``` + +注意: + +- 每条告警完整 markdown 在 `triage_report` 字段中。 +- 不存在 `report_path`。 +- 不会生成 `triage_report_*.md` 这类单告警 markdown 文件。 + +- `triage_report` 是带语义标签的 markdown 字符串,根标签为 ``。 +- 前端应按 ``、``、``、``、``、``、``、``、`` 切块后渲染标签内 markdown。 +- 工作流的报告生成 prompt 已包含攻击成功和攻击失败 few-shot;如果 LLM 未按标签输出,会自动使用确定性 fallback。 + + +## 6. 处理规则 + +默认参数: + +| 参数 | 推荐默认 | 代码行为和说明 | +| --- | --- | --- | +| `input_paths` | 无 | 显式路径列表,优先级最高 | +| `input_path` | 无 | 单个显式路径 | +| `input_date` | 今天 | 自动发现该日所有上游 `dedup_result_*.jsonl` | +| `concurrency` | `1` | `workflow.md` 和 metadata 推荐 1;`concurrent_triage` 会限制到 1 到 5 | +| `max_triage_cache_size` | `100000` | 小于 1 时回退 100000 | +| `triage_output_mode` | `soc_db` | 输出模式:`soc_db` / `jsonl` / `both` / `none` | +| `soc_db_path` | `~/.flocks/data/soc.db` | 默认 SOC DB 写入位置 | +| `persist_triage_output` | `false` | 旧兼容参数;设为 `true` 会在 `soc_db` 模式下额外写 JSONL | +| `jsonl_output_dir` | 空 | 可选 JSONL 输出目录;为空时使用工作流默认日期目录 | + +并发注意: + +- 外层 `ThreadPoolExecutor(max_workers=concurrency)` 处理 unique work units。 +- 内层每个 leader 会用 4 路并行 LLM 分支:`survey`、`cve_related`、`cve_info`、`payload_analysis`。 +- 稳态 LLM 峰值约为 `concurrency * 4`。 +- 配置引导应默认显式给出 `concurrency=1`。如果用户要提高到 2 到 5,先说明 LLM 并发和上游工具压力,再确认。 +- 当前 `load_dedup_file` 节点在完全不传 `concurrency` 时会输出 5;因此引导和样例中应显式传 `concurrency=1`,避免与文档推荐值不一致。 + +leader/follower 规则: + +- 按 `dedup_key` 分组。 +- 每个分组首条为 leader。 +- leader 负责真实研判。 +- follower 复制 leader 的 `attack_verdict`、`risk_level`、`report_title`、`triage_report`、`attack_success`。 +- 无 `dedup_key` 告警各自独立研判,`triage_source` 会是 `no_dedup_key_triaged` 或 `no_dedup_key_failed`。 + +研判缓存: + +```text +~/.flocks/workspace/workflows/stream_alert_triage/triage_cache.pkl +~/.flocks/workspace/workflows/stream_alert_triage/triage_cache.lock +``` + +- key:`dedup_key`。 +- value:`attack_verdict`、`risk_level`、`report_title`、`triage_report`、`attack_success`。 +- cache 命中时只有在 `triage_report` 为 `soc.triage.markdown.v1` 标签化 markdown 时才直接复用,不调用 LLM。 +- 旧 `final_report` 缓存会按 miss 重新研判并写回新版字段。 +- cache 未命中时 leader 执行完整内联研判。 +- 新结果会合并写回 cache,文件锁 + 原子落盘。 +- 淘汰策略是 FIFO LRU,超过 `max_triage_cache_size` 时丢弃最旧条目。 + +研判逻辑: + +- 本工作流不调用 `tdp_alert_triage` 子工作流。 +- 研判逻辑是内联实现,语义同源于 `tdp_alert_triage` 文档版本。 +- 单条 leader 会执行情报准备、4 路 LLM 分析、攻击状态判断、verdict 归一化、标题生成和 markdown 聚合。 +- 如果后续要接入真实 TDP 平台检索或页面调查,必须按 `tdp-use` skill 处理,不得绕过对应 skill 直接调用 TDP 工具。 + +低层参数默认隐藏,不主动询问:LLM timeout、retry、verdict 映射、JSONL counter 文件名、file lock 细节。只有用户明确排障时再解释。 + +## 7. 样例验证 + +推荐样例 1:上游本次输出文件。 + +```json +{ + "input_paths": [ + "~/.flocks/workspace/workflows/stream_alert_denoise/2026-05-18/dedup_result_001.jsonl" + ], + "concurrency": 1, + "max_triage_cache_size": 100000, + "triage_output_mode": "soc_db" +} +``` + +推荐样例 2:按日期重放。 + +```json +{ + "input_date": "2026-05-18", + "concurrency": 1, + "max_triage_cache_size": 100000, + "triage_output_mode": "soc_db" +} +``` + +轻量验证优先做只读检查: + +1. 路径是否存在。 +2. 首行是否为 `_type=file_header` 或第一条 JSON 告警。 +3. 后续每行是否能按 JSON 对象解析。 +4. 是否至少有 `dedup_key`、`sip`、`dip`、`req_http_url`、`threat_name` 中的关键字段。 +5. 按 `dedup_key` 估算 unique work units 和 follower 数。 +6. 预估 `concurrency * 4` 的 LLM 峰值。 + +真实执行验证注意: + +- 只要存在 cache miss,就会触发 LLM 和情报工具调用。 +- `triage_output_mode=none` 只是不写 SOC DB 和 JSONL,不代表不会调用 LLM,也不代表不会写 `triage_cache.pkl`。 +- `persist_triage_output=false` 只是旧 JSONL 开关为关;默认仍会按 `triage_output_mode=soc_db` 写入 SOC DB。 +- 如果要避免外部副作用,先只做文件解析和字段检查,不运行工作流。 +- 如果用户确认运行,建议用 1 到 3 条样例告警、`concurrency=1`、明确是否允许写缓存和输出文件。 + +最小期望输出: + +- `load_stats.record_count > 0` +- `triage_stats.total == load_stats.record_count` +- `triage_stats.work_units <= triage_stats.total` +- `enriched_alerts_with_triage[*].triage_report` 存在于已研判或缓存命中的告警上 +- `soc_db_result.rows` 等于本次尝试写入 `alert_records` 的记录数,除非 `triage_output_mode=jsonl/none` +- `output_paths` 指向当日 `triage_result_NNN.jsonl`,仅在 `triage_output_mode=jsonl/both` 或旧参数 `persist_triage_output=true` 时存在 +- `summary_path` 指向 `outputs//artifacts/stream_alert_triage_summary.md` + +## 8. 应用方式 + +当前工作流目录包含 `config.json` 作为默认运行配置,默认输出到 `~/.flocks/data/soc.db`,不写 JSONL;`workflow.json` 中 `triggers` 为空。配置引导应把它当作“已有输出默认值、尚未声明发布/触发模板”的工作流。 + +如果用户要配置运行入口: + +1. 优先引导为手动运行或 API run 输入参数模板。 +2. 如果用户要发布成 API 服务,应使用 `workflow_config_manage(action="get" 或 "sync" 或 "diff" 或 "put", workflow_id="stream_alert_triage")` 流程。 +3. 如果用户要开启定时触发,默认建议 3 分钟一次;应用前必须确认触发输入来源、输出模式、是否允许写入 `soc.db` 和是否允许触发 LLM,并使用 `workflow_config_manage(action="get" -> "diff" -> "put", workflow_id="stream_alert_triage", config_type="poller")` 读取和写入 poller 配置。 +4. 如果需要扩展工作流目录下的 `config.json`,必须使用 runtime 消费的结构:`kind: workflow.integration-config`,顶层包含 `publish` 和 `triggers`。 +5. 不要生成旧的 `publishTemplates` wrapper。 +6. 不要直接写 `config.json` 来表示发布、接入或触发配置已经生效。 +7. 启停、发布、取消发布等运行态动作必须调用运行时接口。 +8. 不要读取 `server_api_token`,不要用 curl 调 `/api/workflow/stream_alert_triage/poller-config` 读取或写入定时配置。 +9. 如果后端配置接口不可用,只能把目标配置保存为草稿到 outputs,并明确说明未应用、未发布、未启动。 + +应用变更前必须展示: + +- 计划。 +- 输入参数或 publish / triggers 模板 diff。 +- poller 配置变更时必须展示 `workflow_config_manage(config_type="poller")` 生成的 diff。 +- 是否会触发 LLM、情报工具、`triage_cache.pkl` 写入、`soc.db` 写入、`triage_result_NNN.jsonl` 写入。 +- question 工具确认:应用、保存草稿或暂不修改。 +- 用户确认应用后,使用 `workflow_config_manage(action="put", workflow_id="stream_alert_triage", config_type="", config={...})` 写入完整配置。 + +不要通过删除 `triage_cache.pkl` 来“重置配置”。缓存清理是运行数据操作,必须单独说明影响并取得确认。 + +## 9. 查配置 + +只读检查顺序: + +1. 读取本文。 +2. 读取 `workflow.md` 和 `workflow.json`。 +3. 调用 `workflow_config_manage(action="get", workflow_id="stream_alert_triage")` 或 `workflow_config_manage(action="status", workflow_id="stream_alert_triage")`。 +4. 调用 `workflow_config_manage(action="get", workflow_id="stream_alert_triage", config_type="poller")` 或 `workflow_config_manage(action="status", workflow_id="stream_alert_triage", config_type="poller")`。 +5. 如果后端无配置,再检查工作流目录是否有 `config.json`。 +6. 汇总已配置项、缺失项和最推荐下一步。 + +查配置时重点报告: + +- 当前是否存在 `config.json` 或后端配置,默认输出方式是否为 `soc_db`。 +- `workflow.json.triggers` 是否为空。 +- 是否已经配置定时触发;如果没有,说明默认推荐是每 3 分钟一次,但需要用户确认后才应用。 +- 推荐输入方式:`input_paths`、`input_path`、`input_date` 或今日默认。 +- 推荐显式设置 `concurrency=1`。 +- 当前缓存路径和上限配置,但不要读取或修改大型 pickle 内容,除非用户明确要求排障。 +- 输出路径:SOC DB 默认在 `~/.flocks/data/soc.db`;triage JSONL 可选在 `~/.flocks/workspace/workflows/stream_alert_triage//`;总览报告在 `~/.flocks/workspace/outputs//artifacts/`。 + +查配置不得修改文件、触发 LLM、调用情报工具、写缓存、启动监听、发布 API 或停止服务。 diff --git a/.flocks/flockshub/plugins/workflows/stream_alert_triage/manifest.json b/.flocks/flockshub/plugins/workflows/stream_alert_triage/manifest.json new file mode 100644 index 000000000..3dfcd21ca --- /dev/null +++ b/.flocks/flockshub/plugins/workflows/stream_alert_triage/manifest.json @@ -0,0 +1,68 @@ +{ + "schemaVersion": "hub.plugin.v1", + "id": "stream_alert_triage", + "type": "workflow", + "name": "Stream Alert Triage", + "nameCn": "HTTP研判工作流", + "description": "Downstream alert triage workflow that writes triage results to SOC DB by default with optional JSONL output.", + "descriptionCn": "下游告警研判工作流,默认写入 SOC DB,并保留 JSONL 输出配置。", + "version": "1.0.0", + "author": "Flocks Team", + "license": "MIT", + "homepage": "", + "category": "workflow-automation", + "tags": [ + "siem", + "ndr", + "web-security" + ], + "useCases": [ + "alert-triage", + "log-analysis", + "security-reporting" + ], + "domains": [ + "security-ops" + ], + "capabilities": [ + "workflow", + "scheduled-task" + ], + "trust": "official", + "source": { + "kind": "bundled", + "path": "plugins/workflows/stream_alert_triage" + }, + "compatibility": { + "flocks": ">=0.8.0", + "os": [ + "darwin", + "linux", + "windows" + ] + }, + "dependencies": { + "skills": [], + "tools": [], + "python": [], + "external": [] + }, + "permissions": { + "tools": [], + "network": false, + "shell": false, + "filesystem": "read-write" + }, + "risk": { + "level": "medium", + "reasons": [] + }, + "entrypoints": [ + "workflow.json", + "workflow.md", + "guide.md", + "config.json" + ], + "components": [], + "checksums": {} +} diff --git a/.flocks/flockshub/plugins/workflows/stream_alert_triage/meta.json b/.flocks/flockshub/plugins/workflows/stream_alert_triage/meta.json new file mode 100644 index 000000000..92f3b5ed8 --- /dev/null +++ b/.flocks/flockshub/plugins/workflows/stream_alert_triage/meta.json @@ -0,0 +1,11 @@ +{ + "name": "stream_alert_triage", + "nameCn": "HTTP研判工作流", + "description": "Self-contained downstream pipeline for stream_alert_denoise. Loads enriched_alerts from JSONL files written by stream_alert_denoise, then runs leader/follower concurrent triage with the tdp_alert_triage logic INLINED (no sub-workflow invocation). Alerts sharing the same dedup_key in a batch form groups; only the LEADER (first occurrence) is triaged, FOLLOWERS reuse the leader result with no extra LLM calls. Each alert's 4 LLM analysis branches (survey / cve_related / cve_info / payload_analysis) still run in parallel via a nested 4-way ThreadPoolExecutor. The semantic-tagged markdown verdict report is attached to each alert via the `triage_report` field — NO per-alert markdown file is written to disk. Persistent cache (triage_cache.pkl, FIFO LRU, file-locked, atomic write): historic dedup_key hits reuse the cached verdict/title/triage_report instantly; misses run the full inline triage and persist new results. Default persistence writes enriched triage alerts into ~/.flocks/data/soc.db alert_records; optional JSONL output is controlled by config.json or triage_output_mode=jsonl/both.", + "category": "default", + "status": "active", + "createdBy": null, + "createdAt": 1780892155316, + "updatedAt": 1782876198603, + "id": "stream_alert_triage" +} diff --git a/.flocks/flockshub/plugins/workflows/stream_alert_triage/workflow.json b/.flocks/flockshub/plugins/workflows/stream_alert_triage/workflow.json new file mode 100644 index 000000000..b4ea894be --- /dev/null +++ b/.flocks/flockshub/plugins/workflows/stream_alert_triage/workflow.json @@ -0,0 +1,104 @@ +{ + "name": "stream_alert_triage", + "nameCn": "HTTP研判工作流", + "description": "你好", + "description_cn": "你好", + "start": "load_dedup_file", + "nodes": [ + { + "id": "load_dedup_file", + "type": "python", + "description": "一次性读取 stream_alert_denoise 写入的 JSONL 文件。输入优先级:input_paths > input_path > input_date(自动遍历该日所有 dedup_result_*.jsonl)> 当日默认。跳过 file_header 行,输出 enriched_alerts (list[dict])。", + "code": "\"\"\"\nload_dedup_file: 一次性读取 stream_alert_denoise 写入的 JSONL 文件。\n\n输入参数(按优先级):\n - input_paths list[str] 显式文件路径列表(来自 stream_alert_denoise.outputs.output_paths)\n - input_path str 单个文件路径(来自 stream_alert_denoise.outputs.output_path)\n - input_date str 日期 YYYY-MM-DD;读取该日目录下全部 dedup_result_*.jsonl\n - 默认:取“今天”目录下全部 dedup_result_*.jsonl\n\n跳过首行 file_header({_type: file_header}),其余每行为一条 enriched_alert。\n\n输出:\n - enriched_alerts list[dict] 含 dedup_key/is_duplicate 等字段\n - loaded_files list[str] 实际读取到的文件列表\n - load_stats dict 统计信息\n - concurrency int 外层并发数(默认 5)\n - max_triage_cache_size int 研判缓存 FIFO LRU 上限(默认 100000)\n\"\"\"\n\nimport datetime\nimport glob\nimport json\nimport os\nimport re\n\nfrom flocks.config import Config\n\nWORKFLOW_NAME = 'stream_alert_denoise'\n_JSONL_PREFIX = 'dedup_result'\n\n\ndef _dedup_root():\n flocks_root = Config().get_global().data_dir.parent\n return flocks_root / 'workspace' / 'workflows' / WORKFLOW_NAME\n\n\ndef _date_str(input_date):\n if input_date:\n s = str(input_date).strip()\n if re.match(r'^\\d{4}-\\d{2}-\\d{2}$', s):\n return s\n return datetime.datetime.now().strftime('%Y-%m-%d')\n\n\ndef _expand_paths(input_paths, input_path, input_date):\n paths = []\n if input_paths:\n if isinstance(input_paths, str):\n input_paths = [input_paths]\n for p in input_paths:\n if p:\n paths.append(os.path.expanduser(str(p)))\n if input_path:\n paths.append(os.path.expanduser(str(input_path)))\n\n if not paths:\n date_str = _date_str(input_date)\n day_dir = _dedup_root() / date_str\n pattern = str(day_dir / f'{_JSONL_PREFIX}_*.jsonl')\n paths = sorted(glob.glob(pattern))\n print(f'[load] auto-discovered date={date_str} dir={day_dir} files={len(paths)}')\n\n # Dedupe while preserving order; drop non-existent\n seen = set()\n final = []\n for p in paths:\n if p in seen:\n continue\n seen.add(p)\n if not os.path.exists(p):\n print(f'[load] WARNING: file not found, skipping: {p}')\n continue\n final.append(p)\n return final\n\n\ninput_paths = inputs.get('input_paths')\ninput_path = inputs.get('input_path')\ninput_date = inputs.get('input_date')\n\nfiles = _expand_paths(input_paths, input_path, input_date)\n\nenriched_alerts = []\nfile_stats = []\ntotal_skipped_headers = 0\ntotal_bad_lines = 0\n\nfor path in files:\n rec_count = 0\n skipped_headers = 0\n bad_lines = 0\n try:\n with open(path, 'r', encoding='utf-8') as f:\n for line in f:\n line = line.strip()\n if not line:\n continue\n try:\n obj = json.loads(line)\n except Exception:\n bad_lines += 1\n continue\n if isinstance(obj, dict) and obj.get('_type') == 'file_header':\n skipped_headers += 1\n continue\n enriched_alerts.append(obj)\n rec_count += 1\n except Exception as e:\n print(f'[load] WARNING: failed to read {path!r}: {e}')\n file_stats.append({'path': path, 'records': 0, 'headers': 0, 'bad_lines': 0, 'error': str(e)})\n continue\n total_skipped_headers += skipped_headers\n total_bad_lines += bad_lines\n file_stats.append({'path': path, 'records': rec_count, 'headers': skipped_headers, 'bad_lines': bad_lines})\n print(f'[load] {path}: {rec_count} records (+{skipped_headers} headers skipped)')\n\nload_stats = {\n 'file_count': len(files),\n 'record_count': len(enriched_alerts),\n 'header_skipped': total_skipped_headers,\n 'bad_lines': total_bad_lines,\n 'files': file_stats,\n}\nprint(f'[load] DONE files={len(files)} total_records={len(enriched_alerts)} '\n f'headers={total_skipped_headers} bad_lines={total_bad_lines}')\n\n# Down-stream runtime tunables\noutputs['enriched_alerts'] = enriched_alerts\noutputs['loaded_files'] = files\noutputs['load_stats'] = load_stats\noutputs['concurrency'] = max(1, int(inputs.get('concurrency', 5)))\noutputs['max_triage_cache_size'] = int(inputs.get('max_triage_cache_size', 100000))\noutputs['input_date'] = _date_str(input_date)\n" + }, + { + "id": "concurrent_triage", + "type": "python", + "description": "Leader/follower 分组并发研判节点(自包含,内联 tdp_alert_triage 逻辑)。先按 dedup_key 把 alerts 分组:每组只对 leader 研判,follower 复用 leader 结果。外层 ThreadPoolExecutor(concurrency) 处理 unique work units(concurrency 取值 1–5,默认 1),内层 ThreadPoolExecutor(4) 并行 survey / cve_related / cve_info / payload_analysis。dedup_key 在 triage_cache.pkl 命中时直接复用历史 verdict/title/triage_report;未命中则 leader 执行完整研判(情报查询 + 4 并行 LLM + attack_analysis + verdict + title + 聚合 markdown),完整研判 markdown 仅写入 alert 的 `triage_report` 字段,**不生成任何独立报告文件**。新结果合并写回 cache(FIFO LRU + 文件锁 + 原子落盘)。所有 enriched_with_triage alerts 默认写入 `~/.flocks/data/soc.db` 的 `alert_records` 表;可通过工作流目录 `config.json` 或运行输入将 `triage_output_mode` 切换为 `jsonl` / `both` / `none`,保留 `triage_result_NNN.jsonl` 可选输出。", + "code": "\"\"\"\nconcurrent_triage: leader/follower 分组并发研判 + dedup_key 缓存复用(自包含)。\n\n去重模式:\n 1. 输入 alerts 先按 dedup_key 分组 → unique dedup_keys 列表\n 2. 每个 group 只对 leader(首条)做研判;followers 复用 leader 结果,不重复调 LLM\n 3. 无 dedup_key 的 alert 各自独立成 work unit(防御性研判,无法复用)\n\n并发结构:\n 外层 ThreadPoolExecutor(max_workers=concurrency):处理 unique work unit\n (concurrency 取值 1–5,默认 1,由 inputs.concurrency 控制)\n 内层 ThreadPoolExecutor(max_workers=4):单条 alert 内 4 个 LLM 并行\n (survey / cve_related / cve_info / payload_analysis — 保留 tdp_alert_triage\n 的 4 并行分支结构)\n 稳态 LLM 并发峰值 ≈ concurrency × 4(仅 4 并行分支阶段,且分支全部启用时)\n\n研判产物只以字段形式附加到每条 alert(attack_verdict / risk_level /\nreport_title / triage_report ...),**不生成任何独立的 per-alert markdown 报告\n文件**,避免冗余落盘与跨日期路径失效。\n\ndedup_key 缓存(与 stream_alert_denoise 的 LSH 状态文件同根目录,逻辑独立):\n ~/.flocks/workspace/workflows/stream_alert_triage/triage_cache.pkl\n - cache 命中:直接复用历史 verdict/title/triage_report,**不调用 LLM**\n - cache 未命中:leader 执行完整内联研判(情报 + 4 并行 LLM + verdict + title + report);\n follower 直接广播 leader 结果\n - 新结果合并写回 cache,FIFO LRU 淘汰,文件锁 + 原子落盘\n\"\"\"\n\nimport ipaddress\nimport json\nimport os\nimport pickle\nimport re\nimport sys\nimport threading\nimport time\nfrom concurrent.futures import ThreadPoolExecutor, as_completed\n\nIS_WINDOWS = sys.platform == 'win32'\nif IS_WINDOWS:\n import msvcrt # noqa: F401\nelse:\n import fcntl # noqa: F401\n\nWORKFLOW_NAME = 'stream_alert_triage'\n\n# Per-call LLM timeout and retry budget for every analysis branch in this\n# node. Workflow LLM calls share the dedicated ``flocks-workflow-llm-loop``;\n# a single hung call (e.g. provider 504, slow TLS handshake) without a\n# timeout would otherwise pin one of the (already concurrency-limited)\n# worker threads for up to httpx's DEFAULT read timeout (10 min), serially\n# blocking the rest of the alert pipeline. 120s + 1 retry covers normal\n# slow-but-alive responses while still recovering from transient hangs.\nLLM_CALL_TIMEOUT_S = 120.0\nLLM_CALL_MAX_RETRIES = 1\n\nTRIAGE_FIELDS = (\n 'attack_verdict',\n 'risk_level',\n 'report_title',\n 'triage_report',\n 'attack_success',\n)\nVERDICT_LABELS = ('attack_success', 'attack_failed', 'attack', 'unknown', 'benign')\nVERDICT_RISK = {\n 'attack_success': 'High',\n 'attack_failed': 'Medium',\n 'attack': 'Medium',\n 'unknown': 'Medium',\n 'benign': 'Low',\n}\nVERDICT_CN = {\n 'attack_success': '攻击成功',\n 'attack_failed': '攻击失败',\n 'attack': '攻击',\n 'unknown': '未知',\n 'benign': '安全',\n}\nTRIAGE_REPORT_VERSION = 'soc.triage.markdown.v1'\nTRIAGE_REPORT_TAGS = (\n 'report_title',\n 'report_meta',\n 'analysis_steps',\n 'triage_conclusion',\n 'attack_payload',\n 'payload_explanation',\n 'response_evidence',\n 'key_evidence',\n 'disposal_recommendation',\n)\n\n\n# ── Cache persistence ─────────────────────────────────────────────────────────\n\ndef _cache_paths():\n from flocks.config import Config\n flocks_root = Config().get_global().data_dir.parent\n state_dir = flocks_root / 'workspace' / 'workflows' / WORKFLOW_NAME\n state_dir.mkdir(parents=True, exist_ok=True)\n return str(state_dir / 'triage_cache.pkl'), str(state_dir / 'triage_cache.lock')\n\n\ndef _acquire_lock(lock_path):\n fh = open(lock_path, 'w+')\n try:\n if IS_WINDOWS:\n fh.write('L'); fh.flush(); fh.seek(0)\n while True:\n try:\n msvcrt.locking(fh.fileno(), msvcrt.LK_LOCK, 1); break\n except OSError:\n continue\n else:\n fcntl.flock(fh.fileno(), fcntl.LOCK_EX)\n except BaseException:\n try:\n fh.close()\n except Exception:\n pass\n raise\n return fh\n\n\ndef _release_lock(fh):\n try:\n if IS_WINDOWS:\n try:\n fh.seek(0); msvcrt.locking(fh.fileno(), msvcrt.LK_UNLCK, 1)\n except OSError:\n pass\n else:\n fcntl.flock(fh.fileno(), fcntl.LOCK_UN)\n finally:\n fh.close()\n\n\ndef _load_cache(cache_path):\n if not os.path.exists(cache_path) or os.path.getsize(cache_path) == 0:\n return {}\n try:\n with open(cache_path, 'rb') as f:\n c = pickle.load(f)\n if not isinstance(c, dict):\n return {}\n print(f'[triage_cache] loaded {len(c)} entries from {cache_path}')\n return c\n except Exception as e:\n print(f'[triage_cache] WARNING: failed to load ({e}), starting fresh')\n return {}\n\n\ndef _save_cache_atomic(cache_path, cache):\n tmp = cache_path + '.tmp'\n try:\n with open(tmp, 'wb') as f:\n pickle.dump(cache, f); f.flush(); os.fsync(f.fileno())\n os.replace(tmp, cache_path)\n print(f'[triage_cache] saved {len(cache)} entries -> {cache_path}')\n except Exception as e:\n print(f'[triage_cache] WARNING: failed to save: {e}')\n if os.path.exists(tmp):\n try: os.remove(tmp)\n except Exception: pass\n\n\ndef _evict_lru(cache, max_keys):\n excess = len(cache) - max_keys\n if excess > 0:\n for k in list(cache.keys())[:excess]:\n del cache[k]\n return excess\n return 0\n\n\n# ── Runtime output config and persistence targets ──────────────────────────────\n#\n# Defaults are read from ~/.flocks/plugins/workflows/stream_alert_triage/config.json.\n# Runtime inputs override config values. The default mode is soc_db so SOC pages\n# read the same DB-backed dataset. JSONL remains available via config/input:\n# triage_output_mode = soc_db | jsonl | both | none\n# persist_triage_output = true (legacy alias that adds JSONL to soc_db)\n\nimport datetime as _datetime\n\nMAX_RECORDS_PER_FILE = 10000\n_TRIAGE_JSONL_PREFIX = 'triage_result'\n_TRIAGE_COUNTER_FILE = '.triage_counter.json'\n_WORKFLOW_CONFIG_PATH = os.path.expanduser('~/.flocks/plugins/workflows/stream_alert_triage/config.json')\n_DEFAULT_SOC_DB_PATH = os.path.expanduser('~/.flocks/data/soc.db')\n\n\ndef _load_workflow_config():\n try:\n with open(_WORKFLOW_CONFIG_PATH, 'r', encoding='utf-8') as f:\n cfg = json.load(f)\n if isinstance(cfg, dict):\n return cfg\n except FileNotFoundError:\n pass\n except Exception as e:\n print(f'[triage_config] WARNING: failed to read {_WORKFLOW_CONFIG_PATH}: {e}')\n return {}\n\n\ndef _configured_value(config, key, default=None):\n if key in inputs:\n value = inputs.get(key)\n if value is not None and not (isinstance(value, str) and not value.strip()):\n return value\n return config.get(key, default)\n\n\ndef _input_bool(value, default=False):\n if value is None:\n return default\n if isinstance(value, bool):\n return value\n if isinstance(value, (int, float)):\n return bool(value)\n text = str(value).strip().lower()\n if text in {'1', 'true', 'yes', 'y', 'on'}:\n return True\n if text in {'0', 'false', 'no', 'n', 'off'}:\n return False\n return default\n\n\ndef _resolve_output_config():\n config = _load_workflow_config()\n raw_mode = str(_configured_value(config, 'triage_output_mode', 'soc_db') or 'soc_db').strip().lower()\n mode_alias = {\n 'db': 'soc_db',\n 'sqlite': 'soc_db',\n 'sqlite_db': 'soc_db',\n 'soc': 'soc_db',\n 'json': 'jsonl',\n 'file': 'jsonl',\n 'files': 'jsonl',\n 'off': 'none',\n 'disabled': 'none',\n }\n requested_mode = mode_alias.get(raw_mode, raw_mode)\n if requested_mode not in {'soc_db', 'jsonl', 'both', 'none'}:\n print(f'[triage_config] WARNING: invalid triage_output_mode={raw_mode!r}; using soc_db')\n requested_mode = 'soc_db'\n\n legacy_jsonl = _input_bool(_configured_value(config, 'persist_triage_output', False), False)\n write_soc_db = requested_mode in {'soc_db', 'both'}\n write_jsonl = requested_mode in {'jsonl', 'both'}\n effective_mode = requested_mode\n if requested_mode == 'soc_db' and legacy_jsonl:\n write_jsonl = True\n effective_mode = 'both'\n if requested_mode == 'none':\n write_soc_db = False\n write_jsonl = False\n effective_mode = 'none'\n\n soc_db_path = os.path.expanduser(str(\n _configured_value(config, 'soc_db_path', _DEFAULT_SOC_DB_PATH) or _DEFAULT_SOC_DB_PATH\n ))\n jsonl_output_dir = _configured_value(config, 'jsonl_output_dir', '') or ''\n jsonl_output_dir = os.path.expanduser(str(jsonl_output_dir)) if jsonl_output_dir else ''\n return {\n 'config_path': _WORKFLOW_CONFIG_PATH,\n 'requested_mode': requested_mode,\n 'mode': effective_mode,\n 'write_soc_db': write_soc_db,\n 'write_jsonl': write_jsonl,\n 'soc_db_path': soc_db_path,\n 'jsonl_output_dir': jsonl_output_dir,\n }\n\n\n# ── Persisted JSONL output (optional; mirrors stream_alert_denoise layout) ─────\n#\n# Directory : ~/.flocks/workspace/workflows/stream_alert_triage//\n# Filename : triage_result_NNN.jsonl (3-digit zero-padded seq)\n# Layout : line 1 = {\"_type\":\"file_header\", ...}, subsequent lines = one\n# enriched_with_triage alert per line.\n# Counter : .triage_counter.json sidecar tracks (seq, count) so we don't\n# rescan every existing file on each run; auto-rolls over to a\n# new file when reaching MAX_RECORDS_PER_FILE.\n\n\ndef _triage_output_dir(configured_dir=''):\n \"\"\"Return output directory for triage_result_*.jsonl.\"\"\"\n if configured_dir:\n out_dir = configured_dir\n os.makedirs(out_dir, exist_ok=True)\n return out_dir\n from flocks.config import Config\n flocks_root = Config().get_global().data_dir.parent\n date_str = _datetime.datetime.now().strftime('%Y-%m-%d')\n out_dir = flocks_root / 'workspace' / 'workflows' / WORKFLOW_NAME / date_str\n out_dir.mkdir(parents=True, exist_ok=True)\n return str(out_dir)\n\n\ndef _triage_get_counter(out_dir):\n path = os.path.join(out_dir, _TRIAGE_COUNTER_FILE)\n try:\n with open(path, 'r', encoding='utf-8') as f:\n d = json.load(f)\n return int(d.get('seq', 0)), int(d.get('count', 0))\n except Exception:\n return 0, 0\n\n\ndef _triage_set_counter(out_dir, seq, count):\n path = os.path.join(out_dir, _TRIAGE_COUNTER_FILE)\n tmp = path + '.tmp'\n try:\n with open(tmp, 'w', encoding='utf-8') as f:\n json.dump({'seq': seq, 'count': count}, f)\n os.replace(tmp, path)\n except Exception:\n pass\n\n\ndef _triage_find_active_file(out_dir):\n \"\"\"Locate the active (latest, not-yet-full) jsonl file; create if none.\"\"\"\n seq, count = _triage_get_counter(out_dir)\n if seq > 0:\n path = os.path.join(out_dir, f'{_TRIAGE_JSONL_PREFIX}_{seq:03d}.jsonl')\n if os.path.exists(path):\n return path, count, seq\n import glob as _glob\n existing = sorted(_glob.glob(os.path.join(out_dir, _TRIAGE_JSONL_PREFIX + '_*.jsonl')))\n if not existing:\n return None, 0, 0\n latest = existing[-1]\n try:\n seq = int(os.path.basename(latest).replace(_TRIAGE_JSONL_PREFIX + '_', '').replace('.jsonl', ''))\n except ValueError:\n seq = len(existing)\n count = 0\n try:\n with open(latest, 'r', encoding='utf-8') as f:\n for line in f:\n if line.strip() and '\"_type\"' not in line:\n count += 1\n except Exception:\n pass\n return latest, count, seq\n\n\ndef _triage_write_jsonl(out_dir, alerts, run_id, run_stats):\n \"\"\"Append all alerts to today's triage_result_NNN.jsonl, rolling over at\n MAX_RECORDS_PER_FILE. Returns the list of files that were written to.\"\"\"\n now = _datetime.datetime.now()\n written = []\n active_path, active_count, seq = _triage_find_active_file(out_dir)\n remaining = list(alerts)\n while remaining:\n available = MAX_RECORDS_PER_FILE - active_count\n if available <= 0 or active_path is None:\n seq += 1\n active_path = os.path.join(out_dir, f'{_TRIAGE_JSONL_PREFIX}_{seq:03d}.jsonl')\n active_count = 0\n available = MAX_RECORDS_PER_FILE\n header = {\n '_type': 'file_header',\n 'created_at': now.isoformat(),\n 'date': now.strftime('%Y-%m-%d'),\n 'workflow': WORKFLOW_NAME,\n 'seq': seq,\n 'run_id': run_id,\n 'batch_total': run_stats.get('total'),\n 'batch_triaged': run_stats.get('triaged'),\n 'batch_followers_reused':run_stats.get('followers_reused'),\n 'batch_cache_hit': run_stats.get('cache_hit'),\n 'batch_triage_failed': run_stats.get('triage_failed'),\n }\n with open(active_path, 'w', encoding='utf-8') as hf:\n hf.write(json.dumps(header, ensure_ascii=False) + '\\n')\n batch = remaining[:available]\n remaining = remaining[available:]\n with open(active_path, 'a', encoding='utf-8') as af:\n for alert in batch:\n af.write(json.dumps(alert, ensure_ascii=False) + '\\n')\n active_count += len(batch)\n if active_path not in written:\n written.append(active_path)\n if remaining:\n active_path = None\n active_count = 0\n if written:\n _triage_set_counter(out_dir, seq, active_count)\n return written\n\n\n# ── SOC DB output (default) ───────────────────────────────────────────────────\n\ndef _ensure_soc_db_schema(conn):\n conn.execute(\"\"\"\n CREATE TABLE IF NOT EXISTS alert_records (\n row_id TEXT PRIMARY KEY,\n record_id TEXT,\n asset_date TEXT NOT NULL,\n source_file TEXT NOT NULL,\n line_number INTEGER NOT NULL,\n event_time INTEGER,\n source_type TEXT,\n threat_name TEXT,\n is_duplicate INTEGER NOT NULL DEFAULT 0,\n record_json TEXT NOT NULL\n )\n \"\"\")\n conn.execute('CREATE INDEX IF NOT EXISTS idx_alert_records_asset_date ON alert_records(asset_date)')\n conn.execute('CREATE INDEX IF NOT EXISTS idx_alert_records_event_time ON alert_records(event_time)')\n conn.execute('CREATE INDEX IF NOT EXISTS idx_alert_records_source_type ON alert_records(source_type)')\n conn.execute('CREATE INDEX IF NOT EXISTS idx_alert_records_threat_name ON alert_records(threat_name)')\n\n\ndef _event_time_value(alert):\n for key in ('time', 'event_time', 'timestamp', 'timestamp_real', 'occur_time', 'created_at'):\n value = alert.get(key)\n if value in (None, ''):\n continue\n if isinstance(value, (int, float)):\n ts = float(value)\n if ts > 100000000000:\n ts = ts / 1000.0\n return int(ts)\n text = str(value).strip()\n if not text:\n continue\n try:\n ts = float(text)\n if ts > 100000000000:\n ts = ts / 1000.0\n return int(ts)\n except Exception:\n pass\n normalized = text.replace('Z', '+00:00')\n try:\n return int(_datetime.datetime.fromisoformat(normalized).timestamp())\n except Exception:\n pass\n for fmt in ('%Y-%m-%d %H:%M:%S', '%Y/%m/%d %H:%M:%S', '%Y-%m-%d %H:%M', '%Y/%m/%d %H:%M'):\n try:\n return int(_datetime.datetime.strptime(text, fmt).timestamp())\n except Exception:\n continue\n return int(time.time())\n\n\ndef _asset_date_value(alert, event_time):\n value = alert.get('asset_date') or alert.get('_asset_date') or alert.get('date')\n if value:\n text = str(value).strip()\n if re.match(r'^\\d{4}-\\d{2}-\\d{2}$', text):\n return text\n try:\n return _datetime.datetime.fromtimestamp(int(event_time)).strftime('%Y-%m-%d')\n except Exception:\n return _datetime.datetime.now().strftime('%Y-%m-%d')\n\n\ndef _source_type_value(alert):\n for key in ('source_type', '_source_type', 'data_source', 'log_type', 'vendor', 'device_type'):\n value = alert.get(key)\n if value not in (None, ''):\n return str(value)\n return ''\n\n\ndef _record_id_value(alert):\n for key in ('record_id', 'id', 'uuid', 'event_id', 'dedup_key'):\n value = alert.get(key)\n if value not in (None, ''):\n return str(value)\n return ''\n\n\ndef _stable_row_id(alert, source_file, line_number, event_time):\n existing = alert.get('row_id') or alert.get('_row_id')\n if existing:\n return str(existing)\n import hashlib as _hashlib\n basis = {\n 'record_id': _record_id_value(alert),\n 'dedup_key': alert.get('dedup_key', ''),\n 'time': event_time,\n 'source_file': source_file,\n 'line_number': line_number,\n 'sip': alert.get('sip', ''),\n 'sport': alert.get('sport', ''),\n 'dip': alert.get('dip', ''),\n 'dport': alert.get('dport', ''),\n 'threat_rule_id': alert.get('threat_rule_id') or alert.get('rule_id') or '',\n }\n raw = json.dumps(basis, sort_keys=True, ensure_ascii=False)\n return _hashlib.sha256(raw.encode('utf-8')).hexdigest()\n\n\ndef _triage_write_soc_db(db_path, alerts, run_id):\n import sqlite3\n\n db_dir = os.path.dirname(db_path)\n if db_dir:\n os.makedirs(db_dir, exist_ok=True)\n default_source_file = ''\n loaded_files = inputs.get('loaded_files') or []\n if isinstance(loaded_files, list) and len(loaded_files) == 1:\n default_source_file = str(loaded_files[0])\n\n persisted_at = _datetime.datetime.now().isoformat()\n rows = []\n for idx, alert in enumerate(alerts, 1):\n record = dict(alert)\n record['_triage_run_id'] = run_id\n record['_triage_persisted_at'] = persisted_at\n source_file = str(\n record.get('source_file')\n or record.get('_source_file')\n or record.get('file_path')\n or default_source_file\n or 'stream_alert_triage'\n )\n try:\n line_number = int(record.get('line_number') or record.get('_line_number') or idx)\n except Exception:\n line_number = idx\n event_time = _event_time_value(record)\n asset_date = _asset_date_value(record, event_time)\n record_id = _record_id_value(record)\n source_type = _source_type_value(record)\n threat_name = str(record.get('threat_name') or record.get('rule_name') or '')\n is_duplicate = 1 if record.get('is_duplicate') else 0\n row_id = _stable_row_id(record, source_file, line_number, event_time)\n rows.append((\n row_id,\n record_id,\n asset_date,\n source_file,\n line_number,\n event_time,\n source_type,\n threat_name,\n is_duplicate,\n json.dumps(record, ensure_ascii=False),\n ))\n\n with sqlite3.connect(db_path) as conn:\n _ensure_soc_db_schema(conn)\n conn.executemany(\"\"\"\n INSERT INTO alert_records (\n row_id, record_id, asset_date, source_file, line_number,\n event_time, source_type, threat_name, is_duplicate, record_json\n ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n ON CONFLICT(row_id) DO UPDATE SET\n record_id=excluded.record_id,\n asset_date=excluded.asset_date,\n source_file=excluded.source_file,\n line_number=excluded.line_number,\n event_time=excluded.event_time,\n source_type=excluded.source_type,\n threat_name=excluded.threat_name,\n is_duplicate=excluded.is_duplicate,\n record_json=excluded.record_json\n \"\"\", rows)\n conn.commit()\n return {'path': db_path, 'table': 'alert_records', 'rows': len(rows)}\n\n\n# ── LLM provider warm-up (avoid cold-start race in _parallel_4_branches) ──────\n#\n# Background: when this node starts, the LLM provider (e.g. threatbook-cn-llm)\n# is lazy-initialized on the first call. Inside `_parallel_4_branches` we\n# submit 4 LLM calls to a ThreadPoolExecutor simultaneously; whichever one\n# wins the race may race against provider registration and fail with\n# \"provider 'xxx' not exists\" while subsequent calls succeed. A single\n# synchronous warm-up call before any concurrent fan-out forces the provider\n# to finish registering on the main thread, eliminating the race entirely.\n#\n# Failure of the warm-up is non-fatal — we just log and continue. The first\n# real LLM call will still see the same error and surface it normally.\n\ndef _warmup_llm():\n \"\"\"Force LLM provider lazy-init on the main thread before any fan-out.\"\"\"\n t0 = time.time()\n try:\n llm.ask('ping')\n print(f'[triage] LLM provider warm-up OK in {round((time.time()-t0)*1000)}ms')\n return True\n except Exception as e:\n print(f'[triage] WARNING: LLM warm-up failed ({type(e).__name__}: '\n f'{str(e)[:200]}); proceeding anyway')\n return False\n\n\n# ── Inline triage helpers (mirroring tdp_alert_triage docs version) ───────────\n\ndef _strip_think(text):\n return re.sub(r'[\\s\\S]*?', '', str(text or ''), flags=re.IGNORECASE).strip()\n\n\ndef _is_public_ip(value):\n try:\n ip_obj = ipaddress.ip_address(value)\n except Exception:\n return False\n return not (ip_obj.is_private or ip_obj.is_loopback or ip_obj.is_reserved\n or ip_obj.is_link_local or ip_obj.is_multicast or ip_obj.is_unspecified)\n\n\ndef _pick(*values):\n for v in values:\n if v not in (None, '', [], {}):\n return v\n return ''\n\n\ndef _parse_alert(alert_input):\n \"\"\"Mirrors tdp_alert_triage.receive_alert. Supports three input shapes:\n nested TDP (net.http.url), flat TDP (net_http_url), normalized (req_http_url).\n \"\"\"\n if isinstance(alert_input, str):\n try:\n alert_input = json.loads(alert_input)\n except Exception:\n alert_input = {}\n if isinstance(alert_input, list):\n alert_data = alert_input[0] if alert_input else {}\n elif isinstance(alert_input, dict) and isinstance(alert_input.get('data'), list):\n alert_data = alert_input.get('data', [])[0] if alert_input.get('data') else {}\n else:\n alert_data = alert_input if isinstance(alert_input, dict) else {}\n\n net = alert_data.get('net', {}) or {}\n http = net.get('http', {}) or {}\n threat = alert_data.get('threat', {}) or {}\n assets = alert_data.get('assets', {}) or {}\n\n src_ip = _pick(\n alert_data.get('attacker'), alert_data.get('external_ip'),\n net.get('src_ip'), net.get('flow_src_ip'),\n alert_data.get('net_real_src_ip'),\n alert_data.get('sip'), alert_data.get('src_ip'), alert_data.get('src'),\n )\n dst_ip = _pick(\n alert_data.get('victim'), alert_data.get('machine'),\n alert_data.get('server_ip'), net.get('dest_ip'), net.get('flow_dest_ip'),\n alert_data.get('net_dest_ip'),\n alert_data.get('dip'), alert_data.get('dst_ip'), alert_data.get('dst'),\n )\n src_port = _pick(\n net.get('src_port'), net.get('flow_src_port'),\n alert_data.get('external_port'), alert_data.get('net_src_port'),\n alert_data.get('sport'), alert_data.get('src_port'), 0,\n )\n dst_port = _pick(\n net.get('dest_port'), net.get('flow_dest_port'),\n alert_data.get('server_port'), alert_data.get('machine_port'),\n alert_data.get('net_dest_port'),\n alert_data.get('dport'), alert_data.get('dst_port'), 0,\n )\n protocol = _pick(\n net.get('app_proto'), net.get('type'), net.get('proto'),\n alert_data.get('net_app_proto'), alert_data.get('protocol'),\n alert_data.get('event_type'), 'TCP',\n )\n alert_type = _pick(\n threat.get('name'), alert_data.get('threat_name'),\n alert_data.get('vuln_name'),\n alert_data.get('alert_type'), threat.get('topic'),\n alert_data.get('type'), 'unknown',\n )\n severity = _pick(\n threat.get('severity'), alert_data.get('threat_severity'),\n alert_data.get('severity'), threat.get('level'),\n alert_data.get('level'), 'medium',\n )\n\n req_line = _pick(http.get('reqs_line'), alert_data.get('req_line'), alert_data.get('net_http_reqs_line'))\n req_header = _pick(http.get('reqs_header'), alert_data.get('req_header'), alert_data.get('net_http_reqs_header'))\n req_body = _pick(http.get('req_body'), alert_data.get('req_body'), alert_data.get('net_http_reqs_body'))\n resp_line = _pick(http.get('resp_line'), alert_data.get('rsp_line'), alert_data.get('resp_line'),\n alert_data.get('net_http_resp_line'))\n resp_header = _pick(http.get('resp_header'), alert_data.get('rsp_header'), alert_data.get('resp_header'),\n alert_data.get('net_http_resp_header'))\n resp_body = _pick(http.get('resp_body'), alert_data.get('rsp_body'), alert_data.get('resp_body'),\n alert_data.get('net_http_resp_body'))\n status = _pick(http.get('status'), alert_data.get('http_status'),\n alert_data.get('net_http_status'), alert_data.get('rsp_status_code'), 0)\n\n host = _pick(http.get('reqs_host'), alert_data.get('url_host'), http.get('domain'),\n alert_data.get('req_host'), alert_data.get('net_http_reqs_host'), dst_ip)\n raw_url = _pick(http.get('raw_url'), http.get('url'),\n alert_data.get('url_path'),\n alert_data.get('net_http_url'), alert_data.get('req_http_url'),\n alert_data.get('uri'))\n url = ''\n if host and raw_url:\n scheme = 'https' if net.get('is_https') else 'http'\n url = raw_url if str(raw_url).startswith(('http://', 'https://')) else f'{scheme}://{host}{raw_url}'\n elif raw_url and str(raw_url).startswith(('http://', 'https://')):\n url = raw_url\n elif raw_url:\n url = raw_url\n\n payload = f'请求行: {req_line}\\n请求头: {req_header}\\n请求体: {req_body}'\n response = f'状态行: {resp_line}\\n响应头: {resp_header}\\n响应体: {resp_body}'\n threat_result = _pick(threat.get('result'), alert_data.get('threat_result'))\n threat_msg = _pick(threat.get('msg'), alert_data.get('threat_msg'))\n\n log_text = (\n f'[告警基本信息]\\n'\n f'告警类型: {alert_type}\\n严重级别: {severity}\\n'\n f'源地址: {src_ip}:{src_port}\\n目的地址: {dst_ip}:{dst_port}\\n'\n f'协议: {protocol}\\nURL: {url}\\nHTTP状态码: {status}\\n'\n f'TDP判定: {threat_result}\\nTDP消息: {threat_msg}\\n\\n'\n f'[HTTP请求内容]\\n{payload}\\n\\n'\n f'[HTTP响应内容]\\n{response}'\n )\n\n vuln_text = '\\n'.join(str(item) for item in [\n threat_msg, threat.get('topic', ''),\n alert_data.get('data', ''), url,\n json.dumps(threat.get('tag', []), ensure_ascii=False),\n ] if item)\n vuln_matches = sorted(set(re.findall(r'\\b(?:CVE|CNVD|CNNVD|XVE)-[A-Za-z0-9._-]+\\b', vuln_text, flags=re.I)))\n\n iocs = []\n for candidate in [src_ip, dst_ip]:\n if candidate:\n iocs.append({'type': 'ip', 'value': candidate})\n if url:\n iocs.append({'type': 'url', 'value': url})\n if host and not re.match(r'^\\d{1,3}(?:\\.\\d{1,3}){3}(?::\\d+)?$', str(host)):\n iocs.append({'type': 'domain', 'value': str(host).split(':')[0]})\n\n return {\n 'src_ip': src_ip, 'dst_ip': dst_ip, 'src_port': src_port, 'dst_port': dst_port,\n 'protocol': protocol, 'payload': payload, 'response': response,\n 'url': url, 'status': status,\n 'alert_type': alert_type, 'severity': severity,\n 'vuln_id': vuln_matches[0] if vuln_matches else '',\n 'vuln_candidates': vuln_matches,\n 'threat_result': threat_result, 'threat_msg': threat_msg,\n 'failed_by': threat.get('failed_by', []),\n 'asset_ip': assets.get('ip', ''), 'asset_name': assets.get('name', []),\n 'iocs': iocs, 'log_text': log_text,\n }\n\n\ndef _prepare_intel(parsed):\n \"\"\"Mirrors tdp_alert_triage.prepare_intel. Pre-fetches IP/domain/URL threat intel\n and CVE info so the parallel LLM tasks have concrete context to consume.\n \"\"\"\n iocs = parsed.get('iocs', [])\n intel_results = []\n seen = set()\n for ioc in iocs:\n ioc_type = ioc.get('type', '')\n ioc_value = str(ioc.get('value', '')).strip()\n key = (ioc_type, ioc_value)\n if not ioc_value or key in seen:\n continue\n seen.add(key)\n if ioc_type == 'ip':\n if not _is_public_ip(ioc_value):\n continue\n r = tool.run_safe('threatbook_ip_query', ip=ioc_value)\n if r['success']:\n intel_results.append({'source': 'threatbook', 'type': 'ip',\n 'value': ioc_value, 'result': r['text']})\n elif ioc_type == 'domain':\n r = tool.run_safe('threatbook_domain_query', domain=ioc_value)\n if r['success']:\n intel_results.append({'source': 'threatbook', 'type': 'domain',\n 'value': ioc_value, 'result': r['text']})\n elif ioc_type == 'url':\n r = tool.run_safe('threatbook_url_query', url=ioc_value)\n if r['success']:\n intel_results.append({'source': 'threatbook', 'type': 'url',\n 'value': ioc_value, 'result': r['text']})\n\n vuln_info = {}\n vuln_id = parsed.get('vuln_id', '')\n if vuln_id:\n r = tool.run_safe('__mcp_vuln_query', vuln_id=vuln_id)\n if r['success']:\n try:\n obj = r.get('obj')\n if isinstance(obj, str):\n obj = json.loads(obj)\n vuln_info = obj if isinstance(obj, dict) else {'raw_result': r.get('text', '')}\n except Exception:\n vuln_info = {'raw_result': r.get('text', '')}\n\n intel_content = '\\n'.join(\n f\"[{i['source']}/{i['type']}] {i['value']}\\n{i['result']}\" for i in intel_results\n ) or '(无可用情报数据)'\n vuln_content = (\n json.dumps(vuln_info, ensure_ascii=False, indent=2)\n if vuln_info else '(无可用漏洞情报数据)'\n )\n return intel_results, intel_content, vuln_info, vuln_content\n\n\n# ── 4 LLM analyses (the parallel branches from tdp_alert_triage) ─────────────\n\ndef _ask_llm(prompt):\n \"\"\"Wrap ``llm.ask`` with the workflow-wide timeout + retry budget.\n\n Centralizing this avoids a hung provider request (no TCP timeout from\n upstream) from blocking a worker thread indefinitely. Any call that does\n not need bespoke parameters should go through here.\n \"\"\"\n return llm.ask(\n prompt,\n timeout_s=LLM_CALL_TIMEOUT_S,\n max_retries=LLM_CALL_MAX_RETRIES,\n )\n\n\ndef _llm_survey(log_text, intel_content):\n prompt = f'''你是一个专业的Web日志分析专家。请总结以下IP的情报数据中的空间测绘信息。\n1. 如果该IP没有测绘信息,则不列出。\n2. 如果IP有测绘信息,则以简短的语言对该IP的测绘信息进行总结,关键说明ip的标签和测绘信息显示有哪些服务或者应用资产。\n3. 多个IP的测绘信息以无序列表显示,每个ip数据描述占一行数据。\n4. 不需要生成其他额外的补充信息。\n\n## 情报参考信息\n{intel_content}\n\n## 用户的原始输入日志\n{log_text}\n'''\n return _strip_think(_ask_llm(prompt))\n\n\ndef _llm_cve_related(log_text):\n prompt = f'''请从以下的日志数据中提取漏洞编号。\n要求:\n1. 仅从日志文本中识别漏洞编号,不要做任何推测。\n2. 如果日志中存在漏洞编号,则用简短语言描述,如:\"日志中存在漏洞编号:CVE-****-****\"。\n3. 如果日志中不存在漏洞编号,则输出:\"日志中无关联漏洞情报\"。\n\n日志数据如下:\n{log_text}\n'''\n return _strip_think(_ask_llm(prompt))\n\n\ndef _llm_cve_info(log_text, vuln_content):\n prompt = f'''你是一个专业的Web日志分析专家。参考情报信息中的漏洞数据,简要说明关联的CVE漏洞信息。\n1. 不要输出任何解释说明,只输出漏洞基本信息。不需要生成漏洞的处置建议或修复措施等。\n\n## 情报参考信息\n{vuln_content}\n\n## 用户的原始输入日志\n{log_text}\n'''\n return _strip_think(_ask_llm(prompt))\n\n\ndef _llm_payload_analysis(log_text):\n prompt = f'''你是一个专业的Web日志分析专家。根据用户输入的日志进行攻击负载分析。\n1. 首先分析日志中是否包含攻击负载,并给出判定依据。\n2. 不要进行攻击意图分析、攻击影响分析。\n3. 用简短的语言在一段话中进行描述。\n\n## 用户的原始输入日志:\n{log_text}\n'''\n return _strip_think(_ask_llm(prompt))\n\n\ndef _parallel_4_branches(parsed, intel_content, vuln_content):\n \"\"\"4 LLM analyses run in parallel — keeps tdp_alert_triage's fan-out structure.\"\"\"\n log_text = parsed.get('log_text', '')\n with ThreadPoolExecutor(max_workers=4, thread_name_prefix='triage_branch') as pool:\n futs = {\n 'survey_result': pool.submit(_llm_survey, log_text, intel_content),\n 'cve_related_result': pool.submit(_llm_cve_related, log_text),\n 'cve_info_result': pool.submit(_llm_cve_info, log_text, vuln_content),\n 'payload_analysis_result': pool.submit(_llm_payload_analysis, log_text),\n }\n out = {}\n for name, fut in futs.items():\n try:\n out[name] = fut.result()\n except Exception as e:\n print(f'[triage] WARNING: branch {name} failed: {e}')\n out[name] = ''\n return out\n\n\n# ── Join-point LLM analyses (attack_analysis_result -> verdict -> title) ──────\n\ndef _llm_attack_analysis(log_text):\n prompt = f'''你是一名专业且经验丰富的网络安全分析师和Web日志分析专家,你对HTTP协议以及Web攻击有着深入的理解,并且你能够快速识别和应对各种网络威胁。你的任务是对提供的HTTP请求与响应内容进行详细的专业分析,并判断日志请求的攻击状态。\n\n请严格遵循以下指令进行思考和分析:\n1. 攻击状态只能从以下情况中选择一种:[\"攻击成功\", \"攻击失败\", \"攻击\", \"未知\", \"安全\"]。\n2. 从日志中提取出\"HTTP请求内容\"和\"HTTP响应内容\"。请注意,HTTP请求内容和HTTP响应内容是分开的,请不要混淆,有些日志中没有包含HTTP响应内容,请不要将HTTP请求内容和HTTP响应内容混淆。分析后请你记住哪些是HTTP请求内容,哪些是HTTP响应内容。\n3. 请检查HTTP响应状态码,2xx或者3xx状态码都代表本次HTTP请求成功,4xx或者5xx状态码大多数情况下都代表请求失败,只有在请求成功的情况下才能对攻击是否成功进行后续判断。\n\n各攻击状态的定义以及判定标准:\n1. 攻击成功:\n(1) 首先分析日志中是否含有清晰的\"HTTP响应内容\",如果日志中没有\"HTTP响应内容\",则肯定不属于攻击成功。\n(2) 如果日志中未提供\"HTTP响应内容\",即使HTTP请求内容中包含攻击者预期的结果,也不能判定为攻击成功。\n(3) 从日志中提取出\"HTTP请求内容\"和\"HTTP响应内容\"。请深入分析\"HTTP响应内容\",并判定其是否为\"HTTP请求内容\"攻击成功时的预期结果,这是判定攻击成功的强依据。请注意,HTTP响应码200仅表示网络连接成功,不代表攻击攻击成功。\n(4) 分析HTTP请求内容和HTTP响应内容,只有当HTTP响应内容中明确包含攻击载荷在目标机器上成功执行的证据,并且HTTP请求内容中包含攻击载荷的特征,则判定为\"攻击成功\"。\n(5) 请注意:攻击成功的判定必须包含HTTP响应内容。如果不包含HTTP响应内容,则肯定不属于攻击成功。\n(6) 请注意:如果不包含HTTP响应内容,即使HTTP请求内容是攻击,这也不属于攻击成功。\n2. 攻击失败:\n(1) 分析HTTP请求内容和HTTP响应内容,如果HTTP响应内容中明确包含攻击载荷在目标机器上执行失败或者被阻止的证据,并且HTTP请求内容中包含攻击载荷的特征,则判定为\"攻击失败\"。\n(2) 攻击失败的判定必须包含HTTP响应内容。如果不包含HTTP响应内容,则肯定不属于攻击失败。\n3. 攻击:\n(1) 在\"HTTP请求内容\"或\"HTTP响应内容\"中发现任何证明存在攻击意图的证据,即可判定为存在攻击行为。但如果不符合上述的攻击成功或者攻击失败的标准,则\"攻击状态\"为\"攻击\"。\n(2) 请注意:如果日志中只提供了\"HTTP请求内容\",且没有提供\"HTTP响应内容\",且HTTP的请求内容分析中是包含攻击行为的,则\"攻击状态\"为\"攻击\"。\n4. 未知:\n(1) 如果不能100%确定HTTP通信的攻击结果,那么请在\"攻击状态\"处给出\"未知\"。\n(2) 请注意:如果在你给的判定原因中存在\"可能\"等不确定词汇,都代表你不能对你的结论100%确定,那么请在\"攻击状态\"处给出\"未知\"。\n5. 安全:\n(1) 如果\"HTTP请求内容\"和\"HTTP响应内容\"中都没有任何攻击意图的证据,那么请在\"攻击状态\"处给出\"安全\"。\n\n## 日志内容\n{log_text}\n\n## 输出要求\n请按下列结构输出(中文):\n1. 攻击状态: [攻击成功/攻击失败/攻击/未知/安全]\n2. 判定依据: 简要说明请求与响应的关键证据\n3. 详细分析: 不超过200字\n'''\n return _strip_think(_ask_llm(prompt))\n\n\ndef _llm_attack_verdict(attack_analysis_result):\n prompt = f'''你是一个专业的Web日志分析专家。请据参考信息,直接输出攻击判定类别:\nattack_success:表示攻击成功。\nattack_failed:表示攻击失败。\nattack:表示是日志内容是攻击。\nunknown:表示未知。\nbenign:是安全。\n不额外输出任何其他信息,包括解释、判定依据等。\n\n## 日志分析结果:\n{attack_analysis_result}\n'''\n raw = _strip_think(_ask_llm(prompt)).strip().lower()\n return next((v for v in VERDICT_LABELS if v in raw), 'unknown')\n\n\ndef _llm_report_title(alert_type, attack_verdict, attack_analysis_result):\n prompt = f'''你是一个专业的Web日志分析专家。请基于以下分析结果,生成一份不超过 30 字的中文报告标题。\n要求:\n1. 标题必须能体现\"攻击类型\"或\"攻击结果分析的结论\"。\n2. 不要带书名号、引号或其他标点。\n3. 只输出标题本身,不要任何解释或说明。\n\n## 攻击类型\n{alert_type}\n\n## 攻击判定\n{attack_verdict}\n\n## 攻击分析结果\n{attack_analysis_result}\n'''\n raw = _strip_think(_ask_llm(prompt)).strip()\n return raw.splitlines()[0].strip(' \"\\'《》[]【】') if raw else f'{alert_type} - {attack_verdict}'\n\n\ndef _clip_text(value, limit=3000):\n text = str(value or '').strip()\n if len(text) > limit:\n return text[:limit] + '\\n...(已截断)'\n return text or '未提供'\n\n\ndef _fence_text(value):\n text = _clip_text(value, 6000)\n return text.replace('```', '``\\\\u200b`')\n\n\ndef _extract_tagged_triage_report(text):\n text = _strip_think(text)\n m = re.search(r']*>[\\s\\S]*?', text, flags=re.I)\n return m.group(0).strip() if m else text.strip()\n\n\ndef _is_valid_triage_report(markdown):\n text = str(markdown or '')\n if not re.search(r']*version=[\"\\']soc\\.triage\\.markdown\\.v1[\"\\'][^>]*>', text, flags=re.I):\n return False\n if not re.search(r'', text, flags=re.I):\n return False\n for tag in TRIAGE_REPORT_TAGS:\n if not re.search(rf'<{tag}\\b[^>]*>', text, flags=re.I):\n return False\n if not re.search(rf'', text, flags=re.I):\n return False\n return True\n\n\ndef _is_current_triage_fields(fields):\n if not isinstance(fields, dict):\n return False\n return _is_valid_triage_report(fields.get('triage_report'))\n\n\ndef _format_intel_brief(intel_results):\n if not intel_results:\n return '未查询到外部威胁情报。'\n lines = []\n for intel in intel_results[:6]:\n lines.append(f\"- {intel.get('source', 'intel')} / {intel.get('type', 'ioc')}: {intel.get('value', '')} => {_clip_text(intel.get('result'), 500)}\")\n return '\\n'.join(lines)\n\n\ndef _build_default_tagged_triage_report(parsed, intel_results, vuln_info, branches,\n attack_analysis_result, attack_verdict,\n report_title, risk_level):\n verdict_cn = VERDICT_CN.get(attack_verdict, attack_verdict)\n title = report_title or f'{parsed.get(\"alert_type\", \"Web日志告警\")} - {verdict_cn}'\n payload = _fence_text(parsed.get('payload', ''))\n response = _fence_text(parsed.get('response', ''))\n url = parsed.get('url') or '未提供'\n threat_msg = parsed.get('threat_msg') or '未提供'\n status = parsed.get('status') or '未提供'\n survey = _clip_text(branches.get('survey_result'), 1500)\n cve_related = _clip_text(branches.get('cve_related_result'), 1500)\n cve_info = _clip_text(branches.get('cve_info_result'), 1500)\n payload_analysis = _clip_text(branches.get('payload_analysis_result'), 1500)\n attack_analysis = _clip_text(attack_analysis_result, 1500)\n intel_brief = _format_intel_brief(intel_results)\n vuln_brief = _clip_text(json.dumps(vuln_info, ensure_ascii=False, indent=2), 1800) if vuln_info else '未查询到漏洞详情。'\n\n if attack_verdict == 'attack_success':\n recommendation = '立即核查目标资产是否产生异常文件、进程、账号或敏感数据访问记录,并按成功入侵事件升级处置。'\n elif attack_verdict == 'attack_failed':\n recommendation = '保留拦截与响应证据,复核同源后续请求,并确认防护策略是否持续生效。'\n elif attack_verdict == 'benign':\n recommendation = '作为低风险事件留痕,结合资产白名单或业务访问记录确认是否可降噪。'\n else:\n recommendation = '补齐目标 Web 日志、响应体、主机侧进程和文件证据后再确认攻击成功性。'\n\n return f'''\n\n\n# {title}\n\n\n\n- 研判结论:{verdict_cn}\n- 风险等级:{risk_level}\n- 告警类型:{parsed.get('alert_type', 'unknown')}\n- 源 IP:{parsed.get('src_ip', 'N/A')}:{parsed.get('src_port', 'N/A')}\n- 目标资产:{parsed.get('dst_ip', 'N/A')}:{parsed.get('dst_port', 'N/A')}\n- URL:{url}\n- 响应码:{status}\n\n\n\n## 分析步骤\n\n### 1. 日志类型分析\n该告警按 Web 日志处理,已提取 HTTP 请求、响应、源地址、目标资产、URL、响应码和 TDP 判定字段。\n\n### 2. 情报信息\n{intel_brief}\n\n### 3. 测绘信息\n{survey}\n\n### 4. 告警关联漏洞情报\n{cve_related}\n\n### 5. 攻击负载分析\n{payload_analysis}\n\n### 6. 攻击分析结果\n{attack_analysis}\n\n\n\n## 研判结论\n当前研判结论为 **{verdict_cn}**,风险等级为 **{risk_level}**。TDP 消息为:{threat_msg}\n\n\n\n## 攻击payload\n\n```http\n{payload}\n```\n\n\n\n## 具体含义解释\n\n1. 请求命中的告警类型为 {parsed.get('alert_type', 'unknown')}。\n2. 请求 URL 为 {url},需要结合参数、请求体和目标业务判断攻击意图。\n3. Payload 分析结果:{payload_analysis}\n\n\n\n## 响应证据\n\n```http\n{response}\n```\n\n响应码为 {status}。如果响应体未提供或没有执行成功证据,则不能仅凭请求侧 payload 判定攻击成功。\n\n\n\n## 重要证据\n\n1. 源地址:{parsed.get('src_ip', 'N/A')}:{parsed.get('src_port', 'N/A')}。\n2. 目标资产:{parsed.get('dst_ip', 'N/A')}:{parsed.get('dst_port', 'N/A')}。\n3. TDP 判定:{parsed.get('threat_result', '未提供')};TDP 消息:{threat_msg}。\n4. 漏洞详情:{vuln_brief}\n\n\n\n## 处置建议\n\n1. {recommendation}\n2. 检索同源 IP、同一 dedup_key、同一 URL 或同一漏洞特征的横向告警。\n3. 结合目标资产 Web 访问日志、主机审计、EDR 与 WAF 日志补齐证据链。\n\n\n'''\n\n\ndef _llm_triage_report_markdown(parsed, intel_results, vuln_info, branches,\n attack_analysis_result, attack_verdict,\n report_title, risk_level):\n verdict_cn = VERDICT_CN.get(attack_verdict, attack_verdict)\n context = json.dumps({\n 'report_title': report_title,\n 'attack_verdict': attack_verdict,\n 'verdict_cn': verdict_cn,\n 'risk_level': risk_level,\n 'alert': {\n 'alert_type': parsed.get('alert_type'),\n 'severity': parsed.get('severity'),\n 'src_ip': parsed.get('src_ip'),\n 'src_port': parsed.get('src_port'),\n 'dst_ip': parsed.get('dst_ip'),\n 'dst_port': parsed.get('dst_port'),\n 'url': parsed.get('url'),\n 'status': parsed.get('status'),\n 'threat_result': parsed.get('threat_result'),\n 'threat_msg': parsed.get('threat_msg'),\n 'payload': parsed.get('payload'),\n 'response': parsed.get('response'),\n },\n 'survey_result': branches.get('survey_result'),\n 'cve_related_result': branches.get('cve_related_result'),\n 'cve_info_result': branches.get('cve_info_result'),\n 'payload_analysis_result': branches.get('payload_analysis_result'),\n 'attack_analysis_result': attack_analysis_result,\n 'intel_results': intel_results,\n 'vuln_info': vuln_info,\n }, ensure_ascii=False, indent=2)\n\n prompt = f'''你是一名资深 SOC 告警研判分析师。请根据输入上下文,生成一份供前端直接渲染的 SOC 告警研判报告 markdown。\n\n硬性要求:\n1. 只输出带语义标签的 markdown,不要输出 JSON,不要解释规则。\n2. 根标签必须是 。\n3. 必须按顺序输出并完整闭合这些标签:\n 。\n4. 标签外不得输出正文内容。标签内可以使用 markdown 标题、列表、引用、代码块。\n5. 段落标题必须贴近前端展示模板:分析步骤、研判结论、攻击payload、具体含义解释、响应证据、重要证据、处置建议。\n6. 如果没有 HTTP 响应体或没有明确响应证据,不得判定为攻击成功;需要写明“当前日志未提供有效响应证据”。\n7. 不要编造输入中不存在的 IP、域名、URL、CVE、账号、文件路径或响应内容。\n8. 攻击 payload 和响应证据必须分别放在对应标签中,不要混淆请求与响应。\n\nFew-shot 示例 1:攻击成功\n\n\n\n# 敏感文件泄露攻击成功分析报告\n\n\n\n- 研判结论:攻击成功\n- 风险等级:High\n- 告警类型:敏感文件访问\n- 源 IP:203.0.113.10:42131\n- 目标资产:198.51.100.20:80\n- URL:http://example.com/api/.env\n- 响应码:200\n\n\n\n## 分析步骤\n\n### 1. 日志类型分析\n该日志包含 HTTP 请求路径、响应码和响应体,可用于判断敏感文件是否被返回。\n\n### 2. 情报信息\n源 IP 命中扫描源标签,风险高。\n\n### 3. 测绘信息\n目标为公网 Web 服务,存在敏感路径暴露风险。\n\n### 4. 告警关联漏洞情报\n该行为与环境变量文件泄露场景一致。\n\n### 5. 攻击负载分析\n攻击者直接请求 /api/.env,目标是读取环境变量配置。\n\n### 6. 攻击分析结果\n响应码为 200,响应体中出现 DB_PASSWORD,支持攻击成功。\n\n\n\n## 研判结论\n攻击者成功读取敏感配置文件,响应体中包含数据库密码字段,结论为攻击成功。\n\n\n\n## 攻击payload\n\n```http\nGET /api/.env HTTP/1.1\nHost: example.com\n```\n\n\n\n## 具体含义解释\n\n1. /api/.env 是常见环境变量文件路径。\n2. 攻击者通过 GET 请求尝试直接读取配置文件。\n3. 该路径若返回真实内容,通常意味着敏感文件暴露。\n\n\n\n## 响应证据\n\n```http\nHTTP/1.1 200 OK\n\nDB_PASSWORD=example-secret\n```\n\n响应体出现 DB_PASSWORD,证明敏感配置内容已被返回。\n\n\n\n## 重要证据\n\n1. 请求路径为 /api/.env。\n2. 响应码为 200。\n3. 响应体包含 DB_PASSWORD。\n\n\n\n## 处置建议\n\n1. 立即下线或限制敏感文件访问。\n2. 轮换可能泄露的密钥和数据库密码。\n3. 检索同源 IP 和同路径访问记录。\n\n\n\n\nFew-shot 示例 2:攻击失败\n\n\n\n# SQL注入攻击失败分析报告\n\n\n\n- 研判结论:攻击失败\n- 风险等级:Medium\n- 告警类型:SQL注入\n- 源 IP:203.0.113.44:51002\n- 目标资产:198.51.100.30:443\n- URL:https://shop.example.com/item?id=1\n- 响应码:403\n\n\n\n## 分析步骤\n\n### 1. 日志类型分析\n该日志包含请求参数和响应码,能够确认请求侧存在 SQL 注入尝试。\n\n### 2. 情报信息\n源 IP 暂无高置信恶意标签。\n\n### 3. 测绘信息\n目标为公网电商 Web 服务。\n\n### 4. 告警关联漏洞情报\n当前日志未提供可确认具体 CVE 的证据。\n\n### 5. 攻击负载分析\n请求参数中包含 union select,存在明显 SQL 注入意图。\n\n### 6. 攻击分析结果\n响应码为 403,响应体显示请求被阻断,不支持攻击成功。\n\n\n\n## 研判结论\n该请求存在 SQL 注入攻击意图,但响应显示被拒绝,当前判断为攻击失败。\n\n\n\n## 攻击payload\n\n```http\nGET /item?id=1 union select user HTTP/1.1\nHost: shop.example.com\n```\n\n\n\n## 具体含义解释\n\n1. union select 是典型 SQL 注入关键字组合。\n2. 攻击者尝试拼接查询以读取数据库用户信息。\n3. 该 payload 证明攻击意图,但不等同于成功执行。\n\n\n\n## 响应证据\n\n```http\nHTTP/1.1 403 Forbidden\n\nblocked by waf\n```\n\n响应状态和内容说明请求被拦截,未见数据泄露或执行成功证据。\n\n\n\n## 重要证据\n\n1. 请求参数包含 union select。\n2. 响应码为 403。\n3. 响应体显示 blocked by waf。\n\n\n\n## 处置建议\n\n1. 保留 WAF 拦截证据。\n2. 检查同源 IP 是否持续尝试其他注入 payload。\n3. 确认目标接口参数化查询和安全策略仍然有效。\n\n\n\n\n## 待研判上下文\n```json\n{context}\n```\n\n请输出最终报告:\n'''\n return _extract_tagged_triage_report(_ask_llm(prompt))\n\n\ndef _generate_triage_report(parsed, intel_results, vuln_info, branches,\n attack_analysis_result, attack_verdict, report_title):\n # Aggregate everything into tagged markdown for frontend rendering.\n # The markdown is returned through `triage_report` and is not written as a\n # per-alert file; leader/follower/cache-hit paths reuse the same field.\n verdict_cn = VERDICT_CN.get(attack_verdict, attack_verdict)\n risk_level = VERDICT_RISK.get(attack_verdict, 'Medium')\n\n if not report_title:\n report_title = f'{parsed.get(\"alert_type\", \"Web日志告警\")} - {verdict_cn}'\n\n try:\n triage_report = _llm_triage_report_markdown(\n parsed, intel_results, vuln_info, branches,\n attack_analysis_result, attack_verdict, report_title, risk_level,\n )\n except Exception as e:\n print(f'[triage] WARNING: triage_report LLM generation failed: {e}')\n triage_report = ''\n\n if not _is_valid_triage_report(triage_report):\n print('[triage] WARNING: triage_report missing required semantic tags; using deterministic fallback')\n triage_report = _build_default_tagged_triage_report(\n parsed, intel_results, vuln_info, branches,\n attack_analysis_result, attack_verdict, report_title, risk_level,\n )\n\n return triage_report, report_title, risk_level\n\n\ndef _triage_single_alert(alert):\n \"\"\"End-to-end inline triage for a single alert. Returns triage_fields dict.\n\n No file I/O — the full markdown report lives in the returned `triage_report` field\n and is broadcast to followers / persisted via `triage_cache.pkl`.\n \"\"\"\n parsed = _parse_alert(alert)\n intel_results, intel_content, vuln_info, vuln_content = _prepare_intel(parsed)\n\n branches = _parallel_4_branches(parsed, intel_content, vuln_content)\n\n attack_analysis_result = _llm_attack_analysis(parsed['log_text'])\n attack_verdict = _llm_attack_verdict(attack_analysis_result)\n report_title = _llm_report_title(parsed.get('alert_type', 'unknown'),\n attack_verdict, attack_analysis_result)\n\n triage_report, report_title, risk_level = _generate_triage_report(\n parsed, intel_results, vuln_info, branches,\n attack_analysis_result, attack_verdict, report_title,\n )\n\n return {\n 'attack_verdict': attack_verdict,\n 'risk_level': risk_level,\n 'report_title': report_title,\n 'triage_report': triage_report,\n 'attack_success': attack_verdict == 'attack_success',\n }\n\n\n# ── Main: leader/follower batch deduplication ────────────────────────────────\n# When the input batch contains multiple alerts sharing the same dedup_key\n# (e.g. upstream emits is_duplicate=True alerts in the same batch, or LSH\n# clustering produces several alerts per cluster), we only triage the LEADER\n# (first occurrence of each dedup_key). All FOLLOWERS in the same group reuse\n# the leader's triage result without invoking the LLM again.\n#\n# Work unit types:\n# ('dk', dedup_key, leader_idx) — group of 1+ alerts sharing dedup_key\n# ('nokey', None, alert_idx) — single alert with no dedup_key\n# (cannot be deduplicated, always triaged)\n\nenriched_alerts = list(inputs.get('enriched_alerts', []) or [])\nconcurrency = min(5, max(1, int(inputs.get('concurrency', 1))))\nmax_triage_cache_size = int(inputs.get('max_triage_cache_size', 100000))\nif max_triage_cache_size < 1:\n max_triage_cache_size = 100000\n\n# Group by dedup_key\ngroups = {} # dedup_key -> [alert_index, ...]\nno_key_indices = [] # alerts with no dedup_key\nfor i, a in enumerate(enriched_alerts):\n dk = a.get('dedup_key', '') if isinstance(a, dict) else ''\n if dk:\n groups.setdefault(dk, []).append(i)\n else:\n no_key_indices.append(i)\n\nwork_units = (\n [('dk', dk, group_indices[0]) for dk, group_indices in groups.items()]\n + [('nokey', None, idx) for idx in no_key_indices]\n)\nfollower_count = sum(len(v) - 1 for v in groups.values())\n\nprint(f'[triage] alerts={len(enriched_alerts)} '\n f'→ {len(groups)} unique dedup_keys ({follower_count} followers) + '\n f'{len(no_key_indices)} no-key alerts '\n f'= {len(work_units)} work units; outer_concurrency={concurrency} '\n f'(per-alert 4 LLM branches run in parallel)')\n\ncache_path, lock_path = _cache_paths()\nlock_fh = _acquire_lock(lock_path)\ntry:\n triage_cache_snapshot = _load_cache(cache_path)\nfinally:\n _release_lock(lock_fh)\n\n# Only warm up the LLM when at least one work unit may actually need it.\n# A unit \"may need\" the LLM if it's a no-key alert OR a dedup_key unit whose\n# entry is not in the cache snapshot. Pure cache-hit batches skip warm-up.\n_needs_llm = any(\n unit_type == 'nokey' or not _is_current_triage_fields(triage_cache_snapshot.get(dk))\n for unit_type, dk, _ in work_units\n)\nif _needs_llm:\n _warmup_llm()\n\nresults_lock = threading.Lock()\nnew_results = {} # dedup_key -> triage_fields (only for freshly computed leaders)\ngroup_outcomes = {} # dedup_key -> (triage_fields, source) for broadcasting to followers\nnokey_outcomes = {} # alert_idx -> (triage_fields, source)\nstats = {\n 'total': len(enriched_alerts),\n 'unique_dedup_keys': len(groups),\n 'followers_reused': follower_count,\n 'no_dedup_key_alerts': len(no_key_indices),\n 'work_units': len(work_units),\n 'cache_hit': 0,\n 'triaged': 0,\n 'triage_failed': 0,\n 'verdict_counts': {},\n 'cache_size_before': len(triage_cache_snapshot),\n 'cache_size_after': 0,\n 'evicted': 0,\n}\n\n\ndef _bump_verdict(verdict):\n with results_lock:\n stats['verdict_counts'][verdict] = stats['verdict_counts'].get(verdict, 0) + 1\n\n\n_UNKNOWN_TRIAGE = {\n 'attack_verdict': 'unknown',\n 'risk_level': 'Medium',\n 'report_title': '',\n 'triage_report': '',\n 'attack_success': False,\n}\n\n\ndef _process_unit(unit_type, dedup_key, leader_idx):\n \"\"\"Triage one unique work unit. Returns (key, triage_fields, source, ms, error).\"\"\"\n leader_alert = enriched_alerts[leader_idx]\n t0 = time.time()\n\n # 1) cache lookup for dedup_key units\n if unit_type == 'dk':\n cached = triage_cache_snapshot.get(dedup_key)\n if cached:\n if _is_current_triage_fields(cached):\n with results_lock:\n stats['cache_hit'] += 1\n _bump_verdict(cached.get('attack_verdict', 'unknown'))\n return dedup_key, cached, 'cache', 0, None\n print(f'[triage_cache] stale entry for dedup_key={dedup_key}: '\n 'missing current triage_report markdown; treating as cache miss')\n\n # 2) cache miss or no-key → run full inline triage on the leader\n try:\n triage_fields = _triage_single_alert(leader_alert)\n ms = round((time.time() - t0) * 1000)\n with results_lock:\n stats['triaged'] += 1\n if dedup_key:\n new_results[dedup_key] = triage_fields\n _bump_verdict(triage_fields.get('attack_verdict', 'unknown'))\n source = 'triaged' if unit_type == 'dk' else 'no_dedup_key_triaged'\n return (dedup_key if unit_type == 'dk' else leader_idx), triage_fields, source, ms, None\n except Exception as e:\n import traceback\n ms = round((time.time() - t0) * 1000)\n with results_lock:\n stats['triage_failed'] += 1\n _bump_verdict('unknown')\n err = str(e)[:500]\n print(f'[triage] leader_idx={leader_idx} FAILED: {e}\\n{traceback.format_exc()}')\n source = 'failed' if unit_type == 'dk' else 'no_dedup_key_failed'\n return (dedup_key if unit_type == 'dk' else leader_idx), dict(_UNKNOWN_TRIAGE), source, ms, err\n\n\nt_start = time.time()\nunit_completions = {} # key -> (triage_fields, source, ms, error)\nif work_units:\n with ThreadPoolExecutor(max_workers=concurrency, thread_name_prefix='stream_triage') as pool:\n futures = [pool.submit(_process_unit, *u) for u in work_units]\n for done_count, fut in enumerate(as_completed(futures), 1):\n try:\n key, triage_fields, source, ms, err = fut.result()\n unit_completions[key] = (triage_fields, source, ms, err)\n if source == 'cache':\n group_outcomes[key] = (triage_fields, source)\n elif source.startswith('no_dedup_key'):\n nokey_outcomes[key] = (triage_fields, source)\n else:\n group_outcomes[key] = (triage_fields, source)\n except Exception as e:\n print(f'[triage] WARNING: unexpected worker exception: {e}')\n if done_count % 5 == 0 or done_count == len(futures):\n print(f'[triage] progress {done_count}/{len(futures)} '\n f'(cache_hit={stats[\"cache_hit\"]} triaged={stats[\"triaged\"]} '\n f'failed={stats[\"triage_failed\"]})')\n\n# ── Apply outcomes back to every alert (broadcast leader → followers) ─────────\nenriched_with_triage = [None] * len(enriched_alerts)\nfor i, alert in enumerate(enriched_alerts):\n out = dict(alert) if isinstance(alert, dict) else {'_raw': alert}\n dk = alert.get('dedup_key', '') if isinstance(alert, dict) else ''\n out['has_dedup_key'] = bool(dk)\n\n if dk:\n triage_fields, source = group_outcomes.get(dk, (dict(_UNKNOWN_TRIAGE), 'failed'))\n is_leader = (groups.get(dk, [i])[0] == i)\n # All triage fields are pure data (no file-path references); broadcast as-is.\n for k, v in triage_fields.items():\n out[k] = v\n if not is_leader and source != 'cache':\n out['triage_source'] = 'follower_reused'\n out['triage_status'] = 'reused_from_leader'\n else:\n out['triage_source'] = source\n out['triage_status'] = 'cached' if source == 'cache' else (\n 'ok' if source == 'triaged' else 'failed'\n )\n completion = unit_completions.get(dk)\n if completion and is_leader:\n _, _, ms, err = completion\n if ms:\n out['triage_ms'] = ms\n if err:\n out['triage_error'] = err\n else:\n # no-key alerts: each is its own unit, keyed by alert idx\n triage_fields, source = nokey_outcomes.get(i, (dict(_UNKNOWN_TRIAGE), 'no_dedup_key_failed'))\n for k, v in triage_fields.items():\n out[k] = v\n out['triage_source'] = source\n out['triage_status'] = 'ok' if source == 'no_dedup_key_triaged' else 'failed'\n completion = unit_completions.get(i)\n if completion:\n _, _, ms, err = completion\n if ms:\n out['triage_ms'] = ms\n if err:\n out['triage_error'] = err\n\n enriched_with_triage[i] = out\n\nelapsed_ms = round((time.time() - t_start) * 1000)\nprint(f'[triage] all done in {elapsed_ms}ms: cache_hit={stats[\"cache_hit\"]} '\n f'triaged={stats[\"triaged\"]} failed={stats[\"triage_failed\"]} '\n f'followers_reused={stats[\"followers_reused\"]} '\n f'no_dedup_key={stats[\"no_dedup_key_alerts\"]}')\n\n# Persist new triage results back to cache (merge with concurrent writers).\nif new_results:\n lock_fh = _acquire_lock(lock_path)\n try:\n cache = _load_cache(cache_path)\n for k, v in new_results.items():\n if k in cache:\n del cache[k] # LRU touch (move to end on rewrite)\n cache[k] = v\n evicted = _evict_lru(cache, max_triage_cache_size)\n if evicted:\n print(f'[triage_cache] LRU eviction: dropped {evicted} entries (max={max_triage_cache_size})')\n _save_cache_atomic(cache_path, cache)\n stats['cache_size_after'] = len(cache)\n stats['evicted'] = evicted\n finally:\n _release_lock(lock_fh)\nelse:\n stats['cache_size_after'] = stats['cache_size_before']\n\ntriage_results = []\nfor a in enriched_with_triage:\n triage_results.append({\n 'dedup_key': a.get('dedup_key', ''),\n 'has_dedup_key': a.get('has_dedup_key', False),\n 'threat_name': a.get('threat_name', ''),\n 'sip': a.get('sip', ''),\n 'dip': a.get('dip', ''),\n 'is_duplicate': a.get('is_duplicate'),\n 'triage_source': a.get('triage_source', ''),\n 'triage_status': a.get('triage_status', ''),\n 'attack_verdict': a.get('attack_verdict', ''),\n 'risk_level': a.get('risk_level', ''),\n 'report_title': a.get('report_title', ''),\n 'triage_ms': a.get('triage_ms'),\n 'triage_error': a.get('triage_error'),\n })\n\nstats['elapsed_ms'] = elapsed_ms\nstats['concurrency'] = concurrency\nstats['max_triage_cache_size'] = max_triage_cache_size\n\n# Persist enriched_with_triage according to workflow config. Default is SOC DB;\n# JSONL is still available by config/input for downstream pipelines or archival.\noutput_cfg = _resolve_output_config()\nrun_id = (inputs.get('_run_id')\n or os.environ.get('FLOCKS_RUN_ID')\n or str(int(time.time() * 1000)))\noutput_paths = []\noutput_dir = ''\nsoc_db_result = {\n 'path': output_cfg.get('soc_db_path', ''),\n 'table': 'alert_records',\n 'rows': 0,\n}\n\nif output_cfg['write_soc_db'] and enriched_with_triage:\n try:\n soc_db_result = _triage_write_soc_db(\n output_cfg['soc_db_path'], enriched_with_triage, run_id,\n )\n print(f'[triage] wrote {soc_db_result.get(\"rows\", 0)} enriched alerts to '\n f'SOC DB {soc_db_result.get(\"path\")}')\n except Exception as e:\n import traceback\n print(f'[triage] WARNING: failed to persist triage results to SOC DB: {e}\\n{traceback.format_exc()}')\nelif not output_cfg['write_soc_db']:\n print(f'[triage] SOC DB output disabled by triage_output_mode={output_cfg[\"requested_mode\"]!r}')\n\nif output_cfg['write_jsonl'] and enriched_with_triage:\n try:\n output_dir = _triage_output_dir(output_cfg.get('jsonl_output_dir', ''))\n output_paths = _triage_write_jsonl(\n output_dir, enriched_with_triage, run_id, stats,\n )\n print(f'[triage] wrote {len(enriched_with_triage)} enriched alerts to '\n f'{len(output_paths)} JSONL file(s) under {output_dir}')\n for p in output_paths:\n print(f' → {p}')\n except Exception as e:\n import traceback\n print(f'[triage] WARNING: failed to persist triage_result JSONL: {e}\\n{traceback.format_exc()}')\nelif not output_cfg['write_jsonl']:\n print(f'[triage] JSONL output disabled by triage_output_mode={output_cfg[\"requested_mode\"]!r}')\n\nstats['output_mode'] = output_cfg['mode']\nstats['requested_output_mode'] = output_cfg['requested_mode']\nstats['output_config_path'] = output_cfg['config_path']\nstats['soc_db_path'] = soc_db_result.get('path', '')\nstats['soc_db_rows'] = soc_db_result.get('rows', 0)\nstats['output_paths'] = output_paths\nstats['output_dir'] = output_dir\n\nprint(f'[triage] stats={json.dumps(stats, ensure_ascii=False)}')\n\noutputs['enriched_alerts_with_triage'] = enriched_with_triage\noutputs['triage_results'] = triage_results\noutputs['triage_stats'] = stats\noutputs['load_stats'] = inputs.get('load_stats', {})\noutputs['loaded_files'] = inputs.get('loaded_files', [])\noutputs['input_date'] = inputs.get('input_date', '')\noutputs['triage_output_mode'] = output_cfg['mode']\noutputs['soc_db_result'] = soc_db_result\noutputs['soc_db_path'] = soc_db_result.get('path', '')\noutputs['output_config_path'] = output_cfg['config_path']\noutputs['output_paths'] = output_paths\noutputs['output_dir'] = output_dir\n" + }, + { + "id": "summarize", + "type": "python", + "description": "汇总输出:写 pipeline_summary.md 到 ~/.flocks/workspace/outputs//artifacts/,暴露 top-risk 告警的 verdict/title/triage_report 作为工作流的 final outputs。", + "code": "\"\"\"\nsummarize: 汇总 triage 结果,写 pipeline_summary.md,并暴露 top-risk 研判字段。\n\"\"\"\n\nimport datetime\nimport json\nimport os\n\nfrom flocks.workspace.manager import WorkspaceManager\n\nVERDICT_ORDER = {\n 'attack_success': 5,\n 'attack': 4,\n 'attack_failed': 3,\n 'unknown': 2,\n 'benign': 1,\n}\nVERDICT_CN = {\n 'attack_success': '攻击成功',\n 'attack_failed': '攻击失败',\n 'attack': '攻击',\n 'unknown': '未知',\n 'benign': '安全',\n}\nSOURCE_CN = {\n 'cache': '缓存复用',\n 'triaged': '新研判(leader)',\n 'follower_reused': '同批次复用(follower)',\n 'failed': '研判失败',\n 'no_dedup_key_triaged': '无dedup_key已研判',\n 'no_dedup_key_failed': '无dedup_key研判失败',\n}\n\nenriched = list(inputs.get('enriched_alerts_with_triage', []) or [])\ntriage_stats = dict(inputs.get('triage_stats', {}) or {})\nload_stats = dict(inputs.get('load_stats', {}) or {})\nloaded_files = list(inputs.get('loaded_files', []) or [])\ninput_date = inputs.get('input_date', '') or datetime.date.today().isoformat()\noutput_paths = list(inputs.get('output_paths', []) or [])\noutput_dir = inputs.get('output_dir', '') or ''\ntriage_output_mode = triage_stats.get('output_mode') or inputs.get('triage_output_mode') or 'soc_db'\nsoc_db_result = dict(inputs.get('soc_db_result', {}) or {})\nsoc_db_path = inputs.get('soc_db_path') or soc_db_result.get('path') or triage_stats.get('soc_db_path', '')\n\n# Pick the top-risk alert (first occurrence of the highest verdict tier).\ntop = None\nfor a in enriched:\n v = a.get('attack_verdict', 'unknown')\n if top is None:\n top = a\n continue\n cur = VERDICT_ORDER.get(top.get('attack_verdict', ''), 0)\n new = VERDICT_ORDER.get(v, 0)\n if new > cur:\n top = a\n\ntriage_report = (top.get('triage_report') or '') if top else ''\nreport_title = (top.get('report_title') or '') if top else ''\nattack_verdict = (top.get('attack_verdict') or '') if top else ''\nrisk_level = (top.get('risk_level') or '') if top else ''\n\nrows = []\nfor i, a in enumerate(enriched, 1):\n verdict = a.get('attack_verdict', 'unknown')\n source = a.get('triage_source', '')\n rows.append(\n f'| {i} '\n f'| {(a.get(\"dedup_key\") or \"\")[:8]} '\n f'| {(\"是\" if a.get(\"is_duplicate\") else \"否\")} '\n f'| {(a.get(\"threat_name\") or \"\")[:30]} '\n f'| {SOURCE_CN.get(source, source)} '\n f'| {VERDICT_CN.get(verdict, verdict)} '\n f'| {(a.get(\"report_title\") or \"\")[:30]} |'\n )\n\ntotal_alerts = triage_stats.get('total', 0) or 1\nreuse_rate = (\n triage_stats.get('cache_hit', 0)\n + triage_stats.get('followers_reused', 0)\n) / total_alerts\n\noutput_files_md = (\n '\\n'.join(f'- `{p}`' for p in output_paths) if output_paths else '_(not persisted)_'\n)\nsoc_db_md = (\n f'`{soc_db_path}` ({soc_db_result.get(\"rows\", triage_stats.get(\"soc_db_rows\", 0))} rows)'\n if soc_db_path else '_(not persisted)_'\n)\n\nsummary_md = (\n f'# Stream Alert Triage Summary\\n\\n'\n f'**Date**: {input_date}\\n'\n f'**Loaded files**: {load_stats.get(\"file_count\", 0)}\\n'\n f'**Loaded records**: {load_stats.get(\"record_count\", 0)}\\n\\n'\n f'## Persisted Results\\n'\n f'**Output mode**: `{triage_output_mode}`\\n'\n f'**SOC DB**: {soc_db_md}\\n'\n f'**JSONL output dir**: `{output_dir or \"(none)\"}`\\n'\n f'**JSONL files written**:\\n{output_files_md}\\n\\n'\n f'## Statistics\\n'\n f'- Total alerts: {triage_stats.get(\"total\", 0)}\\n'\n f'- Unique dedup_keys: {triage_stats.get(\"unique_dedup_keys\", 0)}\\n'\n f'- Work units (LLM-bound): {triage_stats.get(\"work_units\", 0)}\\n'\n f'- Cache hit (reused historic triage): {triage_stats.get(\"cache_hit\", 0)}\\n'\n f'- Followers reused (in-batch dedup): {triage_stats.get(\"followers_reused\", 0)}\\n'\n f'- New triages (leaders): {triage_stats.get(\"triaged\", 0)}\\n'\n f'- Triage failed: {triage_stats.get(\"triage_failed\", 0)}\\n'\n f'- No-dedup_key alerts: {triage_stats.get(\"no_dedup_key_alerts\", 0)}\\n'\n f'- Reuse rate (cache + followers): {reuse_rate:.1%}\\n'\n f'- Concurrency: {triage_stats.get(\"concurrency\", \"?\")}\\n'\n f'- Elapsed: {triage_stats.get(\"elapsed_ms\", 0)} ms\\n'\n f'- Cache size: {triage_stats.get(\"cache_size_before\", 0)} → {triage_stats.get(\"cache_size_after\", 0)} '\n f'(evicted {triage_stats.get(\"evicted\", 0)})\\n'\n f'- Verdict distribution: {json.dumps(triage_stats.get(\"verdict_counts\", {}), ensure_ascii=False)}\\n\\n'\n f'## Details\\n\\n'\n f'| # | dedup_key | dup | threat | source | verdict | title |\\n'\n f'|---|-----------|-----|--------|--------|---------|-------|\\n'\n + ('\\n'.join(rows) if rows else '| – | – | – | – | – | – | – |') + '\\n\\n'\n + (f'## Top-Risk Alert Report\\n\\n{triage_report}\\n' if triage_report else '')\n)\n\nws = WorkspaceManager.get_instance()\n_summary_root = str(ws.get_workspace_dir() / 'outputs' / datetime.date.today().isoformat())\nartifacts_dir = os.path.join(_summary_root, 'artifacts')\nos.makedirs(artifacts_dir, exist_ok=True)\nsummary_path = os.path.join(artifacts_dir, 'stream_alert_triage_summary.md')\ntry:\n with open(summary_path, 'w', encoding='utf-8') as f:\n f.write(summary_md)\n print(f'[summarize] wrote {summary_path}')\nexcept Exception as e:\n print(f'[summarize] WARNING: failed to write summary: {e}')\n summary_path = ''\n\nprint(f'[summarize] top_verdict={attack_verdict!r} title={report_title[:40]!r}')\n\noutputs['summary_report'] = summary_md\noutputs['summary_path'] = summary_path\noutputs['top_attack_verdict'] = attack_verdict\noutputs['top_risk_level'] = risk_level\noutputs['top_report_title'] = report_title\noutputs['top_triage_report'] = triage_report\noutputs['triage_results'] = inputs.get('triage_results', [])\noutputs['triage_stats'] = triage_stats\noutputs['load_stats'] = load_stats\noutputs['loaded_files'] = loaded_files\noutputs['enriched_alerts_with_triage'] = enriched\noutputs['triage_output_mode'] = triage_output_mode\noutputs['soc_db_result'] = soc_db_result\noutputs['soc_db_path'] = soc_db_path\noutputs['output_paths'] = output_paths\noutputs['output_dir'] = output_dir\n" + } + ], + "edges": [ + { + "from": "load_dedup_file", + "to": "concurrent_triage", + "order": 0 + }, + { + "from": "concurrent_triage", + "to": "summarize", + "order": 0 + } + ], + "metadata": { + "node_timeout_s": 7200, + "sampleInputs": { + "_comment_input": "三选一:input_paths(来自 stream_alert_denoise.outputs.output_paths)/ input_path(来自 stream_alert_denoise.outputs.output_path)/ input_date(YYYY-MM-DD,遍历该日所有 dedup_result_*.jsonl);都不传时默认取“今天”目录下所有文件。", + "input_date": "2026-05-18", + "concurrency": 1, + "max_triage_cache_size": 100000, + "persist_triage_output": false, + "_comment_dedup": "同批次内多条 alert 共享 dedup_key 时只 LLM 研判 1 次(leader),其余 follower 直接复用结果;跨批次/跨进程的复用由 triage_cache.pkl 提供。", + "_comment_cache": "研判缓存位于 ~/.flocks/workspace/workflows/stream_alert_triage/triage_cache.pkl,FIFO LRU,文件锁 + 原子落盘,可跨进程/跨执行复用。dedup_key 即 stream_alert_denoise 生成的 MD5(strict_fields + lsh_cluster_id)。", + "triage_output_mode": "soc_db", + "_comment_output": "默认写入 ~/.flocks/data/soc.db;如需 JSONL,设置 triage_output_mode=jsonl 或 both;旧参数 persist_triage_output=true 仍会额外写 JSONL。" + } + }, + "triggers": [ + { + "id": "schedule-1782876198587", + "name": "Schedule Trigger", + "type": "schedule", + "enabled": false, + "source": { + "mode": "interval", + "intervalSeconds": 300 + }, + "mapping": {}, + "inputs": { + "_comment_input": "三选一:input_paths(来自 stream_alert_denoise.outputs.output_paths)/ input_path(来自 stream_alert_denoise.outputs.output_path)/ input_date(YYYY-MM-DD,遍历该日所有 dedup_result_*.jsonl);都不传时默认取“今天”目录下所有文件。", + "input_date": "2026-05-18", + "concurrency": 1, + "max_triage_cache_size": 100000, + "persist_triage_output": false, + "_comment_dedup": "同批次内多条 alert 共享 dedup_key 时只 LLM 研判 1 次(leader),其余 follower 直接复用结果;跨批次/跨进程的复用由 triage_cache.pkl 提供。", + "_comment_cache": "研判缓存位于 ~/.flocks/workspace/workflows/stream_alert_triage/triage_cache.pkl,FIFO LRU,文件锁 + 原子落盘,可跨进程/跨执行复用。dedup_key 即 stream_alert_denoise 生成的 MD5(strict_fields + lsh_cluster_id)。", + "triage_output_mode": "soc_db", + "_comment_output": "默认写入 ~/.flocks/data/soc.db;如需 JSONL,设置 triage_output_mode=jsonl 或 both;旧参数 persist_triage_output=true 仍会额外写 JSONL。" + }, + "concurrency": { + "policy": "allow", + "maxParallel": 1, + "queueSize": 100 + }, + "runtime": { + "timeoutSeconds": 7200, + "noOverlap": true + }, + "testSamples": [ + { + "name": "default", + "payload": { + "_comment_input": "三选一:input_paths(来自 stream_alert_denoise.outputs.output_paths)/ input_path(来自 stream_alert_denoise.outputs.output_path)/ input_date(YYYY-MM-DD,遍历该日所有 dedup_result_*.jsonl);都不传时默认取“今天”目录下所有文件。", + "input_date": "2026-05-18", + "concurrency": 1, + "max_triage_cache_size": 100000, + "persist_triage_output": false, + "_comment_dedup": "同批次内多条 alert 共享 dedup_key 时只 LLM 研判 1 次(leader),其余 follower 直接复用结果;跨批次/跨进程的复用由 triage_cache.pkl 提供。", + "_comment_cache": "研判缓存位于 ~/.flocks/workspace/workflows/stream_alert_triage/triage_cache.pkl,FIFO LRU,文件锁 + 原子落盘,可跨进程/跨执行复用。dedup_key 即 stream_alert_denoise 生成的 MD5(strict_fields + lsh_cluster_id)。", + "triage_output_mode": "soc_db", + "_comment_output": "默认写入 ~/.flocks/data/soc.db;如需 JSONL,设置 triage_output_mode=jsonl 或 both;旧参数 persist_triage_output=true 仍会额外写 JSONL。" + }, + "headers": {}, + "query": {} + } + ] + } + ] +} diff --git a/.flocks/flockshub/plugins/workflows/stream_alert_triage/workflow.md b/.flocks/flockshub/plugins/workflows/stream_alert_triage/workflow.md new file mode 100644 index 000000000..11d62c43a --- /dev/null +++ b/.flocks/flockshub/plugins/workflows/stream_alert_triage/workflow.md @@ -0,0 +1,170 @@ +# stream_alert_triage + +stream_alert_triage 是 NDR 告警流并发研判 Pipeline。 + +核心能力: +- 读取上游 stream_alert_denoise 去重输出 +- 同批次 dedup_key 相同 → 只研判 leader,follower 复用结果 +- 跨批次 dedup_key 命中缓存 → 直接复用历史研判,不调 LLM +- 4 并行 LLM 分支(survey / cve_related / cve_info / payload_analysis) +- 研判产物仅写入 triage_report 字段,不生成独立报告文件 + +**完全自包含**,研判逻辑直接内联,不依赖也不嵌入 `tdp_alert_triage`。 + +## 核心特性 + +* **跨批次复用**:dedup_key 命中持久化 cache 时直接复用历史 verdict/title/triage_report,不调 LLM +* **同批次去重**:批内多条 alert 共享 dedup_key 时,只对 **leader(首条)研判**,follower 广播复用 leader 结果 +* **保留 4 并行 LLM 分支**(survey / cve_related / cve_info / payload_analysis)— 与 `tdp_alert_triage` 完全相同的研判语义 +* **研判产物仅以字段形式附加**到每条 alert(`triage_report` 字段含带语义标签的完整 markdown),**不生成任何独立的 per-alert 报告文件** + +## 与上游的关系 + +## 1. 功能概览 + +基本信息: + +- 工作流 ID: `stream_alert_triage` +- 工作流目录: `~/.flocks/plugins/workflows/stream_alert_triage/` +- 分类: `default` +- 状态: `active` +- 入口节点: load_dedup_file (Python) +- 终点节点: summarize (Python) +- 生成时间: 2026/6/24 15:29:18 + +适合在这里写清楚: + +- 这个工作流解决什么问题。 +- 适合处理什么输入。 +- 不负责处理什么边界场景。 + +## 2. 原理和总体流程 + +核心原理是把输入按节点顺序逐步加工,每个节点只负责一个清晰职责。流程顺序如下: + +```text +load_dedup_file -> concurrent_triage -> summarize +``` + +流程表: + +| 顺序 | 节点 | 做什么 | 下一步 | +| --- | --- | --- | --- | +| 1 | load_dedup_file | 一次性读取 stream_alert_denoise 写入的 JSONL 文件。输入优先级:input_paths > input_path > input_date(自动遍历该日所有 dedup_result_*.jsonl)> 当日默认。跳过 file_header 行,输出 enriched_alerts (list[dict])。 | concurrent_triage | +| 2 | concurrent_triage | Leader/follower 分组并发研判节点(自包含,内联 tdp_alert_triage 逻辑)。先按 dedup_key 把 alerts 分组:每组只对 leader 研判,follower 复用 leader 结果。外层 ThreadPoolExecutor(concurrency) 处理 unique work units(concurrency 取值 1–5,默认 1),内层 ThreadPoolExecutor(4) 并行 survey / cve_related / cve_info / payload_analysis。dedup_key 在 triage_cache.pkl 命中时直接复用历史 verdict/title/triage_report;未命中则 leader 执行完整研判(情报查询 + 4 并行 LLM + attack_analysis + verdict + title + 聚合 markdown),完整研判 markdown 仅写入 alert 的 `triage_report` 字段,**不生成任何独立报告文件**。新结果合并写回 cache(FIFO LRU + 文件锁 + 原子落盘)。所有 enriched_with_triage alerts 默认写入 `~/.flocks/data/soc.db` 的 `alert_records` 表;可通过工作流目录 `config.json` 或运行输入将 `triage_output_mode` 切换为 `jsonl` / `both` / `none`,保留 `triage_result_NNN.jsonl` 可选输出。 | summarize | +| 3 | summarize | 汇总输出:写 pipeline_summary.md 到 ~/.flocks/workspace/outputs//artifacts/,暴露 top-risk 告警的 verdict/title/triage_report 作为工作流的 final outputs。 | 工作流最终输出 | + +编辑流程结构时,要同时确认节点顺序、边关系、字段映射和最终输出是否仍然一致。 + +## 3. 输入说明 + +本章用于说明工作流接受什么输入,以及入口节点如何理解这些输入。 + +当前工作流保存了这些样例输入,可以先照着这些字段测试: + +- _comment_input: 三选一:input_paths(来自 stream_alert_denoise.outputs.output_paths)/ input_path(来自 stream_alert_denoise.outputs.output_path)/ ... +- input_date: 2026-05-18 +- concurrency: 1 +- max_triage_cache_size: 100000 +- persist_triage_output: false +- triage_output_mode: soc_db +- _comment_output: 默认写入 ~/.flocks/data/soc.db;如需 JSONL,设置 triage_output_mode=jsonl 或 both;旧参数 persist_triage_output=true 仍会额外写 JSONL。 +- _comment_dedup: 同批次内多条 alert 共享 dedup_key 时只 LLM 研判 1 次(leader),其余 follower 直接复用结果;跨批次/跨进程的复用由 triage_cache.pkl 提供。 +- _comment_cache: 研判缓存位于 ~/.flocks/workspace/workflows/stream_alert_triage/triage_cache.pkl,FIFO LRU,文件锁 + 原子落盘,可跨进程/跨执行复用。dedup_key 即 str... + +修改输入时,至少同步检查: + +- 入口节点是否能读取新字段。 +- 样例输入是否覆盖主要场景。 +- 下游节点是否还在引用旧字段名。 +- 发布方式中的参数说明是否需要更新。 + +## 4. 模块逻辑 + +本章按执行顺序解释每个节点。修改内部逻辑时,优先定位到对应节点,再检查它的上下游关系。 + +### 4.1 load_dedup_file + +职责: 一次性读取 stream_alert_denoise 写入的 JSONL 文件。输入优先级:input_paths > input_path > input_date(自动遍历该日所有 dedup_result_*.jsonl)> 当日默认。跳过 file_header 行,输出 enriched_alerts (list[dict])。 + +- 节点类型: Python +- 输入来源: 工作流输入 / 触发器输入 +- 输出去向: concurrent_triage +- 编辑重点: 修改去重阈值、状态保存、结果落盘路径或输出格式时,优先编辑这里。 +- 上游关系: 从工作流输入开始 +- 下游关系: load_dedup_file -> concurrent_triage + +### 4.2 concurrent_triage + +职责: Leader/follower 分组并发研判节点(自包含,内联 tdp_alert_triage 逻辑)。先按 dedup_key 把 alerts 分组:每组只对 leader 研判,follower 复用 leader 结果。外层 ThreadPoolExecutor(concurrency) 处理 unique work units(concurrency 取值 1–5,默认 1),内层 ThreadPoolExecutor(4) 并行 survey / cve_related / cve_info / payload_analysis。dedup_key 在 triage_cache.pkl 命中时直接复用历史 verdict/title/triage_report;未命中则 leader 执行完整研判(情报查询 + 4 并行 LLM + attack_analysis + verdict + title + 聚合 markdown),完整研判 markdown 仅写入 alert 的 `triage_report` 字段,**不生成任何独立报告文件**。新结果合并写回 cache(FIFO LRU + 文件锁 + 原子落盘)。所有 enriched_with_triage alerts 默认写入 `~/.flocks/data/soc.db` 的 `alert_records` 表;可通过工作流目录 `config.json` 或运行输入将 `triage_output_mode` 切换为 `jsonl` / `both` / `none`,保留 `triage_result_NNN.jsonl` 可选输出。 + +- 节点类型: Python +- 输入来源: load_dedup_file +- 输出去向: summarize +- 编辑重点: 修改去重阈值、状态保存、结果落盘路径或输出格式时,优先编辑这里。 +- 上游关系: load_dedup_file -> concurrent_triage +- 下游关系: concurrent_triage -> summarize + +### 4.3 summarize + +职责: 汇总输出:写 pipeline_summary.md 到 ~/.flocks/workspace/outputs//artifacts/,暴露 top-risk 告警的 verdict/title/triage_report 作为工作流的 final outputs。 + +- 节点类型: Python +- 输入来源: concurrent_triage +- 输出去向: 工作流最终输出 +- 编辑重点: 修改此步骤的输入、输出或执行逻辑时,先确认上下游字段是否同步变化。 +- 上游关系: concurrent_triage -> summarize +- 下游关系: 输出工作流结果 + +## 5. 输出说明 + +本章用于维护工作流最终返回什么,以及是否产生额外副作用。 + +输出说明建议包含: + +- 返回给用户或调用方的核心字段。 +- 给下游系统继续消费的结构化字段。 +- 是否写文件、发通知、调用外部系统或更新状态。 +- 没有结果、部分失败、完全失败时分别返回什么。 + +如果还不确定输出格式,先用一条样例跑通,再把真实返回字段补到这里。 + +## 6. 发布方式 + +发布页会根据 `config.json` 模板和运行时状态决定展示哪些能力;`workflow.md` 只负责解释这些能力的用途。 + +当前 `workflow.json` 里配置了这些触发器: + +- syslog-default: syslog,启用 + +发布相关编辑原则: + +- 改展示模板: 修改 `config.json`。 +- 改运行启停状态: 通过发布页或后端运行时状态处理。 +- 改参数语义: 同步更新本章、输入说明和相关节点。 +- 不要把明文密钥、长期 token 或私人路径写进 `workflow.md` 或 `config.json`。 + +## 7. 编辑指南 + +先判断你要改哪一类内容,再去找对应位置: + +| 修改目标 | 优先查看 | +| --- | --- | +| 输入格式、来源、样例 | 第 3 章和入口节点 | +| 字段映射、清洗、分类 | 第 4 章对应节点 | +| 分支、循环、节点增删 | `workflow.json` 和第 2 章流程表 | +| 输出字段、落盘、通知 | 第 5 章和终点节点 | +| API、Syslog、Kafka 等发布方式 | `config.json` 和第 6 章 | +| 字段重命名 | 所有上下游节点、样例输入和输出说明 | + +编辑后建议把改动说明写回相应章节,让下一个人可以直接看懂为什么这样改。 + +## 8. 验证方式 + +最小验收清单: + +- [ ] 用一条正常样例能跑通。 +- [ ] 输出字段符合你的预期。 +- [ ] 如果改了字段名,下游节点没有继续引用旧字段。 +- [ ] 如果改了发布方式,发布页只展示应该出现的能力。 +- [ ] 没有明文密钥、长期 token 或私人路径写进工作流目录。 diff --git a/.flocks/plugins/skills/sangfor-edr-use/SKILL.md b/.flocks/plugins/skills/sangfor-edr-use/SKILL.md index 10e0e956a..a51058991 100644 --- a/.flocks/plugins/skills/sangfor-edr-use/SKILL.md +++ b/.flocks/plugins/skills/sangfor-edr-use/SKILL.md @@ -5,7 +5,22 @@ description: 用于处理深信服 EDR(终端检测与响应)相关任务, # 深信服 EDR Use -## First +## First + +### 登录态处理规则 + +当用户需要打开深信服 EDR 页面,或需要通过 Web2CLI 抓取 EDR 页面请求时,必须按下面顺序处理登录态,不要一开始就要求用户提供账密: + +1. 先调用 `sangfor_edr_auth` 的 `action=status_auth_state` 或 `action=validate_auth_state`,检查 `~/.flocks/browser/sangfor-edr/auth-state.json` 是否存在且可用。 +2. 如果登录态可用,直接复用该 state,继续打开 EDR 页面或执行 Web2CLI 抓取流程。 +3. 如果 state 不存在或已失效,但本地已经保存了 EDR 地址、用户名和密码,调用 `sangfor_edr_auth` 的 `action=ensure_auth_state` 自动刷新登录态。 +4. 如果没有可用 state,也没有保存账密配置,再引导用户选择: + - 提供 EDR 访问地址、用户名和密码,自动登录并保存账密配置,后续 state 失效时可直接自动刷新; + - 不提供账密,走浏览器手动登录流程。打开 EDR 页面后由用户在可视化浏览器中完成登录,登录成功后保存完整浏览器登录态(包括 cookies、localStorage 等)。 + +无论采用哪种方式,只要获得可用登录态,就继续原有浏览器 / Web2CLI 流程:加载登录态、打开目标 EDR 页面、按需注入 Web2CLI hook、执行页面操作并导出捕获到的请求。后续再次打开页面时,仍必须先校验 `auth-state.json`;若登录态失效且已保存账密,则自动重新走 CDP 登录并刷新 state。 + +若自动登录过程中出现验证码识别失败、MFA 校验、页面选择器变化、未检测到登录成功或有效 `sessionid` 等情况,立即回退到原有浏览器手动登录流程。 > ⚠️ **EDR 没有开放 API**,所有操作必须通过浏览器(CDP 直连)完成。 @@ -81,4 +96,4 @@ powershell -Command "& '\Scripts\python.exe' '\skil - ✅ 正确:`/bin/python`(Unix)或 `\Scripts\python.exe`(Windows) - ❌ 禁止:`python script.py` / `python3 script.py`(直接调用 PATH 中的 Python) -**原因**:Flocks 虚拟环境包含了所有项目依赖,系统 Python 可能缺少必要的包。完整跨平台示例见上一节"执行示例"。 \ No newline at end of file +**原因**:Flocks 虚拟环境包含了所有项目依赖,系统 Python 可能缺少必要的包。完整跨平台示例见上一节"执行示例"。 diff --git a/.flocks/plugins/skills/sangfor-edr-use/references/cdp-workflow.md b/.flocks/plugins/skills/sangfor-edr-use/references/cdp-workflow.md index f6a59c81f..d8b224596 100644 --- a/.flocks/plugins/skills/sangfor-edr-use/references/cdp-workflow.md +++ b/.flocks/plugins/skills/sangfor-edr-use/references/cdp-workflow.md @@ -42,8 +42,14 @@ google-chrome --remote-debugging-port=9222 chromium --remote-debugging-port=9222 ``` -### 3. 登录 EDR -确保用户在 Chrome 中已登录 EDR(如需 MFA,完成认证)。 +### 3. 登录 EDR +登录态必须先检查再分流,不要一开始就要求用户提供账密: + +1. 调用 `sangfor_edr_auth`,`action=status_auth_state`,确认固定 state 是否存在、是否可用,以及是否已有可自动刷新的账密配置。 +2. 如果返回的 `validation.valid` 为 `true`,直接复用已保存 state。 +3. 如果 state 不存在或失效,但 `can_auto_refresh` 为 `true`,调用 `sangfor_edr_auth`,`action=ensure_auth_state`,通过 browser daemon / CDP 驱动真实 EDR 登录页自动登录。验证码图片在浏览器会话中获取,OCR 识别后填入页面;登录成功后保存浏览器 state。 +4. 如果没有可用 state,也没有保存账密配置,再询问用户:提供 EDR 地址、用户名、密码后自动登录并保存,或不提供账密改走手动登录。 +5. 用户不提供账密、自动登录失败、MFA/验证码/OCR/DOM 选择器异常时,由用户在 Chrome 中手动登录 EDR(如需 MFA,完成认证),登录成功后保存浏览器 state。 --- @@ -64,13 +70,27 @@ flocks browser --doctor flocks browser --setup ``` -### Step 3:用户打开目标页面 -用户在 Chrome 中打开: -``` -{EDR_URL}/ui/#/index -``` - -### Step 4:执行抓取脚本 +### Step 3:准备登录态 +优先检查固定 state 文件: +``` +~/.flocks/browser/sangfor-edr/auth-state.json +``` + +推荐先调用工具做状态检查: + +- `action=status_auth_state`:返回 `auth_state_exists`、`validation.valid`、`can_auto_refresh` 等非敏感状态。 +- `validation.valid=true`:直接继续 Step 4。 +- `validation.valid=false` 且 `can_auto_refresh=true`:调用 `action=ensure_auth_state` 自动打开真实登录页、识别验证码、填入账密并保存新的 state。 +- `validation.valid=false` 且 `can_auto_refresh=false`:再询问用户是提供账密自动登录并保存,还是手动登录。 +- 用户拒绝提供账密或自动登录失败:提示用户在 Chrome 中手动登录,登录成功后执行 `flocks browser state save ~/.flocks/browser/sangfor-edr/auth-state.json`。 + +### Step 4:打开目标页面 +用户或工具在 Chrome 中打开: +``` +{EDR_URL}/ui/#/index +``` + +### Step 5:执行抓取脚本 **工具脚本路径**(位于 skill references 目录): ``` @@ -175,7 +195,8 @@ print(text_result["result"]["result"]["value"]) | `Target.getTargets` 返回空 | 浏览器未开启 remote debugging | 用户执行 `chrome.exe --remote-debugging-port=9222` | | EDR tab 未找到 | 页面未打开或 URL 不匹配 | 确保 Chrome 中打开了 EDR 首页 | | 页面数据为空 | EDR 内容在跨域 iframe 中 | 用 CDP direct 方式 attach 到 EDR tab,在正确 frame context 执行 JS | -| 页面显示登录框 | 会话已失效 | 告知用户重新登录 EDR | +| 页面显示登录框 | 会话已失效 | 若已保存账密,调用 `sangfor_edr_auth` 自动刷新 state;否则提示用户手动登录 EDR | +| 自动登录失败 | 验证码识别失败、MFA、页面选择器变化或登录成功检测失败 | 回退到浏览器手动登录,登录后保存 state | --- @@ -204,4 +225,4 @@ powershell -Command "& '\Scripts\python.exe' '\skil ```bash # macOS / Linux "/bin/python" "/skills/sangfor-edr-use/references/fetch_edr_system_state.py" --url "https://edr.example.com/" -``` \ No newline at end of file +``` diff --git a/.flocks/plugins/skills/skyeye-sensor-use/SKILL.md b/.flocks/plugins/skills/skyeye-sensor-use/SKILL.md index de4612965..dc2164abe 100644 --- a/.flocks/plugins/skills/skyeye-sensor-use/SKILL.md +++ b/.flocks/plugins/skills/skyeye-sensor-use/SKILL.md @@ -26,36 +26,98 @@ description: 使用天眼 SkyEye Sensor 传感器侧精简 CLI 查询告警列 > 对后台任务 / 定时任务,或系统不支持可视化,使用 `browser-use` 的 `cdp-headless` 模式。 State 文件路径:`~/.flocks/browser/skyeye-sensor/auth-state.json`(固定,全局唯一)。 +自动登录配置路径:`~/.flocks/browser/skyeye-sensor/auth-config.json`(只保存 base_url、state 路径和 secret 引用;密码写入 Flocks secret)。 + +## 跨平台执行约定(必须遵守) + +`scripts/skyeye_sensor_auth.py` 和 `scripts/skyeye_sensor_cli.py` 都是 **skill-relative** 路径。执行任何脚本前,必须先把当前工作目录切到 `skill_load` 输出中的 **Base directory**,不要假设当前 shell 已经在 skill 目录,也不要拼未加引号的绝对路径。 + +推荐方式: + +- 如果工具支持 `workdir` 参数,直接把 `workdir` 设为 `skill_load` 输出的 Base directory,再运行 `uv run python scripts/...`。 +- 如果只能写 shell 命令,先进入 Base directory,再运行脚本。 +- 跨平台示例优先使用 `--base-url`、`--auth-state` 等 CLI 参数,少用 shell 环境变量前缀。 +- Windows PowerShell 设置环境变量必须用 `$env:NAME = 'value'`;不要用 POSIX 的 `NAME=value command`。 +- macOS/Linux shell 可以用 `NAME=value command` 或 `export NAME=value`。 +- 路径含空格时必须整体加引号。Windows 下优先用 `Set-Location -LiteralPath ''`,macOS/Linux 下优先用 `cd "$SKILL_DIR"`。 +- 不要在 `glob` / 文件工具的 `path` 参数里写 `$env:USERPROFILE`、`$HOME` 这类 shell 变量;这些参数通常不会做 shell 展开。需要用户目录时,用 `~/.flocks/...` 或先在 shell 里展开后再传入。 + +Windows PowerShell 模板: + +```powershell +Set-Location -LiteralPath '' +uv run python scripts/skyeye_sensor_auth.py --base-url 'https://' validate +uv run python scripts/skyeye_sensor_cli.py --base-url 'https://' alarm list --days 7 +``` + +macOS/Linux 模板: + +```bash +cd "" +uv run python scripts/skyeye_sensor_auth.py --base-url 'https://' validate +uv run python scripts/skyeye_sensor_cli.py --base-url 'https://' alarm list --days 7 +``` ### 首次登录 / Session 过期重新登录 +打开 SkyEye Sensor 页面或执行 CLI 前,必须按下面顺序处理登录态,不要一开始就要求用户提供账密: + +1. 先检查 `auth-state.json` 是否可用。 +2. 如果 state 可用,直接复用登录态继续打开页面或执行 CLI 获取数据。 +3. 如果 state 不存在或失效,但本地已有可用于自动登录的配置,先在 skill Base directory 中执行自动刷新:`uv run python scripts/skyeye_sensor_auth.py ensure`。 +4. 如果没有可用 state,也没有保存账密配置,再引导用户选择: + - 提供 SkyEye Sensor 地址、用户名、密码,自动登录并保存账密配置,后续 state 失效时可直接自动刷新; + - 不提供账密,走浏览器手动登录流程。 +5. 只有自动登录失败、验证码 OCR / MFA / 登录页 DOM 变化 / 登录成功检测失败,或用户拒绝提供账密时,才回退手动登录。 + +只读查看本地是否具备自动刷新条件(需在 skill Base directory 中执行): + ```bash -flocks browser --doctor +uv run python scripts/skyeye_sensor_auth.py status ``` -如果 `flocks browser --doctor` 提示浏览器已运行,但 daemon 或 active browser connection 不可用,必须直接提示用户: +检查 state 是否可用(需在 skill Base directory 中执行): -```text -browser: not connected — 请确保 Chrome / Chromium / Edge 已打开,然后访问对应浏览器的 inspect 页面(例如 chrome://inspect/#remote-debugging 或 edge://inspect/#remote-debugging)并勾选 Allow remote debugging +```bash +uv run python scripts/skyeye_sensor_auth.py --base-url https:// validate ``` -然后等待用户进一步指示,不要直接操作。 +如果 `validate` 返回 `valid: true`,继续后续页面操作或 CLI 查询;如果返回 `auth_state_not_found`、`auth_state_expired_or_login_page` 或 `auth_state_load_failed`,再看 `status` 中 `can_auto_refresh` 是否为 `true`。 -当用户确认已开启 remote debugging 后: +有已保存账密配置时自动刷新(需在 skill Base directory 中执行): + +```bash +uv run python scripts/skyeye_sensor_auth.py ensure +``` + +自动刷新成功后继续执行原页面或 CLI 操作;失败才进入下方用户选择流程。 + +```bash +flocks browser --doctor +``` + +如果 `flocks browser --doctor` 提示浏览器已运行,但 daemon 或 active browser connection 不可用,先执行 `flocks browser --setup` 触发 attach,不要先要求用户重复勾选 Allow remote debugging。 1. 执行 `flocks browser --setup` 触发交互式 attach,不要用短超时包装该命令。 2. 再运行 `flocks browser --doctor` 做只读确认。 -3. 如果还失败,先执行 `flocks browser --reload` 清理旧 daemon,再重新执行 `flocks browser --setup`,避免因为残留 daemon 造成干扰。 -4. 只有随后 `--doctor` 通过后,才继续后面的登录或页面操作。 +3. 如果 `--setup` 或 `--doctor` 明确提示 remote debugging 未启用,再提示用户打开 inspect 页面(例如 `chrome://inspect/#remote-debugging` 或 `edge://inspect/#remote-debugging`)并勾选 Allow remote debugging。 +4. 如果还失败,先执行 `flocks browser --reload` 清理旧 daemon,再重新执行 `flocks browser --setup`,避免因为残留 daemon 造成干扰。 +5. 只有随后 `--doctor` 通过后,才继续后面的登录或页面操作。 -打开登录页并等待用户手动完成登录(含短信验证码 / MFA 等): +当必须询问用户时,说明两种选择并尊重用户偏好: + +- 提供账密并保存:调用 `scripts/skyeye_sensor_auth.py`,通过 browser daemon / CDP 驱动真实登录页自动登录。脚本会优先从登录页验证码图片元素动态获取图片、OCR 识别、填入页面、保存 state,并把用户名/密码写入 Flocks secret,把基础配置保存到 `~/.flocks/browser/skyeye-sensor/auth-config.json`。后续 `auth-state.json` 失效时,可在 skill Base directory 中直接执行 `uv run python scripts/skyeye_sensor_auth.py ensure`。 +- 不提供账密:沿用原有浏览器登录流程。打开登录页后由用户手动完成登录(含短信验证码 / MFA 等),登录成功后保存 state。 + +手动登录时,打开登录页并等待用户完成登录: ```bash flocks browser -c ' tid = new_tab("https:///login", activate=True) wait_for_load() print(tid) -print(page_info()) +import json +print(json.dumps(page_info(), ensure_ascii=True)) ' ``` @@ -66,6 +128,32 @@ print(page_info()) flocks browser state save ~/.flocks/browser/skyeye-sensor/auth-state.json ``` +提供账密并保存时,执行自动登录: + +```bash +uv run python scripts/skyeye_sensor_auth.py --base-url https:// ensure --username '' --password '' +``` + +下次 `auth-state.json` 失效且本地保存过账密配置时,可直接执行: + +```bash +uv run python scripts/skyeye_sensor_auth.py ensure +``` + +也可以在查询命令前直接带账密或使用已保存账密刷新登录态: + +```bash +uv run python scripts/skyeye_sensor_cli.py \ + --base-url https:// \ + --username '' \ + --password '' \ + alarm count + +uv run python scripts/skyeye_sensor_cli.py --auto-login alarm list +``` + +如果验证码 OCR、MFA、登录页 DOM 变化或登录成功检测失败,回退到上面的手动登录流程。 + ### CLI 认证失败时的恢复流程 当 CLI 出现以下任一情况,优先判定为认证问题(**不要立刻要求用户重新登录**): @@ -82,13 +170,15 @@ flocks browser state load ~/.flocks/browser/skyeye-sensor/auth-state.json --url # 2) 读取当前页面状态 flocks browser -c ' -print(page_info()) +import json +print(json.dumps(page_info(), ensure_ascii=True)) ' ``` ```bash # 3) 根据结果决策 URL=$(flocks browser -c ' +import json info = page_info() print(info.get("url", "")) ' | tail -n 1) @@ -112,10 +202,11 @@ CLI 在 skill 内: 执行命令时: -1. 优先使用 `uv run python scripts/skyeye_sensor_cli.py ...` -2. 认证优先使用浏览器导出的 `auth-state.json` +1. 必须先进入 `skill_load` 输出的 Base directory,或把命令工具的 `workdir` 设置为该目录。 +2. 优先使用 `uv run python scripts/skyeye_sensor_cli.py ...` +3. 认证优先使用浏览器导出的 `auth-state.json` -认证环境变量: +可选认证环境变量: - `SKYEYE_SENSOR_BASE_URL=https://` - `SKYEYE_SENSOR_AUTH_STATE=~/.flocks/browser/skyeye-sensor/auth-state.json` @@ -124,7 +215,7 @@ CLI 在 skill 内: ## 常用命令 -如果已经通过 `export` 设置好环境变量: +如果已经通过环境变量或 CLI 参数设置好认证信息: ```bash # 默认输出 JSON @@ -139,13 +230,10 @@ uv run python scripts/skyeye_sensor_cli.py alarm list --days 7 --hazard-level "3 uv run python scripts/skyeye_sensor_cli.py alarm list --days 7 --table ``` -带认证的完整单行格式(无需提前 export,适合直接执行): +带认证的完整单行格式(无需提前 export,适合 Windows/macOS/Linux 直接执行): ```bash -SKYEYE_SENSOR_BASE_URL=https:// \ -SKYEYE_SENSOR_AUTH_STATE=~/.flocks/browser/skyeye-sensor/auth-state.json \ -uv run python scripts/skyeye_sensor_cli.py \ - alarm list --days 7 --hazard-level "3,2" --json +uv run python scripts/skyeye_sensor_cli.py --base-url https:// --auth-state ~/.flocks/browser/skyeye-sensor/auth-state.json alarm list --days 7 --hazard-level "3,2" ``` ## 查询策略 @@ -250,7 +338,7 @@ uv run python scripts/skyeye_sensor_cli.py alarm count --days 7 --hazard-level " ## 重要提醒 -- **Session 管理**:详见[零、登录认证](#零登录认证)。任务开始前先确认 `auth-state.json` 存在;CLI 认证失败时先走恢复流程,不要立刻要求用户重新登录。 +- **Session 管理**:详见[零、登录认证](#零登录认证)。任务开始前先用 `skyeye_sensor_auth.py validate` 确认 `auth-state.json` 可用;CLI 认证失败时先走恢复流程,不要立刻要求用户重新登录。 - **禁止连续失败循环**:同一命令最多重试 2 次;认证恢复流程只走一次,仍失败则提示用户手动重新登录。 - **以下错误属于需要用户干预的基础设施问题,立即停止所有重试,直接告知用户处理**: - `ERR_CERT_AUTHORITY_INVALID`:站点证书不被本机信任,使用--ignore-https-errors 或 请求用户处理。 diff --git a/.flocks/plugins/skills/skyeye-sensor-use/references/API_REFERENCE.md b/.flocks/plugins/skills/skyeye-sensor-use/references/API_REFERENCE.md index f64390364..53c94da16 100644 --- a/.flocks/plugins/skills/skyeye-sensor-use/references/API_REFERENCE.md +++ b/.flocks/plugins/skills/skyeye-sensor-use/references/API_REFERENCE.md @@ -1,12 +1,21 @@ # SkyEye Sensor 查询参考 -CLI 路径:`./.flocks/skills/skyeye-sensor-data-fetch/scripts/skyeye_sensor_cli.py` +CLI 路径:`scripts/skyeye_sensor_cli.py`(相对于 `skill_load` 输出的 Base directory) -建议在 `./.flocks/skills/skyeye-sensor-data-fetch/scripts` 目录执行,并通过环境变量提供认证信息: +执行前必须先进入 `skill_load` 输出的 Base directory,或把命令工具的 `workdir` 设置为该目录。不要假设当前工作目录在 skill 目录,也不要拼未加引号的绝对路径。 + +Windows PowerShell: + +```powershell +Set-Location -LiteralPath '' +uv run python scripts/skyeye_sensor_auth.py --base-url 'https://' validate +``` + +macOS / Linux: ```bash -export SKYEYE_SENSOR_BASE_URL="https://" -export SKYEYE_SENSOR_AUTH_STATE="$HOME/.flocks/browser/skyeye-sensor/auth-state.json" +cd "" +uv run python scripts/skyeye_sensor_auth.py --base-url 'https://' validate ``` 备选认证方式(无 state 文件时): @@ -23,13 +32,13 @@ export SKYEYE_SENSOR_AUTH_STATE="$HOME/.flocks/browser/skyeye-sensor/auth-state. ```bash # 告警明细 -uv run python skyeye_sensor_cli.py alarm list --days 7 --page 1 --page-size 10 +uv run python scripts/skyeye_sensor_cli.py alarm list --days 7 --page 1 --page-size 10 # 按条件过滤 -uv run python skyeye_sensor_cli.py alarm list --hours 6 --sip "1.1.1.1" +uv run python scripts/skyeye_sensor_cli.py alarm list --hours 6 --sip "1.1.1.1" # 告警统计 -uv run python skyeye_sensor_cli.py alarm count --days 1 --sip "1.1.1.1" +uv run python scripts/skyeye_sensor_cli.py alarm count --days 1 --sip "1.1.1.1" ``` ## 适用范围 @@ -98,19 +107,19 @@ uv run python skyeye_sensor_cli.py alarm count --days 1 --sip "1.1.1.1" ```bash # 最近 7 天,某源 IP 的告警 -uv run python skyeye_sensor_cli.py alarm list --days 7 --sip "1.1.1.1" +uv run python scripts/skyeye_sensor_cli.py alarm list --days 7 --sip "1.1.1.1" # 最近 24 小时,高危 + 严重告警 -uv run python skyeye_sensor_cli.py alarm list --hours 24 --hazard-level "3,2" +uv run python scripts/skyeye_sensor_cli.py alarm list --hours 24 --hazard-level "3,2" # 指定威胁类型 + 主机状态 -uv run python skyeye_sensor_cli.py alarm list \ +uv run python scripts/skyeye_sensor_cli.py alarm list \ --days 7 \ --threat-type "2,3" \ --host-state "0,1,2,-1" # 只统计数量 -uv run python skyeye_sensor_cli.py alarm count --days 1 --sip "1.1.1.1" +uv run python scripts/skyeye_sensor_cli.py alarm count --days 1 --sip "1.1.1.1" ``` --- diff --git a/.flocks/plugins/skills/skyeye-sensor-use/scripts/requirements.txt b/.flocks/plugins/skills/skyeye-sensor-use/scripts/requirements.txt index d44cde4fa..5576e8c57 100644 --- a/.flocks/plugins/skills/skyeye-sensor-use/scripts/requirements.txt +++ b/.flocks/plugins/skills/skyeye-sensor-use/scripts/requirements.txt @@ -1,3 +1,4 @@ click>=8.0.0 requests>=2.28.0 rich>=13.0.0 +ddddocr>=1.6.1 diff --git a/.flocks/plugins/skills/skyeye-sensor-use/scripts/skyeye_sensor_auth.py b/.flocks/plugins/skills/skyeye-sensor-use/scripts/skyeye_sensor_auth.py new file mode 100644 index 000000000..f4630af13 --- /dev/null +++ b/.flocks/plugins/skills/skyeye-sensor-use/scripts/skyeye_sensor_auth.py @@ -0,0 +1,670 @@ +#!/usr/bin/env python3 +"""SkyEye Sensor browser auth-state helper. + +This script follows the skill's existing browser workflow and only automates +the manual login step when credentials are available: + +1. load and validate the saved browser auth-state; +2. if it is missing or expired, open the real login page with CDP; +3. wait for the async-rendered login form; +4. fetch and OCR the captcha in the browser session; +5. fill the form, submit it, and save a refreshed auth-state. +""" + +from __future__ import annotations + +import argparse +import base64 +import json +import os +import random +import sys +import time +from pathlib import Path +from typing import Any, Optional +from urllib.parse import urljoin, urlparse + +from flocks.browser import helpers + +AUTH_DIR = Path.home() / ".flocks" / "browser" / "skyeye-sensor" +DEFAULT_AUTH_STATE = AUTH_DIR / "auth-state.json" +AUTH_CONFIG = AUTH_DIR / "auth-config.json" +USERNAME_SECRET_ID = "skyeye_sensor_username" +PASSWORD_SECRET_ID = "skyeye_sensor_password" +DEFAULT_LOGIN_PATH = "/login" +DEFAULT_CAPTCHA_PATH = "/skyeye/admin/code" +DEFAULT_TIMEOUT = 25 +ESSENTIAL_LOCAL_STORAGE_KEYS = {"csrf_token", "csrfToken", "system_type"} +MAX_LOCAL_STORAGE_VALUE_BYTES = 4096 + + +def _get_secret_manager(): + from flocks.security import get_secret_manager + + return get_secret_manager() + + +def _resolve_ref(value: Any) -> Optional[str]: + if value is None: + return None + if not isinstance(value, str): + return str(value) + if value.startswith("{secret:") and value.endswith("}"): + return _get_secret_manager().get(value[len("{secret:") : -1]) + if value.startswith("{env:") and value.endswith("}"): + return os.getenv(value[len("{env:") : -1]) + return value + + +def _normalise_base_url(value: str) -> str: + candidate = value.strip() + if not candidate: + raise ValueError("SkyEye Sensor base_url is required.") + if "://" not in candidate: + candidate = f"https://{candidate}" + parsed = urlparse(candidate) + if not parsed.hostname: + raise ValueError(f"Invalid SkyEye Sensor base_url: {value!r}") + host = parsed.hostname + if ":" in host and not host.startswith("["): + host = f"[{host}]" + port = f":{parsed.port}" if parsed.port else "" + return f"{parsed.scheme}://{host}{port}".rstrip("/") + + +def _read_config() -> dict[str, Any]: + if not AUTH_CONFIG.exists(): + return {} + try: + data = json.loads(AUTH_CONFIG.read_text(encoding="utf-8")) + except json.JSONDecodeError: + return {} + return data if isinstance(data, dict) else {} + + +def _write_config(data: dict[str, Any]) -> None: + AUTH_DIR.mkdir(parents=True, exist_ok=True) + AUTH_CONFIG.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + + +def _persist_inputs(args: argparse.Namespace, config: dict[str, Any]) -> dict[str, Any]: + if args.base_url: + config["base_url"] = _normalise_base_url(args.base_url) + if args.auth_state: + config["auth_state_path"] = str(Path(args.auth_state).expanduser()) + if args.login_path: + config["login_path"] = args.login_path + if args.captcha_path: + config["captcha_path"] = args.captcha_path + + if args.save_credentials: + secrets = _get_secret_manager() + if args.username: + secrets.set(USERNAME_SECRET_ID, args.username) + config["username"] = f"{{secret:{USERNAME_SECRET_ID}}}" + if args.password: + secrets.set(PASSWORD_SECRET_ID, args.password) + config["password"] = f"{{secret:{PASSWORD_SECRET_ID}}}" + if args.base_url or args.auth_state or args.username or args.password: + _write_config(config) + return config + + +def saved_auto_login_status() -> dict[str, Any]: + """Return non-sensitive information about saved auto-login inputs.""" + config = _read_config() + username = _resolve_ref(config.get("username")) or _get_secret_manager().get(USERNAME_SECRET_ID) + password = _resolve_ref(config.get("password")) or _get_secret_manager().get(PASSWORD_SECRET_ID) + base_url = _resolve_ref(config.get("base_url")) or os.getenv("SKYEYE_SENSOR_BASE_URL") + auth_state_path = Path( + _resolve_ref(config.get("auth_state_path")) + or os.getenv("SKYEYE_SENSOR_AUTH_STATE") + or DEFAULT_AUTH_STATE + ).expanduser() + has_username = bool(str(username or "").strip()) + has_password = bool(str(password or "").strip()) + has_base_url = bool(str(base_url or "").strip()) + return { + "auth_state_path": str(auth_state_path), + "auth_state_exists": auth_state_path.exists(), + "auth_config_path": str(AUTH_CONFIG), + "auth_config_exists": AUTH_CONFIG.exists(), + "has_base_url": has_base_url, + "has_saved_username": has_username, + "has_saved_password": has_password, + "can_auto_refresh": has_base_url and has_username and has_password, + } + + +class RuntimeConfig: + def __init__( + self, + *, + base_url: str, + auth_state_path: Path, + username: str, + password: str, + login_path: str, + captcha_path: str, + timeout: int, + auto_ocr: bool, + max_captcha_retry: int, + ) -> None: + self.base_url = base_url + self.auth_state_path = auth_state_path + self.username = username + self.password = password + self.login_path = login_path + self.captcha_path = captcha_path + self.timeout = timeout + self.auto_ocr = auto_ocr + self.max_captcha_retry = max_captcha_retry + + +def _runtime_config(args: argparse.Namespace) -> RuntimeConfig: + config = _persist_inputs(args, _read_config()) + secrets = _get_secret_manager() + + base_url = _normalise_base_url( + args.base_url + or _resolve_ref(config.get("base_url")) + or os.getenv("SKYEYE_SENSOR_BASE_URL") + or "" + ) + auth_state_path = Path( + args.auth_state + or _resolve_ref(config.get("auth_state_path")) + or os.getenv("SKYEYE_SENSOR_AUTH_STATE") + or DEFAULT_AUTH_STATE + ).expanduser() + username = ( + args.username + or _resolve_ref(config.get("username")) + or secrets.get(USERNAME_SECRET_ID) + or os.getenv("SKYEYE_SENSOR_USERNAME") + or "" + ).strip() + password = ( + args.password + or _resolve_ref(config.get("password")) + or secrets.get(PASSWORD_SECRET_ID) + or os.getenv("SKYEYE_SENSOR_PASSWORD") + or "" + ).strip() + + return RuntimeConfig( + base_url=base_url, + auth_state_path=auth_state_path, + username=username, + password=password, + login_path=args.login_path or str(config.get("login_path") or DEFAULT_LOGIN_PATH), + captcha_path=args.captcha_path or str(config.get("captcha_path") or DEFAULT_CAPTCHA_PATH), + timeout=max(5, int(args.timeout or DEFAULT_TIMEOUT)), + auto_ocr=not args.no_ocr, + max_captcha_retry=max(1, int(args.max_captcha_retry or 5)), + ) + + +def _url(cfg: RuntimeConfig, path: str) -> str: + return urljoin(cfg.base_url + "/", path.lstrip("/")) + + +def _login_url(cfg: RuntimeConfig) -> str: + return _url(cfg, cfg.login_path) + + +def _storage_entry_keep(entry: Any) -> bool: + if not isinstance(entry, dict): + return False + name = str(entry.get("name") or "") + value = str(entry.get("value") or "") + if name in ESSENTIAL_LOCAL_STORAGE_KEYS: + return True + lowered = name.lower() + if "token" in lowered or "csrf" in lowered: + return len(value.encode("utf-8", errors="ignore")) <= MAX_LOCAL_STORAGE_VALUE_BYTES + return False + + +def _filter_auth_state_file(path: Path) -> dict[str, Any]: + try: + state = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return {"filtered": False} + if not isinstance(state, dict): + return {"filtered": False} + origins = state.get("origins") + if not isinstance(origins, list): + return {"filtered": False} + + before = 0 + after = 0 + for origin in origins: + if not isinstance(origin, dict): + continue + entries = origin.get("localStorage") + if not isinstance(entries, list): + continue + before += len(entries) + kept = [entry for entry in entries if _storage_entry_keep(entry)] + after += len(kept) + origin["localStorage"] = kept + + path.write_text(json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8") + return {"filtered": True, "localStorageItemsBefore": before, "localStorageItemsAfter": after} + + +def _save_filtered_state(cfg: RuntimeConfig) -> dict[str, Any]: + saved = helpers.save_state(cfg.auth_state_path, url=cfg.base_url) + return {**saved, "filter": _filter_auth_state_file(cfg.auth_state_path)} + + +def _open_page(url: str) -> None: + try: + helpers.open_or_attach_tab(url) + except Exception: + helpers.goto_url(url) + helpers.wait_for_load(timeout=15) + + +def _page_text() -> str: + try: + return str(helpers.js("document.body ? document.body.innerText : ''") or "") + except Exception: + return "" + + +def _has_auth_cookie(cfg: RuntimeConfig) -> bool: + try: + cookies = helpers.cdp("Network.getCookies", urls=[cfg.base_url]).get("cookies", []) + except Exception: + return False + if not isinstance(cookies, list): + return False + names = {str(cookie.get("name") or "") for cookie in cookies if isinstance(cookie, dict)} + return bool(names & {"csrfToken", "sessionid", "JSESSIONID", "PHPSESSID"}) + + +def _looks_like_login_page(url: str, text: str) -> bool: + haystack = f"{url}\n{text}".lower() + return any(marker in haystack for marker in ("login", "请输入用户名", "请输入密码", "请输入验证码", "验证码")) + + +def _is_logged_in(cfg: RuntimeConfig) -> bool: + info = helpers.page_info() + current_url = str(info.get("url") or "") + if _looks_like_login_page(current_url, _page_text()): + return False + return _has_auth_cookie(cfg) or cfg.base_url.rstrip("/") in current_url + + +def _is_browser_daemon_error(exc: Exception) -> bool: + message = str(exc) + return "bu.port" in message or "daemon" in message.lower() + + +def _browser_daemon_result(exc: Exception, auth_state_path: Path) -> dict[str, Any]: + return { + "success": False, + "valid": False, + "status": "browser_daemon_not_ready", + "reason": "browser_daemon_not_ready", + "error": str(exc), + "next_action": "run `flocks browser --setup`, then `flocks browser --doctor`, then retry", + "auth_state_path": str(auth_state_path), + } + + +def validate_auth_state(cfg: RuntimeConfig) -> dict[str, Any]: + if not cfg.auth_state_path.exists(): + return { + "valid": False, + "reason": "auth_state_not_found", + "auth_state_path": str(cfg.auth_state_path), + } + try: + loaded = helpers.load_state(cfg.auth_state_path, url=cfg.base_url) + helpers.wait_for_load(timeout=15) + if _is_logged_in(cfg): + return { + "valid": True, + "reason": "browser_state_loaded", + "auth_state_path": str(cfg.auth_state_path), + "loaded": loaded, + } + return { + "valid": False, + "reason": "auth_state_expired_or_login_page", + "auth_state_path": str(cfg.auth_state_path), + "loaded": loaded, + } + except Exception as exc: + if _is_browser_daemon_error(exc): + result = _browser_daemon_result(exc, cfg.auth_state_path) + result["reason"] = "auth_state_load_failed_browser_daemon_not_ready" + return result + return { + "valid": False, + "reason": "auth_state_load_failed", + "error": str(exc), + "auth_state_path": str(cfg.auth_state_path), + } + + +def _login_dom_summary() -> Any: + script = """(() => { + return Array.from(document.querySelectorAll("input,button,a,img,iframe")) + .slice(0, 80) + .map((el) => ({ + tag: el.tagName, + id: el.id || "", + name: el.getAttribute("name") || "", + type: el.getAttribute("type") || "", + placeholder: el.getAttribute("placeholder") || "", + text: (el.innerText || el.value || el.getAttribute("alt") || "").trim().slice(0, 80), + className: String(el.className || ""), + src: el.getAttribute("src") || "", + href: el.getAttribute("href") || "" + })); +})()""" + try: + return helpers.js(script) + except Exception as exc: + return {"error": str(exc)} + + +def _wait_for_login_form_ready(cfg: RuntimeConfig) -> dict[str, bool]: + script = """(() => { + const exists = (selector) => Boolean(document.querySelector(selector)); + const textButton = Array.from(document.querySelectorAll("button")) + .some((el) => /登\\s*录|login/i.test((el.innerText || "").trim())); + return { + username: exists("input[placeholder='请输入用户名']") || exists(".login-form input[type='text']"), + password: exists("input[placeholder='请输入密码']") || exists(".login-form input[type='password']"), + captcha: exists("input[placeholder='请输入验证码']") || exists(".code-input input"), + submit: exists(".login-form button.q-button--primary") || textButton + }; +})()""" + deadline = time.time() + cfg.timeout + last_state: dict[str, bool] = {} + while time.time() < deadline: + state = helpers.js(script) + last_state = state if isinstance(state, dict) else {} + if last_state.get("username") and last_state.get("password") and last_state.get("captcha") and last_state.get("submit"): + return {key: bool(value) for key, value in last_state.items()} + time.sleep(0.5) + raise RuntimeError( + "SkyEye Sensor login form was not rendered before timeout: " + + json.dumps({"state": last_state, "dom": _login_dom_summary()}, ensure_ascii=False) + ) + + +def _captcha_image_data_url_from_dom() -> str: + script = """(async () => { + const srcAttrs = ["src", "currentSrc", "data-src", "data-url", "data-original"]; + const images = Array.from(document.querySelectorAll("img")); + const candidates = images + .map((img) => { + const values = srcAttrs + .map((attr) => attr === "currentSrc" ? img.currentSrc : img.getAttribute(attr)) + .filter(Boolean); + const hint = [ + img.id || "", + String(img.className || ""), + img.alt || "", + img.title || "", + values.join(" ") + ].join(" ").toLowerCase(); + return {values, hint}; + }) + .filter((item) => item.values.length) + .sort((a, b) => { + const score = (item) => /captcha|verify|vcode|code|验证码/.test(item.hint) ? 0 : 1; + return score(a) - score(b); + }); + + for (const item of candidates) { + for (const raw of item.values) { + const url = new URL(raw, window.location.href).href; + if (url.startsWith("data:image/")) { + return url; + } + try { + const response = await fetch(url, {credentials: "include", cache: "no-store"}); + if (!response.ok) continue; + const blob = await response.blob(); + if (!String(blob.type || "").startsWith("image/")) continue; + return await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onerror = () => reject(reader.error || new Error("captcha image read failed")); + reader.onload = () => resolve(String(reader.result)); + reader.readAsDataURL(blob); + }); + } catch (_err) { + } + } + } + return ""; +})()""" + return str(helpers.js(script) or "") + + +def _captcha_image_from_browser(cfg: RuntimeConfig) -> bytes: + data_url = _captcha_image_data_url_from_dom() + if not data_url: + captcha_url = _url(cfg, f"{cfg.captcha_path}?r={random.random()}") + script = f"""(async () => {{ + const response = await fetch({json.dumps(captcha_url)}, {{ + credentials: "include", + cache: "no-store" + }}); + if (!response.ok) {{ + throw new Error("captcha request failed: " + response.status); + }} + const blob = await response.blob(); + return await new Promise((resolve, reject) => {{ + const reader = new FileReader(); + reader.onerror = () => reject(reader.error || new Error("captcha read failed")); + reader.onload = () => resolve(String(reader.result)); + reader.readAsDataURL(blob); + }}); +}})()""" + data_url = str(helpers.js(script) or "") + if "," not in data_url: + raise RuntimeError("SkyEye Sensor captcha fetch did not return a data URL.") + return base64.b64decode(data_url.split(",", 1)[1]) + + +def _ocr_code(image_bytes: bytes) -> str: + try: + import ddddocr + except ImportError as exc: + raise RuntimeError("ddddocr is required for automatic captcha recognition.") from exc + return str(ddddocr.DdddOcr(show_ad=False).classification(image_bytes)).strip()[:4] + + +def _fill_and_submit(cfg: RuntimeConfig, code: str) -> dict[str, Any]: + payload = { + "username": cfg.username, + "password": cfg.password, + "code": code, + } + script = f"""(() => {{ + const cfg = {json.dumps(payload, ensure_ascii=False)}; + const username = document.querySelector("input[placeholder='请输入用户名']") + || document.querySelector(".login-form input[type='text']"); + const password = document.querySelector("input[placeholder='请输入密码']") + || document.querySelector(".login-form input[type='password']"); + const captcha = document.querySelector("input[placeholder='请输入验证码']") + || document.querySelector(".code-input input"); + const agreement = document.querySelector(".protocol input[type='checkbox']") + || document.querySelector(".q-checkbox__original"); + const submit = document.querySelector(".login-form button.q-button--primary") + || Array.from(document.querySelectorAll("button")) + .find((el) => /登\\s*录|login/i.test((el.innerText || "").trim())); + + function setValue(el, value) {{ + if (!el) return false; + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value").set; + el.focus(); + setter.call(el, value); + el.dispatchEvent(new Event("input", {{bubbles: true}})); + el.dispatchEvent(new Event("change", {{bubbles: true}})); + el.dispatchEvent(new KeyboardEvent("keyup", {{bubbles: true}})); + return true; + }} + + const filled = {{ + username: setValue(username, cfg.username), + password: setValue(password, cfg.password), + captcha: setValue(captcha, cfg.code), + agreement: false + }}; + if (!filled.username || !filled.password || !filled.captcha) {{ + throw new Error("missing SkyEye Sensor login input"); + }} + if (agreement) {{ + if (!agreement.checked) agreement.click(); + agreement.dispatchEvent(new Event("input", {{bubbles: true}})); + agreement.dispatchEvent(new Event("change", {{bubbles: true}})); + filled.agreement = Boolean(agreement.checked); + }} + if (!submit) {{ + throw new Error("missing SkyEye Sensor login submit button"); + }} + submit.click(); + return filled; +}})()""" + result = helpers.js(script) + return result if isinstance(result, dict) else {"result": result} + + +def _wait_for_login_success(cfg: RuntimeConfig) -> bool: + deadline = time.time() + cfg.timeout + while time.time() < deadline: + try: + if _is_logged_in(cfg): + return True + except Exception: + pass + time.sleep(0.5) + return False + + +def refresh_auth_state(cfg: RuntimeConfig, captcha_code: str = "") -> dict[str, Any]: + missing = [] + if not cfg.username: + missing.append("username") + if not cfg.password: + missing.append("password") + if missing: + return { + "success": False, + "status": "manual_login_required", + "reason": "missing_credentials", + "missing": missing, + "auth_state_path": str(cfg.auth_state_path), + } + + try: + _open_page(_login_url(cfg)) + form_state = _wait_for_login_form_ready(cfg) + except Exception as exc: + if _is_browser_daemon_error(exc): + return _browser_daemon_result(exc, cfg.auth_state_path) + raise + last_error = "" + for attempt in range(1, cfg.max_captcha_retry + 1): + try: + code = captcha_code.strip() + if not code: + if not cfg.auto_ocr: + return { + "success": False, + "status": "manual_login_required", + "reason": "captcha_code_required", + "auth_state_path": str(cfg.auth_state_path), + } + code = _ocr_code(_captcha_image_from_browser(cfg)) + filled = _fill_and_submit(cfg, code) + if _wait_for_login_success(cfg): + saved = _save_filtered_state(cfg) + return { + "success": True, + "status": "browser_cdp_login_refreshed_auth_state", + "auth_state_path": str(cfg.auth_state_path), + "attempt": attempt, + "form": form_state, + "filled": {key: bool(value) for key, value in filled.items()}, + "saved": saved, + } + last_error = "login_success_check_timeout" + except Exception as exc: + last_error = str(exc) + + if captcha_code: + break + try: + helpers.goto_url(_login_url(cfg)) + helpers.wait_for_load(timeout=10) + form_state = _wait_for_login_form_ready(cfg) + except Exception: + pass + + return { + "success": False, + "status": "manual_login_required", + "reason": "browser_cdp_login_failed", + "last_error": last_error, + "auth_state_path": str(cfg.auth_state_path), + } + + +def ensure_auth_state(cfg: RuntimeConfig, captcha_code: str = "", force_refresh: bool = False) -> dict[str, Any]: + if not force_refresh: + validation = validate_auth_state(cfg) + if validation.get("valid"): + return {"success": True, "status": "auth_state_loaded", **validation} + return refresh_auth_state(cfg, captcha_code=captcha_code) + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Ensure SkyEye Sensor browser auth-state") + parser.add_argument("action", nargs="?", choices=["ensure", "validate", "refresh", "status"], default="ensure") + parser.add_argument("--base-url", help="SkyEye Sensor base URL, for example https://sensor.example.com") + parser.add_argument("--username", help="Username for CDP-assisted login") + parser.add_argument("--password", help="Password for CDP-assisted login") + parser.add_argument("--auth-state", help=f"Auth-state path, default: {DEFAULT_AUTH_STATE}") + parser.add_argument("--login-path", default="", help=f"Login path, default: {DEFAULT_LOGIN_PATH}") + parser.add_argument("--captcha-path", default="", help="Captcha path override") + parser.add_argument("--captcha-code", default="", help="Manual captcha code; OCR is used when omitted") + parser.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT) + parser.add_argument("--max-captcha-retry", type=int, default=5) + parser.add_argument("--no-ocr", action="store_true", help="Do not OCR captcha; require --captcha-code") + parser.set_defaults(save_credentials=True) + return parser + + +def main() -> int: + args = _build_parser().parse_args() + try: + if args.action == "status": + result = {"success": True, "status": "saved_auto_login_status", **saved_auto_login_status()} + print(json.dumps(result, ensure_ascii=False, indent=2)) + return 0 + cfg = _runtime_config(args) + if args.action == "validate": + result = validate_auth_state(cfg) + elif args.action == "refresh": + result = refresh_auth_state(cfg, captcha_code=args.captcha_code) + else: + result = ensure_auth_state(cfg, captcha_code=args.captcha_code) + print(json.dumps(result, ensure_ascii=False, indent=2)) + return 0 if result.get("success") or result.get("valid") else 1 + except Exception as exc: + print(json.dumps({"success": False, "error": str(exc)}, ensure_ascii=False, indent=2)) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.flocks/plugins/skills/skyeye-sensor-use/scripts/skyeye_sensor_cli.py b/.flocks/plugins/skills/skyeye-sensor-use/scripts/skyeye_sensor_cli.py index d75b8af23..6039ce1aa 100644 --- a/.flocks/plugins/skills/skyeye-sensor-use/scripts/skyeye_sensor_cli.py +++ b/.flocks/plugins/skills/skyeye-sensor-use/scripts/skyeye_sensor_cli.py @@ -5,6 +5,7 @@ import json import sys +from argparse import Namespace from datetime import datetime from pathlib import Path from typing import Any @@ -45,7 +46,10 @@ def format_timestamp(value: Any) -> str: return datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S") -def resolve_auth_file() -> Path | None: +def resolve_auth_file(auth_state: str | None = None) -> Path | None: + if auth_state: + path = Path(auth_state).expanduser() + return path if path.exists() else None if AUTH_STATE_FILE.exists(): return AUTH_STATE_FILE if COOKIE_FILE.exists(): @@ -53,6 +57,38 @@ def resolve_auth_file() -> Path | None: return None +def ensure_browser_auth_state( + *, + base_url: str, + auth_state: str | None, + username: str | None, + password: str | None, + captcha_code: str | None, + force_refresh: bool, + debug: bool, +) -> tuple[bool, str]: + from skyeye_sensor_auth import ensure_auth_state, _runtime_config + + args = Namespace( + base_url=base_url, + username=username, + password=password, + auth_state=auth_state or str(AUTH_STATE_FILE), + login_path="", + captcha_path="", + captcha_code=captcha_code or "", + timeout=25, + max_captcha_retry=5, + no_ocr=False, + save_credentials=True, + ) + cfg = _runtime_config(args) + result = ensure_auth_state(cfg, captcha_code=captcha_code or "", force_refresh=force_refresh) + if debug: + print_info(json.dumps(result, ensure_ascii=False)) + return bool(result.get("success") or result.get("valid")), cfg.base_url + + def build_alarm_filters( hazard_level: str | None = None, threat_type: str | None = None, @@ -150,22 +186,60 @@ def pick_first(item: dict, *keys: str, default: str = "-") -> str: @click.group() @click.option("--token", "-t", help="CSRF Token") @click.option("--base-url", "-u", help="Base URL") +@click.option("--auth-state", help=f"Auth-state path, default: {AUTH_STATE_FILE}") +@click.option("--username", help="Username for browser/CDP login") +@click.option("--password", help="Password for browser/CDP login") +@click.option("--auto-login", is_flag=True, help="Use saved credentials to refresh browser auth-state") +@click.option("--refresh-auth", is_flag=True, help="Force browser/CDP login before running the command") +@click.option("--captcha-code", help="Captcha code; OCR is used when omitted") @click.option("--debug", "-d", is_flag=True, help="Debug mode") @click.pass_context -def cli(ctx: click.Context, token: str | None, base_url: str | None, debug: bool) -> None: +def cli( + ctx: click.Context, + token: str | None, + base_url: str | None, + auth_state: str | None, + username: str | None, + password: str | None, + auto_login: bool, + refresh_auth: bool, + captcha_code: str | None, + debug: bool, +) -> None: """SkyEye Sensor CLI.""" ctx.ensure_object(dict) actual_base_url = base_url or BASE_URL - auth_file = resolve_auth_file() actual_token = token or CSRF_TOKEN - if auth_file is None and not actual_token: - print_error("未提供认证信息。请提供 auth-state.json、cookie.json 或 --token") - sys.exit(1) + wants_browser_login = bool(username or password or auto_login or refresh_auth) + if wants_browser_login: + try: + ok, resolved_base_url = ensure_browser_auth_state( + base_url=actual_base_url, + auth_state=auth_state, + username=username, + password=password, + captcha_code=captcha_code, + force_refresh=refresh_auth, + debug=debug, + ) + actual_base_url = actual_base_url or resolved_base_url + if not ok: + print_error("SkyEye Sensor 自动登录失败,请改用浏览器手动登录后保存 auth-state。") + sys.exit(1) + except Exception as exc: + print_error(f"SkyEye Sensor 自动登录失败: {exc}") + sys.exit(1) + if not actual_base_url: print_error("未提供平台地址。请设置 SKYEYE_SENSOR_BASE_URL 或使用 --base-url。") sys.exit(1) + auth_file = resolve_auth_file(auth_state) + if auth_file is None and not actual_token: + print_error("未提供认证信息。请提供 auth-state.json、cookie.json 或 --token") + sys.exit(1) + ctx.obj["client"] = SkyeyeSensorClient( base_url=actual_base_url, auth_file=auth_file, diff --git a/.flocks/plugins/skills/skyeye-use/SKILL.md b/.flocks/plugins/skills/skyeye-use/SKILL.md index 8a49c8976..66b9f644f 100644 --- a/.flocks/plugins/skills/skyeye-use/SKILL.md +++ b/.flocks/plugins/skills/skyeye-use/SKILL.md @@ -45,7 +45,10 @@ API 参数和适用场景见 [references/api-reference.md](references/api-refere ## 浏览器模式使用指南 - ⚠️ 如果 SkyEye 域名不清楚,请先询问用户,不要擅自填写域名。 -- ⚠️ 用 --headed 打开浏览器,人工完成登录 +- 打开 SkyEye 页面或执行 CLI 前,先按登录态流程处理,不要一开始就要求用户提供账密。 +- 如果用户给的是完整登录页 URL,自动登录时保留其中的路径,例如 `/skyeye/home/login`,不要退回默认 `/login`。 +- SkyEye 自动登录使用图片验证码,脚本会优先从登录页验证码图片元素动态获取图片并用 OCR 库识别。 +- 当没有可用登录态时,引导用户选择:提供账密自动登录并保存,或不提供账密走手动登录;不再提供临时账密选项。 只要进入浏览器模式,就请阅读并按照 browser-workflow 操作。 diff --git a/.flocks/plugins/skills/skyeye-use/references/browser-workflow.md b/.flocks/plugins/skills/skyeye-use/references/browser-workflow.md index d83e731c8..6ec3e5283 100644 --- a/.flocks/plugins/skills/skyeye-use/references/browser-workflow.md +++ b/.flocks/plugins/skills/skyeye-use/references/browser-workflow.md @@ -7,36 +7,71 @@ ## 零、登录认证 State 文件路径:`~/.flocks/browser/skyeye/auth-state.json`(固定,全局唯一)。 +自动登录配置路径:`~/.flocks/browser/skyeye/auth-config.json`(只保存 base_url、state 路径和 secret 引用;密码写入 Flocks secret)。 + +执行 `scripts/skyeye_auth.py` 或 `scripts/skyeye_cli.py` 前,必须先进入 `skill_load` 输出的 Base directory,或把命令工具的 `workdir` 设置为该目录。 +如果用户给的是完整登录页 URL,自动登录时必须保留路径;例如 base URL 为 `https://`,login path 为 `/skyeye/home/login`。`skyeye_auth.py` 会从完整 URL 自动推导,`skyeye_cli.py` 可显式传 `--login-path /skyeye/home/login`。 ### 首次登录 / Session 过期重新登录 +打开 SkyEye 页面或执行 CLI 前,必须按下面顺序处理登录态,不要一开始就要求用户提供账密: + +1. 先检查 `auth-state.json` 是否可用。 +2. 如果 state 可用,直接复用登录态继续打开页面或执行 CLI 获取数据。 +3. 如果 state 不存在或失效,但本地已有可用于自动登录的配置,先在 skill Base directory 中执行自动刷新:`uv run python scripts/skyeye_auth.py ensure`。 +4. 如果没有可用 state,也没有保存账密配置,再引导用户选择: + - 提供 SkyEye 地址、用户名、密码,自动登录并保存账密配置,后续 state 失效时可直接自动刷新; + - 不提供账密,走浏览器手动登录流程。 +5. 只有自动登录失败、验证码 OCR / MFA / 登录页 DOM 变化 / 登录成功检测失败,或用户拒绝提供账密时,才回退手动登录。 + +只读查看本地是否具备自动刷新条件(需在 skill Base directory 中执行): + ```bash -flocks browser --doctor +uv run python scripts/skyeye_auth.py status ``` -如果 `flocks browser --doctor` 提示浏览器已运行,但 daemon 或 active browser connection 不可用,必须直接提示用户: +检查 state 是否可用(需在 skill Base directory 中执行): -```text -browser: not connected — 请确保 Chrome / Chromium / Edge 已打开,然后访问对应浏览器的 inspect 页面(例如 chrome://inspect/#remote-debugging 或 edge://inspect/#remote-debugging)并勾选 Allow remote debugging +```bash +uv run python scripts/skyeye_auth.py --base-url https:// validate ``` -然后等待用户进一步指示,不要直接操作。 +如果 `validate` 返回 `valid: true`,继续后续页面操作或 CLI 查询;如果返回 `auth_state_not_found`、`auth_state_expired_or_login_page` 或 `auth_state_load_failed`,再看 `status` 中 `can_auto_refresh` 是否为 `true`。 + +有已保存账密配置时自动刷新(需在 skill Base directory 中执行): + +```bash +uv run python scripts/skyeye_auth.py ensure +``` + +自动刷新成功后继续执行原页面或 CLI 操作;失败才进入下方用户选择流程。 + +```bash +flocks browser --doctor +``` -当用户确认已开启 remote debugging 后: +如果 `flocks browser --doctor` 提示浏览器已运行,但 daemon 或 active browser connection 不可用,先执行 `flocks browser --setup` 触发 attach,不要先要求用户重复勾选 Allow remote debugging。 1. 执行 `flocks browser --setup` 触发交互式 attach,不要用短超时包装该命令。 2. 再运行 `flocks browser --doctor` 做只读确认。 -3. 如果还失败,先执行 `flocks browser --reload` 清理旧 daemon,再重新执行 `flocks browser --setup`,避免因为残留 daemon 造成干扰。 -4. 只有随后 `--doctor` 通过后,才继续后面的登录或页面操作。 +3. 如果 `--setup` 或 `--doctor` 明确提示 remote debugging 未启用,再提示用户打开 inspect 页面(例如 `chrome://inspect/#remote-debugging` 或 `edge://inspect/#remote-debugging`)并勾选 Allow remote debugging。 +4. 如果还失败,先执行 `flocks browser --reload` 清理旧 daemon,再重新执行 `flocks browser --setup`,避免因为残留 daemon 造成干扰。 +5. 只有随后 `--doctor` 通过后,才继续后面的登录或页面操作。 -打开登录页并等待用户手动完成登录: +当必须询问用户时,说明两种选择并尊重用户偏好: + +- 提供账密并保存:调用 `scripts/skyeye_auth.py`,通过 browser daemon / CDP 驱动真实登录页自动登录。脚本会优先从登录页验证码图片元素动态获取图片、OCR 识别、填入页面、保存 state,并把用户名/密码写入 Flocks secret,把基础配置保存到 `~/.flocks/browser/skyeye/auth-config.json`。后续 `auth-state.json` 失效时,可在 skill Base directory 中直接执行 `uv run python scripts/skyeye_auth.py ensure`。 +- 不提供账密:沿用浏览器手动登录流程。打开登录页后由用户手动完成登录(含短信验证码 / MFA 等),登录成功后保存 state。 + +手动登录时,打开登录页并等待用户完成登录: ```bash flocks browser -c ' tid = new_tab("https:///login", activate=True) wait_for_load() print(tid) -print(page_info()) +import json +print(json.dumps(page_info(), ensure_ascii=True)) ' ``` @@ -46,6 +81,27 @@ print(page_info()) flocks browser state save ~/.flocks/browser/skyeye/auth-state.json ``` +提供账密并保存时,执行自动登录: + +```bash +uv run python scripts/skyeye_auth.py --base-url https:// ensure --username '' --password '' +``` + +如果登录页不是默认路径,显式传入登录路径: + +```bash +uv run python scripts/skyeye_auth.py --base-url https:// --login-path /skyeye/home/login ensure --username '' --password '' +``` + +也可以在查询命令前直接带账密或使用已保存账密刷新登录态: + +```bash +uv run python scripts/skyeye_cli.py --base-url https:// --username '' --password '' alarm list +uv run python scripts/skyeye_cli.py --base-url https:// --login-path /skyeye/home/login --auto-login alarm list +``` + +如果验证码 OCR、MFA、登录页 DOM 变化或登录成功检测失败,回退到上面的手动登录流程。 + ### CLI 认证失败时的恢复流程 当 CLI 出现以下任一情况,优先判定为认证问题(**不要立刻要求用户重新登录**): @@ -62,7 +118,8 @@ flocks browser state load ~/.flocks/browser/skyeye/auth-state.json --url "https: # 2) 读取当前页面状态 flocks browser -c ' -print(page_info()) +import json +print(json.dumps(page_info(), ensure_ascii=True)) ' ``` @@ -82,8 +139,9 @@ CLI 在 skill 内: 执行命令时: -1. 优先使用 `uv run python scripts/skyeye_cli.py ...` -2. 认证优先使用浏览器导出的 `auth-state.json` +1. 必须先进入 `skill_load` 输出的 Base directory,或把命令工具的 `workdir` 设置为该目录。 +2. 优先使用 `uv run python scripts/skyeye_cli.py ...` +3. 认证优先使用浏览器导出的 `auth-state.json` 认证环境变量: @@ -95,7 +153,7 @@ CLI 在 skill 内: - 用户说"告警列表 / 告警统计"时,用 `alarm list` 或 `alarm count` - 用户说"日志检索 / 日志分析 / 日志统计 / Lucene / 专家模式 / 字段状态"时,用 `log search` -- 如果用户要传感器侧告警,不要用这个 skill,改用 `skyeye-sensor-data-fetch` +- 如果用户要传感器侧告警,不要用这个 skill,改用 `skyeye-sensor-use` ### 常用命令 @@ -114,13 +172,10 @@ uv run python scripts/skyeye_cli.py alarm list --days 1 | jq '.data.items[].thre uv run python scripts/skyeye_cli.py alarm list --days 1 --table ``` -带认证的完整单行格式(无需提前 export,适合直接执行): +带认证的完整单行格式(无需提前 export,适合 Windows/macOS/Linux 直接执行): ```bash -SKYEYE_BASE_URL=https:// \ -SKYEYE_AUTH_STATE=~/.flocks/browser/skyeye/auth-state.json \ -uv run python scripts/skyeye_cli.py \ - alarm list --days 1 +uv run python scripts/skyeye_cli.py --base-url https:// alarm list --days 1 ``` 详细使用方法请阅读 [cli-reference](cli-reference.md) @@ -147,7 +202,8 @@ uv run python scripts/skyeye_cli.py \ flocks browser -c ' tid = new_tab("https:///", activate=True) wait_for_load() -print(page_info()) +import json +print(json.dumps(page_info(), ensure_ascii=True)) print(js("document.body.innerText.slice(0, 2000)")) ' ``` @@ -157,7 +213,8 @@ print(js("document.body.innerText.slice(0, 2000)")) ```bash flocks browser -c ' attach_tab("") -print(page_info()) +import json +print(json.dumps(page_info(), ensure_ascii=True)) ' ``` @@ -165,12 +222,12 @@ print(page_info()) - 这个 skill 面向 SkyEye 分析平台,不是传感器侧接口 - CLI 只保留 `alarm list`、`alarm count` 和 `log search` -- 传感器侧告警请使用 `skyeye-sensor-data-fetch` +- 传感器侧告警请使用 `skyeye-sensor-use` ## 重要提醒 -- **Session 管理**:详见[零、登录认证](#零、登录认证)。任务开始前先确认 `auth-state.json` 存在;CLI 认证失败时先走恢复流程,不要立刻要求用户重新登录。 +- **Session 管理**:详见[零、登录认证](#零登录认证)。任务开始前先用 `skyeye_auth.py validate` 确认 `auth-state.json` 可用;CLI 认证失败时先走恢复流程,不要立刻要求用户重新登录。 - **禁止连续失败循环**:同一命令最多重试 2 次;认证恢复流程只走一次,仍失败则提示用户手动重新登录。 - **以下错误属于需要用户干预的基础设施问题,立即停止所有重试,直接告知用户处理**: - `ERR_CERT_AUTHORITY_INVALID`:站点证书不被本机信任,请求用户处理。 - - `ERR_NAME_NOT_RESOLVED`:域名无法解析,告知用户确认域名或检查 DNS / hosts 配置。 \ No newline at end of file + - `ERR_NAME_NOT_RESOLVED`:域名无法解析,告知用户确认域名或检查 DNS / hosts 配置。 diff --git a/.flocks/plugins/skills/skyeye-use/references/cli-reference.md b/.flocks/plugins/skills/skyeye-use/references/cli-reference.md index f3f4d7ab5..16281c33d 100644 --- a/.flocks/plugins/skills/skyeye-use/references/cli-reference.md +++ b/.flocks/plugins/skills/skyeye-use/references/cli-reference.md @@ -1,5 +1,7 @@ # SkyEye CLI 查询总览 +执行前必须先进入 `skill_load` 输出的 Base directory,或把命令工具的 `workdir` 设置为该目录。CLI 默认复用 `~/.flocks/browser/skyeye/auth-state.json`,也支持 `--auto-login`、`--username/--password` 触发 OCR 图片验证码自动登录;提供账密后会自动保存到 Flocks secret。若登录页不是默认路径,可传 `--login-path /skyeye/home/login`。 + ## 查询策略 ### 告警类请求 @@ -61,14 +63,14 @@ uv run python scripts/skyeye_cli.py log search \ ```bash # 告警明细 -uv run python skyeye_cli.py alarm list --days 1 --filter attack_sip=1.1.1.1 +uv run python scripts/skyeye_cli.py alarm list --days 1 --filter attack_sip=1.1.1.1 # 告警计数 -uv run python skyeye_cli.py alarm count --days 1 --filter threat_name=暴力破解 +uv run python scripts/skyeye_cli.py alarm count --days 1 --filter threat_name=暴力破解 # 日志搜索(仅支持高级模式 Lucene 语法) -uv run python skyeye_cli.py log search 'alarm_sip:(10.0.0.1)' --days 1 -uv run python skyeye_cli.py log search 'attack_result:(攻击成功) AND threat_type:(WEB攻击)' --days 7 +uv run python scripts/skyeye_cli.py log search 'alarm_sip:(10.0.0.1)' --days 1 +uv run python scripts/skyeye_cli.py log search 'attack_result:(攻击成功) AND threat_type:(WEB攻击)' --days 7 ``` ## 详细参考 diff --git a/.flocks/plugins/skills/skyeye-use/scripts/requirements.txt b/.flocks/plugins/skills/skyeye-use/scripts/requirements.txt index d44cde4fa..5576e8c57 100644 --- a/.flocks/plugins/skills/skyeye-use/scripts/requirements.txt +++ b/.flocks/plugins/skills/skyeye-use/scripts/requirements.txt @@ -1,3 +1,4 @@ click>=8.0.0 requests>=2.28.0 rich>=13.0.0 +ddddocr>=1.6.1 diff --git a/.flocks/plugins/skills/skyeye-use/scripts/skyeye_auth.py b/.flocks/plugins/skills/skyeye-use/scripts/skyeye_auth.py new file mode 100644 index 000000000..3b2fc6baa --- /dev/null +++ b/.flocks/plugins/skills/skyeye-use/scripts/skyeye_auth.py @@ -0,0 +1,867 @@ +#!/usr/bin/env python3 +"""SkyEye browser auth-state helper. + +This script follows the skill's existing browser workflow and only automates +the manual login step when credentials are available: + +1. load and validate the saved browser auth-state; +2. if it is missing or expired, open the real login page with CDP; +3. wait for the async-rendered login form; +4. fetch and OCR the captcha in the browser session; +5. fill the form, submit it, and save a refreshed auth-state. +""" + +from __future__ import annotations + +import argparse +import base64 +import json +import os +import random +import sys +import time +from pathlib import Path +from typing import Any, Optional +from urllib.parse import urljoin, urlparse + +from flocks.browser import helpers + +AUTH_DIR = Path.home() / ".flocks" / "browser" / "skyeye" +DEFAULT_AUTH_STATE = AUTH_DIR / "auth-state.json" +AUTH_CONFIG = AUTH_DIR / "auth-config.json" +USERNAME_SECRET_ID = "skyeye_username" +PASSWORD_SECRET_ID = "skyeye_password" +DEFAULT_LOGIN_PATH = "/skyeye/home/login" +DEFAULT_CAPTCHA_PATH = "/skyeye/v1/admin/code" +DEFAULT_TIMEOUT = 25 +ESSENTIAL_LOCAL_STORAGE_KEYS = {"csrf_token", "csrfToken", "system_type"} +MAX_LOCAL_STORAGE_VALUE_BYTES = 4096 + + +def _get_secret_manager(): + from flocks.security import get_secret_manager + + return get_secret_manager() + + +def _resolve_ref(value: Any) -> Optional[str]: + if value is None: + return None + if not isinstance(value, str): + return str(value) + if value.startswith("{secret:") and value.endswith("}"): + return _get_secret_manager().get(value[len("{secret:") : -1]) + if value.startswith("{env:") and value.endswith("}"): + return os.getenv(value[len("{env:") : -1]) + return value + + +def _split_base_url_and_path(value: str) -> tuple[str, str]: + candidate = value.strip() + if not candidate: + raise ValueError("SkyEye base_url is required.") + if "://" not in candidate: + candidate = f"https://{candidate}" + parsed = urlparse(candidate) + if not parsed.hostname: + raise ValueError(f"Invalid SkyEye base_url: {value!r}") + host = parsed.hostname + if ":" in host and not host.startswith("["): + host = f"[{host}]" + port = f":{parsed.port}" if parsed.port else "" + path = parsed.path or "" + return f"{parsed.scheme}://{host}{port}".rstrip("/"), path + + +def _normalise_base_url(value: str) -> str: + base_url, _path = _split_base_url_and_path(value) + return base_url + + +def _login_path_from_url(value: str) -> str: + _base_url, path = _split_base_url_and_path(value) + return path if path and path != "/" else "" + + +def _read_config() -> dict[str, Any]: + if not AUTH_CONFIG.exists(): + return {} + try: + data = json.loads(AUTH_CONFIG.read_text(encoding="utf-8")) + except json.JSONDecodeError: + return {} + return data if isinstance(data, dict) else {} + + +def _write_config(data: dict[str, Any]) -> None: + AUTH_DIR.mkdir(parents=True, exist_ok=True) + AUTH_CONFIG.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + + +def _persist_inputs(args: argparse.Namespace, config: dict[str, Any]) -> dict[str, Any]: + if args.base_url: + config["base_url"] = _normalise_base_url(args.base_url) + inferred_login_path = _login_path_from_url(args.base_url) + if inferred_login_path and not args.login_path: + config["login_path"] = inferred_login_path + if args.auth_state: + config["auth_state_path"] = str(Path(args.auth_state).expanduser()) + if args.login_path: + config["login_path"] = args.login_path + if args.captcha_path: + config["captcha_path"] = args.captcha_path + + if args.base_url or args.auth_state or args.login_path or args.captcha_path: + _write_config(config) + + if args.save_credentials: + secrets = _get_secret_manager() + if args.username: + secrets.set(USERNAME_SECRET_ID, args.username) + config["username"] = f"{{secret:{USERNAME_SECRET_ID}}}" + if args.password: + secrets.set(PASSWORD_SECRET_ID, args.password) + config["password"] = f"{{secret:{PASSWORD_SECRET_ID}}}" + if args.username or args.password: + _write_config(config) + return config + + +def saved_auto_login_status() -> dict[str, Any]: + """Return non-sensitive information about saved auto-login inputs.""" + config = _read_config() + username = _resolve_ref(config.get("username")) or _get_secret_manager().get(USERNAME_SECRET_ID) + password = _resolve_ref(config.get("password")) or _get_secret_manager().get(PASSWORD_SECRET_ID) + base_url = _resolve_ref(config.get("base_url")) or os.getenv("SKYEYE_BASE_URL") + auth_state_path = Path( + _resolve_ref(config.get("auth_state_path")) + or os.getenv("SKYEYE_AUTH_STATE") + or DEFAULT_AUTH_STATE + ).expanduser() + has_username = bool(str(username or "").strip()) + has_password = bool(str(password or "").strip()) + has_base_url = bool(str(base_url or "").strip()) + return { + "auth_state_path": str(auth_state_path), + "auth_state_exists": auth_state_path.exists(), + "auth_config_path": str(AUTH_CONFIG), + "auth_config_exists": AUTH_CONFIG.exists(), + "has_base_url": has_base_url, + "has_saved_username": has_username, + "has_saved_password": has_password, + "can_auto_refresh": has_base_url and has_username and has_password, + } + + +class RuntimeConfig: + def __init__( + self, + *, + base_url: str, + auth_state_path: Path, + username: str, + password: str, + login_path: str, + captcha_path: str, + timeout: int, + auto_ocr: bool, + max_captcha_retry: int, + ) -> None: + self.base_url = base_url + self.auth_state_path = auth_state_path + self.username = username + self.password = password + self.login_path = login_path + self.captcha_path = captcha_path + self.timeout = timeout + self.auto_ocr = auto_ocr + self.max_captcha_retry = max_captcha_retry + + +def _runtime_config(args: argparse.Namespace) -> RuntimeConfig: + config = _persist_inputs(args, _read_config()) + secrets = _get_secret_manager() + + base_url = _normalise_base_url( + args.base_url + or _resolve_ref(config.get("base_url")) + or os.getenv("SKYEYE_BASE_URL") + or "" + ) + auth_state_path = Path( + args.auth_state + or _resolve_ref(config.get("auth_state_path")) + or os.getenv("SKYEYE_AUTH_STATE") + or DEFAULT_AUTH_STATE + ).expanduser() + username = ( + args.username + or _resolve_ref(config.get("username")) + or secrets.get(USERNAME_SECRET_ID) + or os.getenv("SKYEYE_USERNAME") + or "" + ).strip() + password = ( + args.password + or _resolve_ref(config.get("password")) + or secrets.get(PASSWORD_SECRET_ID) + or os.getenv("SKYEYE_PASSWORD") + or "" + ).strip() + + return RuntimeConfig( + base_url=base_url, + auth_state_path=auth_state_path, + username=username, + password=password, + login_path=args.login_path or str(config.get("login_path") or DEFAULT_LOGIN_PATH), + captcha_path=args.captcha_path or str(config.get("captcha_path") or DEFAULT_CAPTCHA_PATH), + timeout=max(5, int(args.timeout or DEFAULT_TIMEOUT)), + auto_ocr=not args.no_ocr, + max_captcha_retry=max(1, int(args.max_captcha_retry or 5)), + ) + + +def _url(cfg: RuntimeConfig, path: str) -> str: + return urljoin(cfg.base_url + "/", path.lstrip("/")) + + +def _login_url(cfg: RuntimeConfig) -> str: + return _url(cfg, cfg.login_path) + + +def _storage_entry_keep(entry: Any) -> bool: + if not isinstance(entry, dict): + return False + name = str(entry.get("name") or "") + value = str(entry.get("value") or "") + if name in ESSENTIAL_LOCAL_STORAGE_KEYS: + return True + lowered = name.lower() + if "token" in lowered or "csrf" in lowered: + return len(value.encode("utf-8", errors="ignore")) <= MAX_LOCAL_STORAGE_VALUE_BYTES + return False + + +def _filter_auth_state_file(path: Path) -> dict[str, Any]: + try: + state = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return {"filtered": False} + if not isinstance(state, dict): + return {"filtered": False} + origins = state.get("origins") + if not isinstance(origins, list): + return {"filtered": False} + + before = 0 + after = 0 + for origin in origins: + if not isinstance(origin, dict): + continue + entries = origin.get("localStorage") + if not isinstance(entries, list): + continue + before += len(entries) + kept = [entry for entry in entries if _storage_entry_keep(entry)] + after += len(kept) + origin["localStorage"] = kept + + path.write_text(json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8") + return {"filtered": True, "localStorageItemsBefore": before, "localStorageItemsAfter": after} + + +def _save_filtered_state(cfg: RuntimeConfig) -> dict[str, Any]: + saved = helpers.save_state(cfg.auth_state_path, url=cfg.base_url) + return {**saved, "filter": _filter_auth_state_file(cfg.auth_state_path)} + + +def _auth_state_has_auth_indicators(path: Path) -> dict[str, Any]: + try: + state = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + return {"valid": False, "reason": "auth_state_json_invalid", "error": str(exc)} + cookies = state.get("cookies") if isinstance(state, dict) else [] + origins = state.get("origins") if isinstance(state, dict) else [] + cookie_names = { + str(cookie.get("name") or "") + for cookie in cookies or [] + if isinstance(cookie, dict) and cookie.get("name") + } + local_storage_names = set() + for origin in origins or []: + if not isinstance(origin, dict): + continue + for entry in origin.get("localStorage") or []: + if isinstance(entry, dict) and entry.get("name"): + local_storage_names.add(str(entry.get("name"))) + has_cookie = bool(cookie_names & {"csrfToken", "sessionid", "JSESSIONID", "PHPSESSID"}) + has_token = bool(local_storage_names & {"csrf_token", "csrfToken"}) + return { + "valid": has_cookie or has_token, + "reason": "auth_state_has_cookie_or_csrf" if (has_cookie or has_token) else "auth_state_missing_auth_indicators", + "cookies": len(cookies or []), + "origins": len(origins or []), + "cookieNames": sorted(cookie_names), + "localStorageAuthKeys": sorted(local_storage_names & {"csrf_token", "csrfToken", "system_type"}), + } + + +def _open_page(url: str) -> None: + try: + helpers.open_or_attach_tab(url) + except Exception: + helpers.goto_url(url) + helpers.wait_for_load(timeout=15) + + +def _page_text() -> str: + try: + return str(helpers.js("document.body ? document.body.innerText : ''") or "") + except Exception: + return "" + + +def _has_auth_cookie(cfg: RuntimeConfig) -> bool: + try: + cookies = helpers.cdp("Network.getCookies", urls=[cfg.base_url]).get("cookies", []) + except Exception: + return False + if not isinstance(cookies, list): + return False + names = {str(cookie.get("name") or "") for cookie in cookies if isinstance(cookie, dict)} + return bool(names & {"csrfToken", "sessionid", "JSESSIONID", "PHPSESSID"}) + + +def _looks_like_login_page(url: str, text: str) -> bool: + haystack = f"{url}\n{text}".lower() + return any(marker in haystack for marker in ("login", "请输入用户名", "请输入密码", "请输入验证码", "验证码")) + + +def _is_logged_in(cfg: RuntimeConfig) -> bool: + info = helpers.page_info() + current_url = str(info.get("url") or "") + if _looks_like_login_page(current_url, _page_text()): + return False + return _has_auth_cookie(cfg) or cfg.base_url.rstrip("/") in current_url + + +def _is_browser_daemon_error(exc: Exception) -> bool: + message = str(exc) + return "bu.port" in message or "daemon" in message.lower() + + +def _browser_daemon_result(exc: Exception, auth_state_path: Path) -> dict[str, Any]: + return { + "success": False, + "valid": False, + "status": "browser_daemon_not_ready", + "reason": "browser_daemon_not_ready", + "error": str(exc), + "next_action": "run `flocks browser --setup`, then `flocks browser --doctor`, then retry", + "auth_state_path": str(auth_state_path), + } + + +def validate_auth_state(cfg: RuntimeConfig) -> dict[str, Any]: + if not cfg.auth_state_path.exists(): + return { + "valid": False, + "reason": "auth_state_not_found", + "auth_state_path": str(cfg.auth_state_path), + } + try: + loaded = helpers.load_state(cfg.auth_state_path, url=cfg.base_url) + helpers.wait_for_load(timeout=15) + if _is_logged_in(cfg): + return { + "valid": True, + "reason": "browser_state_loaded", + "auth_state_path": str(cfg.auth_state_path), + "loaded": loaded, + } + return { + "valid": False, + "reason": "auth_state_expired_or_login_page", + "auth_state_path": str(cfg.auth_state_path), + "loaded": loaded, + } + except Exception as exc: + if _is_browser_daemon_error(exc): + result = _browser_daemon_result(exc, cfg.auth_state_path) + result["reason"] = "auth_state_load_failed_browser_daemon_not_ready" + return result + lightweight = _auth_state_has_auth_indicators(cfg.auth_state_path) + if lightweight.get("valid"): + return { + "valid": True, + "reason": "auth_state_lightweight_valid_after_browser_load_failed", + "error": str(exc), + "auth_state_path": str(cfg.auth_state_path), + "lightweight": lightweight, + } + return { + "valid": False, + "reason": "auth_state_load_failed", + "error": str(exc), + "auth_state_path": str(cfg.auth_state_path), + } + + +def _login_dom_summary() -> Any: + script = """(() => { + return Array.from(document.querySelectorAll("input,button,a,img,iframe")) + .slice(0, 80) + .map((el) => ({ + tag: el.tagName, + id: el.id || "", + name: el.getAttribute("name") || "", + type: el.getAttribute("type") || "", + placeholder: el.getAttribute("placeholder") || "", + text: (el.innerText || el.value || el.getAttribute("alt") || "").trim().slice(0, 80), + className: String(el.className || ""), + src: el.getAttribute("src") || "", + href: el.getAttribute("href") || "" + })); +})()""" + try: + return helpers.js(script) + except Exception as exc: + return {"error": str(exc)} + + +def _handle_pre_login_prompts() -> dict[str, Any]: + script = """(() => { + const visible = (el) => { + if (!el) return false; + const style = window.getComputedStyle(el); + const rect = el.getBoundingClientRect(); + return style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0; + }; + const buttons = Array.from(document.querySelectorAll("button")).filter(visible); + const clicked = []; + for (const button of buttons) { + const text = (button.innerText || button.textContent || "").replace(/\\s+/g, ""); + if (/确认并继续|同意|我同意|继续|关闭/.test(text)) { + button.click(); + clicked.push(text); + break; + } + } + const checkboxes = Array.from(document.querySelectorAll(".authorize-check input[type='checkbox'], .protocol input[type='checkbox'], .q-checkbox__original")) + .filter(visible); + for (const checkbox of checkboxes) { + if (!checkbox.checked) { + checkbox.click(); + clicked.push("checkbox"); + } + } + return {clicked}; +})()""" + try: + result = helpers.js(script) + return result if isinstance(result, dict) else {"result": result} + except Exception as exc: + return {"error": str(exc)} + + +def _wait_for_login_form_ready(cfg: RuntimeConfig) -> dict[str, bool]: + script = """(() => { + const visible = (el) => { + if (!el) return false; + const style = window.getComputedStyle(el); + const rect = el.getBoundingClientRect(); + return style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0; + }; + const pick = (selectors) => selectors + .map((selector) => Array.from(document.querySelectorAll(selector)).find(visible)) + .find(Boolean); + const textInputs = Array.from(document.querySelectorAll("input")) + .filter((el) => visible(el) && !["hidden", "password", "checkbox", "radio", "submit", "button"].includes((el.type || "").toLowerCase())); + const nonCaptchaTextInputs = textInputs.filter((el) => !/验证码|captcha|code|vcode/i.test([ + el.placeholder || "", + el.name || "", + el.id || "", + String(el.className || ""), + el.closest("div")?.className || "" + ].join(" "))); + const username = pick([ + ".sys-account input", + ".account input", + "input[placeholder*='用户名']", + "input[placeholder*='账号']", + "input[name*='user' i]", + "input[id*='user' i]" + ]) || nonCaptchaTextInputs[0]; + const password = pick([ + ".sys-password input", + ".password input[type='password']", + "input[placeholder*='密码']", + "input[type='password']" + ]); + const captcha = pick([ + ".sys-code input", + ".authority-code input", + ".code-input input", + "input[placeholder*='验证码']", + "input[name*='captcha' i]", + "input[id*='captcha' i]", + "input[name*='code' i]", + "input[id*='code' i]" + ]) || textInputs.find((el) => /验证码|captcha|code|vcode/i.test([ + el.placeholder || "", + el.name || "", + el.id || "", + String(el.className || ""), + el.closest("div")?.className || "" + ].join(" "))); + const textButton = Array.from(document.querySelectorAll("button")) + .filter(visible) + .some((el) => /登\\s*录|login/i.test((el.innerText || "").trim())); + const submit = pick([ + "button.login-button", + ".login-button", + ".login-form button.q-button--primary", + "button[type='submit']" + ]); + return { + username: Boolean(username), + password: Boolean(password), + captcha: Boolean(captcha), + submit: Boolean(submit) || textButton + }; +})()""" + deadline = time.time() + cfg.timeout + last_state: dict[str, bool] = {} + while time.time() < deadline: + state = helpers.js(script) + last_state = state if isinstance(state, dict) else {} + if last_state.get("username") and last_state.get("password") and last_state.get("captcha") and last_state.get("submit"): + return {key: bool(value) for key, value in last_state.items()} + time.sleep(0.5) + raise RuntimeError( + "SkyEye login form was not rendered before timeout: " + + json.dumps({"state": last_state, "dom": _login_dom_summary()}, ensure_ascii=False) + ) + + +def _captcha_image_data_url_from_dom() -> str: + script = """(async () => { + const srcAttrs = ["src", "currentSrc", "data-src", "data-url", "data-original"]; + const images = Array.from(document.querySelectorAll("img")); + const candidates = images + .map((img) => { + const values = srcAttrs + .map((attr) => attr === "currentSrc" ? img.currentSrc : img.getAttribute(attr)) + .filter(Boolean); + const hint = [ + img.id || "", + String(img.className || ""), + img.alt || "", + img.title || "", + values.join(" ") + ].join(" ").toLowerCase(); + return {values, hint}; + }) + .filter((item) => item.values.length) + .sort((a, b) => { + const score = (item) => /captcha|verify|vcode|code|验证码/.test(item.hint) ? 0 : 1; + return score(a) - score(b); + }); + + for (const item of candidates) { + for (const raw of item.values) { + const url = new URL(raw, window.location.href).href; + if (url.startsWith("data:image/")) { + return url; + } + try { + const response = await fetch(url, {credentials: "include", cache: "no-store"}); + if (!response.ok) continue; + const blob = await response.blob(); + if (!String(blob.type || "").startsWith("image/")) continue; + return await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onerror = () => reject(reader.error || new Error("captcha image read failed")); + reader.onload = () => resolve(String(reader.result)); + reader.readAsDataURL(blob); + }); + } catch (_err) { + } + } + } + return ""; +})()""" + return str(helpers.js(script) or "") + + +def _captcha_image_from_browser(cfg: RuntimeConfig) -> bytes: + data_url = _captcha_image_data_url_from_dom() + if not data_url: + captcha_url = _url(cfg, f"{cfg.captcha_path}?r={random.random()}") + script = f"""(async () => {{ + const response = await fetch({json.dumps(captcha_url)}, {{ + credentials: "include", + cache: "no-store" + }}); + if (!response.ok) {{ + throw new Error("captcha request failed: " + response.status); + }} + const blob = await response.blob(); + return await new Promise((resolve, reject) => {{ + const reader = new FileReader(); + reader.onerror = () => reject(reader.error || new Error("captcha read failed")); + reader.onload = () => resolve(String(reader.result)); + reader.readAsDataURL(blob); + }}); +}})()""" + data_url = str(helpers.js(script) or "") + if "," not in data_url: + raise RuntimeError("SkyEye captcha fetch did not return a data URL.") + return base64.b64decode(data_url.split(",", 1)[1]) + + +def _ocr_code(image_bytes: bytes) -> str: + try: + import ddddocr + except ImportError as exc: + raise RuntimeError("ddddocr is required for automatic captcha recognition.") from exc + return str(ddddocr.DdddOcr(show_ad=False).classification(image_bytes)).strip()[:4] + + +def _fill_and_submit(cfg: RuntimeConfig, code: str) -> dict[str, Any]: + payload = { + "username": cfg.username, + "password": cfg.password, + "code": code, + } + script = f"""(() => {{ + const cfg = {json.dumps(payload, ensure_ascii=False)}; + const visible = (el) => {{ + if (!el) return false; + const style = window.getComputedStyle(el); + const rect = el.getBoundingClientRect(); + return style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0; + }}; + const pick = (selectors) => selectors + .map((selector) => Array.from(document.querySelectorAll(selector)).find(visible)) + .find(Boolean); + const textInputs = Array.from(document.querySelectorAll("input")) + .filter((el) => visible(el) && !["hidden", "password", "checkbox", "radio", "submit", "button"].includes((el.type || "").toLowerCase())); + const nonCaptchaTextInputs = textInputs.filter((el) => !/验证码|captcha|code|vcode/i.test([ + el.placeholder || "", + el.name || "", + el.id || "", + String(el.className || ""), + el.closest("div")?.className || "" + ].join(" "))); + const username = pick([ + ".sys-account input", + ".account input", + "input[placeholder*='用户名']", + "input[placeholder*='账号']", + "input[name*='user' i]", + "input[id*='user' i]" + ]) || nonCaptchaTextInputs[0]; + const password = pick([ + ".sys-password input", + ".password input[type='password']", + "input[placeholder*='密码']", + "input[type='password']" + ]); + const captcha = pick([ + ".sys-code input", + ".authority-code input", + ".code-input input", + "input[placeholder*='验证码']", + "input[name*='captcha' i]", + "input[id*='captcha' i]", + "input[name*='code' i]", + "input[id*='code' i]" + ]) || textInputs.find((el) => /验证码|captcha|code|vcode/i.test([ + el.placeholder || "", + el.name || "", + el.id || "", + String(el.className || ""), + el.closest("div")?.className || "" + ].join(" "))); + const agreement = document.querySelector(".protocol input[type='checkbox']") + || document.querySelector(".authorize-check input[type='checkbox']") + || document.querySelector(".q-checkbox__original"); + const submit = pick([ + "button.login-button", + ".login-button", + ".login-form button.q-button--primary", + "button[type='submit']" + ]) + || Array.from(document.querySelectorAll("button")) + .filter(visible) + .find((el) => /登\\s*录|login/i.test((el.innerText || "").trim())); + + function setValue(el, value) {{ + if (!el) return false; + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value").set; + el.focus(); + setter.call(el, value); + el.dispatchEvent(new Event("input", {{bubbles: true}})); + el.dispatchEvent(new Event("change", {{bubbles: true}})); + el.dispatchEvent(new KeyboardEvent("keyup", {{bubbles: true}})); + return true; + }} + + const filled = {{ + username: setValue(username, cfg.username), + password: setValue(password, cfg.password), + captcha: setValue(captcha, cfg.code), + agreement: false + }}; + if (!filled.username || !filled.password || !filled.captcha) {{ + throw new Error("missing SkyEye login input"); + }} + if (agreement) {{ + if (!agreement.checked) agreement.click(); + agreement.dispatchEvent(new Event("input", {{bubbles: true}})); + agreement.dispatchEvent(new Event("change", {{bubbles: true}})); + filled.agreement = Boolean(agreement.checked); + }} + if (!submit) {{ + throw new Error("missing SkyEye login submit button"); + }} + submit.click(); + return filled; +}})()""" + result = helpers.js(script) + return result if isinstance(result, dict) else {"result": result} + + +def _wait_for_login_success(cfg: RuntimeConfig) -> bool: + deadline = time.time() + cfg.timeout + while time.time() < deadline: + try: + if _is_logged_in(cfg): + return True + except Exception: + pass + time.sleep(0.5) + return False + + +def refresh_auth_state(cfg: RuntimeConfig, captcha_code: str = "") -> dict[str, Any]: + missing = [] + if not cfg.username: + missing.append("username") + if not cfg.password: + missing.append("password") + if missing: + return { + "success": False, + "status": "manual_login_required", + "reason": "missing_credentials", + "missing": missing, + "auth_state_path": str(cfg.auth_state_path), + } + + try: + _open_page(_login_url(cfg)) + _handle_pre_login_prompts() + form_state = _wait_for_login_form_ready(cfg) + except Exception as exc: + if _is_browser_daemon_error(exc): + return _browser_daemon_result(exc, cfg.auth_state_path) + raise + last_error = "" + for attempt in range(1, cfg.max_captcha_retry + 1): + try: + code = captcha_code.strip() + if not code: + if not cfg.auto_ocr: + return { + "success": False, + "status": "manual_login_required", + "reason": "captcha_code_required", + "auth_state_path": str(cfg.auth_state_path), + } + code = _ocr_code(_captcha_image_from_browser(cfg)) + filled = _fill_and_submit(cfg, code) + if _wait_for_login_success(cfg): + saved = _save_filtered_state(cfg) + return { + "success": True, + "status": "browser_cdp_login_refreshed_auth_state", + "auth_state_path": str(cfg.auth_state_path), + "attempt": attempt, + "form": form_state, + "filled": {key: bool(value) for key, value in filled.items()}, + "saved": saved, + } + last_error = "login_success_check_timeout" + except Exception as exc: + last_error = str(exc) + + if captcha_code: + break + try: + helpers.goto_url(_login_url(cfg)) + helpers.wait_for_load(timeout=10) + _handle_pre_login_prompts() + form_state = _wait_for_login_form_ready(cfg) + except Exception: + pass + + return { + "success": False, + "status": "manual_login_required", + "reason": "browser_cdp_login_failed", + "last_error": last_error, + "auth_state_path": str(cfg.auth_state_path), + } + + +def ensure_auth_state(cfg: RuntimeConfig, captcha_code: str = "", force_refresh: bool = False) -> dict[str, Any]: + if not force_refresh: + validation = validate_auth_state(cfg) + if validation.get("valid"): + return {"success": True, "status": "auth_state_loaded", **validation} + return refresh_auth_state(cfg, captcha_code=captcha_code) + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Ensure SkyEye browser auth-state") + parser.add_argument("action", nargs="?", choices=["ensure", "validate", "refresh", "status"], default="ensure") + parser.add_argument("--base-url", help="SkyEye base URL, for example https://skyeye.example.com") + parser.add_argument("--username", help="Username for CDP-assisted login") + parser.add_argument("--password", help="Password for CDP-assisted login") + parser.add_argument("--auth-state", help=f"Auth-state path, default: {DEFAULT_AUTH_STATE}") + parser.add_argument("--login-path", default="", help=f"Login path, default: {DEFAULT_LOGIN_PATH}") + parser.add_argument("--captcha-path", default="", help="Captcha path override") + parser.add_argument("--captcha-code", default="", help="Manual captcha code; OCR is used when omitted") + parser.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT) + parser.add_argument("--max-captcha-retry", type=int, default=5) + parser.add_argument("--no-ocr", action="store_true", help="Do not OCR captcha; require --captcha-code") + parser.set_defaults(save_credentials=True) + return parser + + +def main() -> int: + args = _build_parser().parse_args() + try: + if args.action == "status": + result = {"success": True, "status": "saved_auto_login_status", **saved_auto_login_status()} + print(json.dumps(result, ensure_ascii=False, indent=2)) + return 0 + cfg = _runtime_config(args) + if args.action == "validate": + result = validate_auth_state(cfg) + elif args.action == "refresh": + result = refresh_auth_state(cfg, captcha_code=args.captcha_code) + else: + result = ensure_auth_state(cfg, captcha_code=args.captcha_code) + print(json.dumps(result, ensure_ascii=False, indent=2)) + return 0 if result.get("success") or result.get("valid") else 1 + except Exception as exc: + print(json.dumps({"success": False, "error": str(exc)}, ensure_ascii=False, indent=2)) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.flocks/plugins/skills/skyeye-use/scripts/skyeye_cli.py b/.flocks/plugins/skills/skyeye-use/scripts/skyeye_cli.py index 3f00aed50..40820fde9 100644 --- a/.flocks/plugins/skills/skyeye-use/scripts/skyeye_cli.py +++ b/.flocks/plugins/skills/skyeye-use/scripts/skyeye_cli.py @@ -5,6 +5,7 @@ import json import sys +from argparse import Namespace from datetime import datetime from pathlib import Path from typing import Any, Iterable @@ -48,6 +49,38 @@ def resolve_auth_file() -> Path | None: return AUTH_STATE_FILE if AUTH_STATE_FILE.exists() else None +def ensure_browser_auth_state( + *, + base_url: str, + login_path: str | None, + username: str | None, + password: str | None, + captcha_code: str | None, + force_refresh: bool, + debug: bool, +) -> tuple[bool, str]: + from skyeye_auth import ensure_auth_state, _runtime_config + + args = Namespace( + base_url=base_url, + username=username, + password=password, + auth_state=str(AUTH_STATE_FILE), + login_path=login_path or "", + captcha_path="", + captcha_code=captcha_code or "", + timeout=25, + max_captcha_retry=5, + no_ocr=False, + save_credentials=True, + ) + cfg = _runtime_config(args) + result = ensure_auth_state(cfg, captcha_code=captcha_code or "", force_refresh=force_refresh) + if debug: + print_info(json.dumps(result, ensure_ascii=False)) + return bool(result.get("success") or result.get("valid")), cfg.base_url + + def parse_pairs(pairs: Iterable[str]) -> dict[str, str]: result: dict[str, str] = {} for pair in pairs: @@ -154,18 +187,57 @@ def common_log_options(func): @click.group() @click.option("--token", "-t", help="CSRF Token,或使用 SKYEYE_CSRF_TOKEN") @click.option("--base-url", "-u", help="平台地址,或使用 SKYEYE_BASE_URL") +@click.option("--login-path", help="Login path for browser/CDP login") +@click.option("--username", help="Username for browser/CDP login") +@click.option("--password", help="Password for browser/CDP login") +@click.option("--auto-login", is_flag=True, help="Use saved credentials to refresh browser auth-state") +@click.option("--refresh-auth", is_flag=True, help="Force browser/CDP login before running the command") +@click.option("--captcha-code", help="Captcha code; OCR is used when omitted") @click.option("--debug", "-d", is_flag=True, help="开启调试输出") @click.option("--table", "table_output", is_flag=True, help="输出格式化表格(默认为 JSON)") @click.pass_context -def cli(ctx: click.Context, token: str, base_url: str, debug: bool, table_output: bool) -> None: +def cli( + ctx: click.Context, + token: str | None, + base_url: str | None, + login_path: str | None, + username: str | None, + password: str | None, + auto_login: bool, + refresh_auth: bool, + captcha_code: str | None, + debug: bool, + table_output: bool, +) -> None: """SkyEye CLI.""" ctx.ensure_object(dict) actual_base_url = base_url or BASE_URL - auth_file = resolve_auth_file() actual_token = token or TOKEN + wants_browser_login = bool(username or password or auto_login or refresh_auth) + if wants_browser_login: + try: + ok, resolved_base_url = ensure_browser_auth_state( + base_url=actual_base_url, + login_path=login_path, + username=username, + password=password, + captcha_code=captcha_code, + force_refresh=refresh_auth, + debug=debug, + ) + actual_base_url = resolved_base_url or actual_base_url + if not ok: + print_error("SkyEye 自动登录失败,请改用浏览器手动登录后保存 auth-state。") + sys.exit(1) + except Exception as exc: + print_error(f"SkyEye 自动登录失败: {exc}") + sys.exit(1) + + auth_file = resolve_auth_file() + if auth_file is None and not actual_token: - print_error("未提供认证信息。请提供 auth-state.json 或 SKYEYE_CSRF_TOKEN。") + print_error("未提供认证信息。请提供 auth-state.json、SKYEYE_CSRF_TOKEN,或使用 --auto-login/--username/--password。") sys.exit(1) if not actual_base_url: print_error("未提供平台地址。请设置 SKYEYE_BASE_URL 或使用 --base-url。") diff --git a/.flocks/plugins/skills/workflow-builder/references/workflow_template/guide.md b/.flocks/plugins/skills/workflow-builder/references/workflow_template/guide.md index 1ca7c7e09..3101f9483 100644 --- a/.flocks/plugins/skills/workflow-builder/references/workflow_template/guide.md +++ b/.flocks/plugins/skills/workflow-builder/references/workflow_template/guide.md @@ -89,12 +89,14 @@ Rex 验证时应优先做轻量检查: 发布配置模板的生效来源: -- 优先读后端 Storage/SQL 的 `/api/workflow//config`。 -- 如果库里没有,调用 `/api/workflow//config/sync`,由后端读取工作流目录下的 `config.json` 并迁移到 Storage/SQL。 +- 必须优先使用内置工具 `workflow_config_manage(action="get", workflow_id="")` 读取 Storage/SQL 中的生效模板;读取运行态配置时使用 `workflow_config_manage(action="get", workflow_id="", config_type="poller")`、`workflow_config_manage(action="get", workflow_id="", config_type="syslog")` 或 `workflow_config_manage(action="get", workflow_id="", config_type="kafka")`。 +- 如果库里没有,调用 `workflow_config_manage(action="sync", workflow_id="")`,由后端读取工作流目录下的 `config.json` 并迁移到 Storage/SQL。 +- 修改配置前先调用 `workflow_config_manage(action="diff", workflow_id="", config_type="", config={...})` 生成差异;用户确认后才调用 `workflow_config_manage(action="put", workflow_id="", config_type="", config={...})` 写入完整配置。 - `config.json` 是导入/兜底模板,不是运行态开关。 - 不要直接写 `config.json` 来表示发布、接入或触发配置已经生效。 -- 启停、发布、取消发布等运行态动作必须调用运行时接口,不要通过修改 `config.json` 完成。 -- 如果后端配置接口不可用,只能把目标配置保存为草稿到 outputs,并明确说明未应用、未发布、未启动。 +- 不要读取 `server_api_token` 或 `service_api_token`,不要手工 curl 本机后端配置接口,例如 `/config`、`/poller-config`、`/syslog-config` 或 `/kafka-config`;如果 `workflow_config_manage` 不可用或失败,只能保存草稿并说明未应用。 +- 支持的运行态配置读写必须调用 `workflow_config_manage(config_type=...)`;启停、发布、取消发布等非配置运行态动作才调用运行时接口,不要通过修改 `config.json` 完成。 +- 如果后端配置库不可用,只能把目标配置保存为草稿到 outputs,并明确说明未应用、未发布、未启动。 应用变更前: @@ -108,7 +110,7 @@ Rex 验证时应优先做轻量检查: 1. 读取本文件。 2. 读取 `workflow.md` 和 `workflow.json`。 -3. 查询 `/api/workflow//config`。 +3. 调用 `workflow_config_manage(action="get", workflow_id="")` 或 `workflow_config_manage(action="status", workflow_id="")`;需要查看定时、Syslog 或 Kafka 配置时,补充对应 `config_type`。 4. 必要时查看 `config.json` 是否只是兜底模板。 5. 汇总已配置项、缺失项和最推荐下一步。 diff --git a/.flocks/plugins/skills/workflow-config-guide/SKILL.md b/.flocks/plugins/skills/workflow-config-guide/SKILL.md index 20970988c..b745542bd 100644 --- a/.flocks/plugins/skills/workflow-config-guide/SKILL.md +++ b/.flocks/plugins/skills/workflow-config-guide/SKILL.md @@ -17,10 +17,10 @@ Do not use this skill to create a brand-new workflow from scratch. Use `workflow 1. Identify the current workflow directory. Prefer the explicit path in the user request; otherwise inspect the active workflow context and project/user workflow roots. 2. Read the workflow-local `guide.md` first. If it is missing or too thin to answer the user's request, stop and use the `question` tool to ask whether to generate or repair `guide.md` from `workflow.md`, `workflow.json`, and `config.json`. -3. Read the workflow files that exist: `workflow.json`, `workflow.md`, optional legacy `config.json`, and `meta.json`. Treat the backend `/api/workflow//config` response as the canonical publish template. If no stored template exists, use `/api/workflow//config/sync` to let the backend migrate the fallback `config.json`. +3. Read the workflow files that exist: `workflow.json`, `workflow.md`, optional legacy `config.json`, and `meta.json`. Treat `workflow_config_manage(action="get", workflow_id="")` as the canonical publish-template read path. For runtime configs, use the matching concrete call: `workflow_config_manage(action="get", workflow_id="", config_type="poller")`, `workflow_config_manage(action="get", workflow_id="", config_type="syslog")`, or `workflow_config_manage(action="get", workflow_id="", config_type="kafka")`. If no stored integration template exists, use `workflow_config_manage(action="sync", workflow_id="")` to let the backend migrate the fallback `config.json`. 4. Summarize the current configurable capabilities in plain language, using `guide.md` as the source for workflow-specific modes, defaults, sample requirements, validation, and recommended question order. 5. When any user decision, missing value, preference, or confirmation is needed, call the `question` tool. Do not ask configuration questions in ordinary assistant text. -6. Before changing the publish template, show a unified diff against the canonical backend config, then call the `question` tool for explicit confirmation. That single approval authorizes applying the shown diff through the backend config endpoint; do not ask a second "should I call PUT" question for the same diff. +6. Before changing any supported config, use `workflow_config_manage(action="diff", workflow_id="", config_type="", config={...})` and show its unified diff against the canonical backend config, then call the `question` tool for explicit confirmation. That single approval authorizes applying the shown diff through `workflow_config_manage(action="put", ...)`; do not ask a second write-confirmation question for the same diff. 7. After applying changes, validate JSON syntax and run the lightest useful workflow/config smoke test available. 8. End with a concise report in chat and save a timestamped report under `~/.flocks/workspace/outputs//`, computing `` at execution time. @@ -48,7 +48,7 @@ Each workflow that can be configured by Rex should include: ## Configuration Contract -Treat the publish configuration template as a workflow runtime/publish template, not as a second copy of workflow code. The canonical template is stored in Storage/SQL under the backend workflow config endpoint. A workflow-local `config.json` is only an import/fallback artifact: when the backend has no stored template, it may read `config.json` once and migrate that content into Storage/SQL. +Treat the publish configuration template as a workflow runtime/publish template, not as a second copy of workflow code. The canonical template is stored in Storage/SQL and must be read or changed through the built-in `workflow_config_manage` tool. Supported runtime configs (`poller`, `syslog`, `kafka`) must also be read, diffed, and written through `workflow_config_manage(config_type=...)`, not through hand-written local API calls. A workflow-local `config.json` is only an import/fallback artifact: when the backend has no stored template, `workflow_config_manage(action="sync", ...)` may read `config.json` once and migrate that content into Storage/SQL. - If the stored template declares only API publishing, the publish page should expose only API publish controls. - If the stored template declares only Syslog, Kafka, Webhook, or Schedule triggers, the publish page should expose only that trigger's start/stop or enable/disable controls. @@ -57,6 +57,7 @@ Treat the publish configuration template as a workflow runtime/publish template, - Treat the template as display/intent only. Real enabled/running/stopped state must come from runtime APIs backed by Storage/SQL, never from editing a template file directly. - Do not modify workflow node code while applying runtime configuration unless the user explicitly asks for a code change. - Re-running with the same answers should be idempotent: no changes, or a small diff limited to comments/timestamps. +- Do not read `server_api_token` or `service_api_token`, do not call `get_secret_manager()` for backend tokens, and do not hand-roll `curl` calls to local workflow config endpoints such as `/config`, `/poller-config`, `/syslog-config`, or `/kafka-config`. If `workflow_config_manage` is unavailable or fails, stop the apply path and save a draft instead of bypassing it. ## Conversation Pattern @@ -78,7 +79,7 @@ The `question` tool is mandatory for this skill. Any time you need the user to c - Ordinary assistant text may summarize the current state, explain a proposed diff, or report results. It must not contain actionable questions such as "要不要...", "是否...", "请确认...", or numbered follow-ups like "第二个问题...". - If `question` is not available in the tool list, say that the configuration guide cannot continue interactively until the `question` tool is available. Do not fall back to inline chat questions. - Use one question card per turn. Do not ask several independent decisions in a single text paragraph. -- For diff approval, show the diff first, then call `question` with choices such as "应用上面的 diff", "只保存草稿", and "暂不修改". If the user chooses to apply the shown diff, immediately apply it through the backend config endpoint; do not ask an extra confirmation that only repeats the same side effect. +- For diff approval, show the diff first, then call `question` with choices such as "应用上面的 diff", "只保存草稿", and "暂不修改". If the user chooses to apply the shown diff, immediately apply it through `workflow_config_manage`; do not ask an extra confirmation that only repeats the same side effect. - For side-effect scope questions, such as "是否顺手修改 workflow.md", call `question`; do not ask in prose. Rule anchor: never make a configuration question choice-only. @@ -101,7 +102,7 @@ Good pattern after showing a diff: "question": "是否应用上面的发布配置 diff?", "type": "choice", "options": [ - {"label": "应用 diff", "description": "通过后端配置接口写入 Storage/SQL。"}, + {"label": "应用 diff", "description": "通过 workflow_config_manage 写入 Storage/SQL。"}, {"label": "只保存草稿", "description": "不改运行配置,只写到输出目录。"}, {"label": "暂不修改", "description": "停止本次配置变更。"} ] @@ -116,29 +117,31 @@ Good pattern after showing a diff: } ``` -## Applying Publish Configuration +## Applying Configuration When the user approves an apply: -1. Read and preserve the previous canonical template from `GET /api/workflow//config`. -2. If the response says no stored template exists, call `POST /api/workflow//config/sync` so the backend migrates the fallback file or creates a generated template. +1. Read and preserve the previous canonical config with `workflow_config_manage(action="get", workflow_id="", config_type="")`. Omit `config_type` only for the default `integration` template. +2. If the integration response says no stored template exists, call `workflow_config_manage(action="sync", workflow_id="")` so the backend migrates the fallback file or creates a generated template. For runtime configs, use `sync` only to migrate an existing trigger fallback into Storage/SQL; otherwise use `put`. 3. Deep-merge the selected values into the existing config shape where possible. -4. Prefer the backend template endpoint: `PUT /api/workflow//config` with the full proposed config object as the JSON body. -5. Use the response's `config` as the saved template and `runtime` as the current effective state; do not infer runtime state from template `enabled` fields. -6. If the endpoint is unavailable, save a draft under `~/.flocks/workspace/outputs//` instead of changing `config.json`, and clearly state that the change was not applied, not published, and not started. -7. Validate with a JSON parser. -8. Verify the publish page or config endpoint returns the saved template from Storage/SQL. -9. Run a smoke test with `metadata.sampleInputs`, `workflow.json` sample inputs, or the user's pasted sample when a safe local test is available. -10. If validation fails, restore the previous template through `PUT /api/workflow//config` and report the exact failure. +4. Use `workflow_config_manage(action="diff", workflow_id="", config_type="", config=)` to produce the exact diff for confirmation. +5. After the user approves the shown diff, use `workflow_config_manage(action="put", workflow_id="", config_type="", config=)` to save the full proposed config object. +6. Use the response's `config` as the saved config and `runtime` as the current effective state; do not infer runtime state from template `enabled` fields. +7. If `workflow_config_manage` is unavailable or returns an error, save a draft under `~/.flocks/workspace/outputs//` instead of changing `config.json`, and clearly state that the change was not applied, not published, and not started. +8. Validate with a JSON parser. +9. Verify `workflow_config_manage(action="get", workflow_id="", config_type="")` returns the saved config from Storage/SQL. +10. Run a smoke test with `metadata.sampleInputs`, `workflow.json` sample inputs, or the user's pasted sample when a safe local test is available. +11. If validation fails, restore the previous config through `workflow_config_manage(action="put", workflow_id="", config_type="", config=)` and report the exact failure. If the user says "publish as API", "Syslog input", "Kafka input", "Webhook input", or "Schedule" from the Publish page, treat it as a guided configuration intent: - First identify whether the user wants to declare/change the template, start/stop runtime state, or both. -- For template changes, use `GET /config` -> diff -> question confirmation -> `PUT /config`. -- For runtime actions, use the runtime endpoint after template confirmation, such as `/publish`, `/unpublish`, `/syslog-config`, `/kafka-config`, `/poller-config`, or `/triggers`. +- For template changes, use `workflow_config_manage(action="get")` -> `workflow_config_manage(action="diff")` -> question confirmation -> `workflow_config_manage(action="put")`. +- For supported runtime config changes, use the same flow with `config_type="poller"`, `config_type="syslog"`, or `config_type="kafka"`; do not call `/poller-config`, `/syslog-config`, or `/kafka-config` directly. +- For non-config runtime actions, use the runtime operation after config confirmation, such as `/publish`, `/unpublish`, or `/triggers`. - If the backend is unreachable, do not say "the user should publish later in the WebUI" as if the requested action succeeded. Save a draft and report the exact blocker. -When the user wants to start, stop, enable, disable, publish, or unpublish a capability, do not edit the template. Use the runtime endpoint for that capability, such as `/publish`, `/unpublish`, `/syslog-config`, `/kafka-config`, `/poller-config`, or `/triggers`. +When the user wants to start, stop, enable, disable, publish, or unpublish a capability, do not edit the template. Use `workflow_config_manage` for the config write when the action is represented by a supported config type; use runtime operations only for non-config operations such as `/publish`, `/unpublish`, or trigger execution. If the user chooses draft mode, save the proposed config under `~/.flocks/workspace/outputs//` and list the path in the final report. diff --git a/.flocks/plugins/tools/device/sangfor_edr_webcli/_provider.yaml b/.flocks/plugins/tools/device/sangfor_edr_webcli/_provider.yaml new file mode 100644 index 000000000..5a065f634 --- /dev/null +++ b/.flocks/plugins/tools/device/sangfor_edr_webcli/_provider.yaml @@ -0,0 +1,124 @@ +name: Sangfor EDR WebCLI +vendor: sangfor +service_id: sangfor_edr +version: "1.0.0" +integration_type: device +description: > + Sangfor EDR WebCLI-backed integration. It can reuse an existing browser + storage state, or refresh auth-state by driving the real EDR login page + through browser daemon / CDP when credentials are configured. +description_cn: > + Sangfor EDR WebCLI device integration. It reuses full browser auth-state + from manual login, or refreshes full browser auth-state through CDP-assisted + browser login when credentials are configured. +credential_fields: + - key: base_url + label: Base URL + storage: config + config_key: base_url + input_type: url + required: true + placeholder: "https://edr.example.com" + - key: auth_state_path + label: Auth State Path + storage: config + config_key: auth_state_path + input_type: text + required: false + default: "~/.flocks/browser/sangfor-edr/auth-state.json" + - key: username + label: Username + storage: secret + config_key: username + secret_id: sangfor_edr_username + input_type: text + required: false + description: Optional. Stored for CDP-assisted browser login when auth-state is missing or expired. + - key: password + label: Password + storage: secret + config_key: password + secret_id: sangfor_edr_password + input_type: password + required: false + description: Optional. Stored as a secret and used only to refresh browser auth-state. + - key: auto_ocr_code + label: Auto OCR Captcha + storage: config + config_key: auto_ocr_code + input_type: boolean + required: false + default: true + - key: max_captcha_retry + label: Max Captcha Retry + storage: config + config_key: max_captcha_retry + input_type: number + required: false + default: 5 + - key: login_path + label: Login Path + storage: config + config_key: login_path + input_type: text + required: false + default: "/ui/login.php" + - key: index_path + label: Index Path + storage: config + config_key: index_path + input_type: text + required: false + default: "/ui/#/index" + - key: username_selector + label: Username Input Selector + storage: config + config_key: username_selector + input_type: text + required: false + default: "#user,input[name='user'],.username-input,#user_name,#username,input[name='user_name'],input[name='username'],input[type='text']" + description: Optional comma-separated selectors for the EDR username input. + - key: password_selector + label: Password Input Selector + storage: config + config_key: password_selector + input_type: text + required: false + default: "#password,input[name='password'],.input_text_password,input[name='pwd'],input[type='password']" + description: Optional comma-separated selectors for the EDR password input. + - key: captcha_selector + label: Captcha Input Selector + storage: config + config_key: captcha_selector + input_type: text + required: false + default: "#code,input[name='code'],.code_input_text,#randcode,#captcha,input[name='randcode'],input[name='captcha'],input[name='verify_code']" + description: Optional comma-separated selectors for the EDR captcha input. + - key: agreement_selector + label: Agreement Checkbox Selector + storage: config + config_key: agreement_selector + input_type: text + required: false + default: ".user-protocol-check input[type='checkbox']" + - key: submit_selector + label: Submit Button Selector + storage: config + config_key: submit_selector + input_type: text + required: false + default: "#button,input[name='button'],.login-opr-btn,#login,#submit,.login-btn,.btn-login,button[type='submit'],input[type='submit']" + description: Optional comma-separated selectors for the EDR login submit control. +defaults: + timeout: 20 + category: custom + verify_ssl: false +notes: | + The existing manual-login flow saves a full browser storageState containing + cookies and localStorage. When credentials are available, the automatic flow + also drives the real EDR login page through browser daemon / CDP and then + saves the same full browser storageState format. + + If captcha OCR, MFA, page selector matching, or login success detection fails, + keep the browser/manual login recovery path and save the resulting full + auth-state with flocks browser. diff --git a/.flocks/plugins/tools/device/sangfor_edr_webcli/_test.yaml b/.flocks/plugins/tools/device/sangfor_edr_webcli/_test.yaml new file mode 100644 index 000000000..f2893fcfb --- /dev/null +++ b/.flocks/plugins/tools/device/sangfor_edr_webcli/_test.yaml @@ -0,0 +1,15 @@ +schema_version: 1 +provider: sangfor_edr +connectivity: + tool: sangfor_edr_auth + params: + action: ensure_auth_state +fixtures: + sangfor_edr_auth: + - label: "Ensure EDR auth state" + label_cn: "Ensure or refresh EDR auth state" + tags: [smoke, auth] + params: + action: ensure_auth_state + assert: + success: true diff --git a/.flocks/plugins/tools/device/sangfor_edr_webcli/sangfor_edr.handler.py b/.flocks/plugins/tools/device/sangfor_edr_webcli/sangfor_edr.handler.py new file mode 100644 index 000000000..d456738fa --- /dev/null +++ b/.flocks/plugins/tools/device/sangfor_edr_webcli/sangfor_edr.handler.py @@ -0,0 +1,908 @@ +"""Sangfor EDR browser-state authentication helper. + +EDR has no stable Open API in this integration. This handler follows the same +browser workflow used by TDP / OneSEC / SkyEye / Qingteng skills: + +1. try to load the saved browser auth-state; +2. if it is still valid, reuse it; +3. if it is missing or expired, open the real EDR login page through CDP; +4. fill username, password and captcha in the browser page; +5. after login succeeds, save the full browser auth-state again. + +The handler only replaces the "wait for the user to log in manually" step with +CDP-assisted form login. It does not implement EDR business APIs. +""" + +from __future__ import annotations + +import base64 +import json +import os +import time +from pathlib import Path +from typing import Any, Optional +from urllib.parse import urljoin, urlparse + +from flocks.browser import helpers +from flocks.config.config_writer import ConfigWriter +from flocks.tool.registry import ToolContext, ToolResult + +SERVICE_ID = "sangfor_edr_v1_0_0" +LEGACY_SERVICE_ID = "sangfor_edr" +USERNAME_SECRET_ID = "sangfor_edr_username" +PASSWORD_SECRET_ID = "sangfor_edr_password" +DEFAULT_AUTH_STATE_PATH = "~/.flocks/browser/sangfor-edr/auth-state.json" +DEFAULT_LOGIN_PATH = "/ui/login.php" +DEFAULT_INDEX_PATH = "/ui/#/index" +DEFAULT_TIMEOUT = 25 +MAX_LOCAL_STORAGE_VALUE_BYTES = 100 * 1024 +CONFIG_KEYS = ( + "base_url", + "auth_state_path", + "auto_ocr_code", + "max_captcha_retry", + "login_path", + "index_path", + "username_selector", + "password_selector", + "captcha_selector", + "agreement_selector", + "submit_selector", +) + + +class RuntimeConfig: + def __init__( + self, + *, + base_url: str, + auth_state_path: Path, + username: str, + password: str, + login_path: str, + index_path: str, + timeout: int, + auto_ocr_code: bool, + max_captcha_retry: int, + username_selector: str, + password_selector: str, + captcha_selector: str, + agreement_selector: str, + submit_selector: str, + ) -> None: + self.base_url = base_url + self.auth_state_path = auth_state_path + self.username = username + self.password = password + self.login_path = login_path + self.index_path = index_path + self.timeout = timeout + self.auto_ocr_code = auto_ocr_code + self.max_captcha_retry = max_captcha_retry + self.username_selector = username_selector + self.password_selector = password_selector + self.captcha_selector = captcha_selector + self.agreement_selector = agreement_selector + self.submit_selector = submit_selector + + +# ── Config / secret helpers ────────────────────────────────────────────────── + +def _get_secret_manager(): + from flocks.security import get_secret_manager + + return get_secret_manager() + + +def _resolve_ref(value: Any) -> Optional[str]: + if value is None: + return None + if not isinstance(value, str): + return str(value) + if value.startswith("{secret:") and value.endswith("}"): + return _get_secret_manager().get(value[len("{secret:") : -1]) + if value.startswith("{env:") and value.endswith("}"): + return os.getenv(value[len("{env:") : -1]) + return value + + +def _coerce_bool(value: Any, default: bool = False) -> bool: + if value is None: + return default + if isinstance(value, bool): + return value + return str(value).strip().lower() in {"1", "true", "yes", "y", "on"} + + +def _coerce_int(value: Any, default: int) -> int: + try: + return int(str(value).strip()) + except (TypeError, ValueError): + return default + + +def _normalise_base_url(value: str) -> str: + candidate = value.strip() + if not candidate: + raise ValueError("Sangfor EDR base_url is required.") + if "://" not in candidate: + candidate = f"https://{candidate}" + parsed = urlparse(candidate) + if not parsed.hostname: + raise ValueError(f"Invalid Sangfor EDR base_url: {value!r}") + host = parsed.hostname + if ":" in host and not host.startswith("["): + host = f"[{host}]" + port = f":{parsed.port}" if parsed.port else "" + return f"{parsed.scheme}://{host}{port}".rstrip("/") + + +def _direct_api_service(service_id: str) -> dict[str, Any]: + services = ConfigWriter.list_api_services_raw() + service = services.get(service_id) if isinstance(services, dict) else None + return dict(service) if isinstance(service, dict) else {} + + +def _has_device_context() -> bool: + try: + from flocks.tool.credential_context import get_active_device_id + + return bool(get_active_device_id()) + except Exception: + return False + + +def _merge_missing(primary: dict[str, Any], fallback: dict[str, Any]) -> dict[str, Any]: + merged = dict(primary) + for key, value in fallback.items(): + # Keep versioned/device values first, but let legacy fill missing fields. + if merged.get(key) in (None, "") and value not in (None, ""): + merged[key] = value + return merged + + +def _load_service_config() -> dict[str, Any]: + versioned_or_override = ConfigWriter.get_api_service_raw(SERVICE_ID) + primary = dict(versioned_or_override) if isinstance(versioned_or_override, dict) else {} + if _has_device_context(): + # Device-scoped calls must not borrow global legacy fields from another EDR. + return primary + return _merge_missing(primary, _direct_api_service(LEGACY_SERVICE_ID)) + + +def _save_params_to_service(params: dict[str, Any]) -> dict[str, Any]: + service = _load_service_config() + persist_credentials = _coerce_bool(params.get("persist_credentials"), default=True) + + for key in CONFIG_KEYS: + value = params.get(key) + if value not in (None, ""): + service[key] = value + + username = params.get("username") + if isinstance(username, str) and username: + if persist_credentials: + _get_secret_manager().set(USERNAME_SECRET_ID, username) + service["username"] = f"{{secret:{USERNAME_SECRET_ID}}}" + else: + service["username"] = username + + password = params.get("password") + if isinstance(password, str) and password: + if persist_credentials: + _get_secret_manager().set(PASSWORD_SECRET_ID, password) + service["password"] = f"{{secret:{PASSWORD_SECRET_ID}}}" + else: + service["password"] = password + + if persist_credentials and any( + params.get(key) not in (None, "") + for key in ("base_url", "auth_state_path", "username", "password") + ): + ConfigWriter.set_api_service(SERVICE_ID, service) + + return service + + +def _saved_auto_login_status(params: dict[str, Any]) -> dict[str, Any]: + """Return non-sensitive information about saved EDR auto-login inputs.""" + service = _save_params_to_service({**params, "persist_credentials": False}) + secrets = _get_secret_manager() + + base_url = ( + _resolve_ref(service.get("base_url")) + or _resolve_ref(service.get("host")) + or os.getenv("SANGFOR_EDR_BASE_URL") + or "" + ) + auth_state_path = Path( + _resolve_ref(service.get("auth_state_path")) + or os.getenv("SANGFOR_EDR_AUTH_STATE") + or DEFAULT_AUTH_STATE_PATH + ).expanduser() + username = ( + _resolve_ref(service.get("username")) + or secrets.get(USERNAME_SECRET_ID) + or secrets.get(f"{SERVICE_ID}_username") + or secrets.get(f"{LEGACY_SERVICE_ID}_username") + or os.getenv("SANGFOR_EDR_USERNAME") + or "" + ) + password = ( + _resolve_ref(service.get("password")) + or secrets.get(PASSWORD_SECRET_ID) + or secrets.get(f"{SERVICE_ID}_password") + or secrets.get(f"{LEGACY_SERVICE_ID}_password") + or os.getenv("SANGFOR_EDR_PASSWORD") + or "" + ) + has_base_url = bool(str(base_url or "").strip()) + has_username = bool(str(username or "").strip()) + has_password = bool(str(password or "").strip()) + return { + "auth_state_path": str(auth_state_path), + "auth_state_exists": auth_state_path.exists(), + "has_base_url": has_base_url, + "has_saved_username": has_username, + "has_saved_password": has_password, + "can_auto_refresh": has_base_url and has_username and has_password, + } + + +def _resolve_runtime_config(params: dict[str, Any]) -> RuntimeConfig: + raw = _save_params_to_service(params) + secrets = _get_secret_manager() + + base_url = _normalise_base_url( + _resolve_ref(raw.get("base_url")) + or _resolve_ref(raw.get("host")) + or os.getenv("SANGFOR_EDR_BASE_URL") + or "" + ) + auth_state_path = Path( + _resolve_ref(raw.get("auth_state_path")) + or os.getenv("SANGFOR_EDR_AUTH_STATE") + or DEFAULT_AUTH_STATE_PATH + ).expanduser() + + username = ( + _resolve_ref(raw.get("username")) + or secrets.get(USERNAME_SECRET_ID) + or secrets.get(f"{SERVICE_ID}_username") + or secrets.get(f"{LEGACY_SERVICE_ID}_username") + or os.getenv("SANGFOR_EDR_USERNAME") + or "" + ).strip() + password = ( + _resolve_ref(raw.get("password")) + or secrets.get(PASSWORD_SECRET_ID) + or secrets.get(f"{SERVICE_ID}_password") + or secrets.get(f"{LEGACY_SERVICE_ID}_password") + or os.getenv("SANGFOR_EDR_PASSWORD") + or "" + ).strip() + + return RuntimeConfig( + base_url=base_url, + auth_state_path=auth_state_path, + username=username, + password=password, + login_path=str(raw.get("login_path") or DEFAULT_LOGIN_PATH), + index_path=str(raw.get("index_path") or DEFAULT_INDEX_PATH), + timeout=max(5, _coerce_int(raw.get("timeout"), DEFAULT_TIMEOUT)), + auto_ocr_code=_coerce_bool(raw.get("auto_ocr_code"), default=True), + max_captcha_retry=max(1, _coerce_int(raw.get("max_captcha_retry"), 5)), + username_selector=str(raw.get("username_selector") or ""), + password_selector=str(raw.get("password_selector") or ""), + captcha_selector=str(raw.get("captcha_selector") or ""), + agreement_selector=str(raw.get("agreement_selector") or ""), + submit_selector=str(raw.get("submit_selector") or ""), + ) + + +# ── Browser state workflow ─────────────────────────────────────────────────── + +def _url(cfg: RuntimeConfig, path: str) -> str: + return urljoin(cfg.base_url + "/", path.lstrip("/")) + + +def _now_ms() -> str: + return str(int(time.time() * 1000)) + + +def _login_url(cfg: RuntimeConfig) -> str: + return _url(cfg, cfg.login_path) + + +def _index_url(cfg: RuntimeConfig) -> str: + return _url(cfg, cfg.index_path) + + +def _open_page(url: str) -> None: + try: + helpers.open_or_attach_tab(url) + except Exception: + helpers.goto_url(url) + helpers.wait_for_load(timeout=15) + + +def _page_text() -> str: + try: + return str(helpers.js("document.body ? document.body.innerText : ''") or "") + except Exception: + return "" + + +def _looks_like_login_page(text: str, url: str) -> bool: + haystack = f"{url}\n{text}".lower() + markers = ("login.php", "user_name", "password", "randcode", "captcha", "验证码", "登录") + return any(marker.lower() in haystack for marker in markers) + + +def _has_session_cookie(cfg: RuntimeConfig) -> bool: + try: + result = helpers.cdp("Network.getCookies", urls=[cfg.base_url]) + except Exception: + return False + cookies = result.get("cookies", []) + if not isinstance(cookies, list): + return False + auth_cookie_names = {"sessionid", "jsessionid", "phpsessid", "ssid", "sid", "token"} + return any( + str(cookie.get("name") or "").lower() in auth_cookie_names and cookie.get("value") + for cookie in cookies + if isinstance(cookie, dict) + ) + + +def _has_logged_in_dom_marker() -> bool: + script = """(() => { + const selectors = [ + ".top-nav", ".navbar", ".header", ".main-header", ".layout-header", + ".sidebar", ".side-menu", ".left-menu", ".main-menu", ".nav-menu", + ".user-info", ".user-name", ".account-info", ".logout", "[href*='logout']", + "#app .router-view", "#app [class*='dashboard']", "[class*='dashboard']" + ]; + if (selectors.some((selector) => document.querySelector(selector))) { + return true; + } + const text = (document.body && document.body.innerText || "").slice(0, 4000); + return /终端概况|受管控终端|威胁资产|已失陷|设备状态|安全概况|退出登录|系统管理/.test(text); +})()""" + try: + return bool(helpers.js(script)) + except Exception: + return False + + +def _is_logged_in(cfg: RuntimeConfig) -> bool: + info = helpers.page_info() + current_url = str(info.get("url") or "") + if _looks_like_login_page(_page_text(), current_url): + return False + if _has_session_cookie(cfg): + return True + # Avoid treating blank/loading/error pages on the same host as authenticated. + return cfg.base_url.rstrip("/") in current_url and _has_logged_in_dom_marker() + + +def _validate_auth_state(cfg: RuntimeConfig) -> dict[str, Any]: + if not cfg.auth_state_path.exists(): + return { + "valid": False, + "reason": "auth_state_not_found", + "auth_state_path": str(cfg.auth_state_path), + } + try: + loaded = helpers.load_state(cfg.auth_state_path, url=_index_url(cfg)) + helpers.wait_for_load(timeout=15) + if _is_logged_in(cfg): + return { + "valid": True, + "reason": "browser_state_loaded", + "auth_state_path": str(cfg.auth_state_path), + "loaded": loaded, + } + return { + "valid": False, + "reason": "auth_state_expired_or_login_page", + "auth_state_path": str(cfg.auth_state_path), + "loaded": loaded, + } + except Exception as exc: + return { + "valid": False, + "reason": "auth_state_load_failed", + "error": str(exc), + "auth_state_path": str(cfg.auth_state_path), + } + + +# ── CDP login workflow ─────────────────────────────────────────────────────── + +def _selector_list(custom: str, defaults: tuple[str, ...]) -> list[str]: + values = [item.strip() for item in custom.split(",") if item.strip()] if custom else [] + values.extend(defaults) + result: list[str] = [] + for value in values: + if value not in result: + result.append(value) + return result + + +def _login_selectors(cfg: RuntimeConfig) -> dict[str, list[str]]: + return { + "username": _selector_list( + cfg.username_selector, + ( + "#user", + "input[name='user']", + ".username-input", + "#user_name", + "#username", + "input[name='user_name']", + "input[name='username']", + "input[type='text']", + ), + ), + "password": _selector_list( + cfg.password_selector, + ( + "#password", + "input[name='password']", + ".input_text_password", + "input[name='pwd']", + "input[type='password']", + ), + ), + "captcha": _selector_list( + cfg.captcha_selector, + ( + "#code", + "input[name='code']", + ".code_input_text", + "#randcode", + "#captcha", + "input[name='randcode']", + "input[name='captcha']", + "input[name='verify_code']", + ), + ), + "agreement": _selector_list( + cfg.agreement_selector, + ( + ".user-protocol-check input[type='checkbox']", + ".sfedr-checkbox-input", + "input[type='checkbox'][true-value='1']", + ), + ), + "submit": _selector_list( + cfg.submit_selector, + ( + "#button", + "input[name='button']", + ".login-opr-btn", + "#login", + "#submit", + ".login-btn", + ".btn-login", + "button[type='submit']", + "input[type='submit']", + ), + ), + } + + +def _login_dom_summary() -> Any: + script = """(() => { + return Array.from(document.querySelectorAll("input,button,a,img,iframe")) + .slice(0, 80) + .map((el) => ({ + tag: el.tagName, + id: el.id || "", + name: el.getAttribute("name") || "", + type: el.getAttribute("type") || "", + placeholder: el.getAttribute("placeholder") || "", + value: el.tagName === "INPUT" && el.type !== "password" ? (el.value || "") : "", + text: (el.innerText || el.value || el.getAttribute("alt") || "").trim().slice(0, 80), + className: String(el.className || ""), + src: el.getAttribute("src") || "", + href: el.getAttribute("href") || "" + })); +})()""" + try: + return helpers.js(script) + except Exception as exc: + return {"error": str(exc)} + + +def _wait_for_login_form_ready(cfg: RuntimeConfig) -> dict[str, bool]: + selectors = _login_selectors(cfg) + payload = { + "usernameSelectors": selectors["username"], + "passwordSelectors": selectors["password"], + "captchaSelectors": selectors["captcha"], + "submitSelectors": selectors["submit"], + } + deadline = time.time() + cfg.timeout + last_state: dict[str, bool] = {} + while time.time() < deadline: + state = helpers.js( + f"""(() => {{ + const cfg = {json.dumps(payload, ensure_ascii=False)}; + const exists = (selectors) => selectors.some((selector) => Boolean(document.querySelector(selector))); + return {{ + username: exists(cfg.usernameSelectors), + password: exists(cfg.passwordSelectors), + captcha: exists(cfg.captchaSelectors), + submit: exists(cfg.submitSelectors) + }}; +}})()""" + ) + last_state = state if isinstance(state, dict) else {} + if last_state.get("username") and last_state.get("password") and last_state.get("submit"): + return {key: bool(value) for key, value in last_state.items()} + time.sleep(0.5) + raise RuntimeError( + "EDR login form was not rendered before timeout: " + + json.dumps({"state": last_state, "dom": _login_dom_summary()}, ensure_ascii=False) + ) + + +def _captcha_image_data_url_from_dom() -> str: + script = """(async () => { + const srcAttrs = ["src", "currentSrc", "data-src", "data-url", "data-original"]; + const images = Array.from(document.querySelectorAll("img")); + const candidates = images + .map((img) => { + const values = srcAttrs + .map((attr) => attr === "currentSrc" ? img.currentSrc : img.getAttribute(attr)) + .filter(Boolean); + const hint = [ + img.id || "", + String(img.className || ""), + img.alt || "", + img.title || "", + values.join(" ") + ].join(" ").toLowerCase(); + return {values, hint}; + }) + .filter((item) => item.values.length) + .sort((a, b) => { + const score = (item) => /captcha|verify|vcode|randcode|code|验证码/.test(item.hint) ? 0 : 1; + return score(a) - score(b); + }); + + for (const item of candidates) { + for (const raw of item.values) { + const url = new URL(raw, window.location.href).href; + if (url.startsWith("data:image/")) { + return url; + } + try { + const response = await fetch(url, {credentials: "include", cache: "no-store"}); + if (!response.ok) continue; + const blob = await response.blob(); + if (!String(blob.type || "").startsWith("image/")) continue; + return await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onerror = () => reject(reader.error || new Error("captcha image read failed")); + reader.onload = () => resolve(String(reader.result)); + reader.readAsDataURL(blob); + }); + } catch (_err) { + } + } + } + return ""; +})()""" + try: + return str(helpers.js(script) or "") + except Exception: + return "" + + +def _captcha_image_from_browser(cfg: RuntimeConfig) -> bytes: + # Prefer the live captcha image URL so versioned/customized EDR login pages work. + data_url = _captcha_image_data_url_from_dom() + if not data_url: + captcha_url = _url(cfg, f"/ui/randcode.php?{_now_ms()}") + script = f"""(async () => {{ + const response = await fetch({json.dumps(captcha_url)}, {{ + credentials: "include", + cache: "no-store" + }}); + if (!response.ok) {{ + throw new Error("captcha request failed: " + response.status); + }} + const blob = await response.blob(); + return await new Promise((resolve, reject) => {{ + const reader = new FileReader(); + reader.onerror = () => reject(reader.error || new Error("captcha read failed")); + reader.onload = () => resolve(String(reader.result)); + reader.readAsDataURL(blob); + }}); +}})()""" + data_url = str(helpers.js(script) or "") + if "," not in data_url: + raise RuntimeError("EDR captcha fetch did not return a data URL.") + return base64.b64decode(data_url.split(",", 1)[1]) + + +def _ocr_verify_code(image_bytes: bytes) -> str: + try: + import ddddocr + except ImportError as exc: + raise RuntimeError( + "ddddocr is required for automatic Sangfor EDR captcha recognition." + ) from exc + return str(ddddocr.DdddOcr(show_ad=False).classification(image_bytes)).strip()[:4] + + +def _filter_large_local_storage_items(path: Path) -> dict[str, Any]: + try: + state = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + return {"filtered": False, "reason": "state_json_unreadable", "error": str(exc)} + if not isinstance(state, dict): + return {"filtered": False, "reason": "state_not_object"} + origins = state.get("origins") + if not isinstance(origins, list): + return {"filtered": False, "reason": "origins_not_list"} + + before = 0 + after = 0 + dropped = 0 + for origin in origins: + if not isinstance(origin, dict): + continue + entries = origin.get("localStorage") + if not isinstance(entries, list): + continue + before += len(entries) + kept = [] + for entry in entries: + value = entry.get("value") if isinstance(entry, dict) else "" + if len(str(value).encode("utf-8")) > MAX_LOCAL_STORAGE_VALUE_BYTES: + dropped += 1 + continue + kept.append(entry) + after += len(kept) + origin["localStorage"] = kept + + path.write_text(json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8") + return { + "filtered": True, + "localStorageItemsBefore": before, + "localStorageItemsAfter": after, + "localStorageItemsDropped": dropped, + "maxValueBytes": MAX_LOCAL_STORAGE_VALUE_BYTES, + } + + +def _save_auth_state(cfg: RuntimeConfig) -> dict[str, Any]: + saved = helpers.save_state(cfg.auth_state_path, url=_index_url(cfg)) + # EDR does not currently need known huge localStorage values for auth. + return {**saved, "filter": _filter_large_local_storage_items(cfg.auth_state_path)} + + +def _set_login_form_values(cfg: RuntimeConfig, code: str) -> dict[str, Any]: + selectors = _login_selectors(cfg) + payload = { + "username": cfg.username, + "password": cfg.password, + "code": code, + "usernameSelectors": selectors["username"], + "passwordSelectors": selectors["password"], + "captchaSelectors": selectors["captcha"], + "agreementSelectors": selectors["agreement"], + "submitSelectors": selectors["submit"], + } + script = f"""(() => {{ + const cfg = {json.dumps(payload, ensure_ascii=False)}; + function first(selectors) {{ + for (const selector of selectors) {{ + const el = document.querySelector(selector); + if (el) return el; + }} + return null; + }} + function setValue(el, value) {{ + if (!el) return false; + const proto = el instanceof HTMLTextAreaElement + ? HTMLTextAreaElement.prototype + : HTMLInputElement.prototype; + const setter = Object.getOwnPropertyDescriptor(proto, "value").set; + el.focus(); + setter.call(el, value); + el.dispatchEvent(new Event("input", {{bubbles: true}})); + el.dispatchEvent(new Event("change", {{bubbles: true}})); + el.dispatchEvent(new KeyboardEvent("keyup", {{bubbles: true}})); + return true; + }} + const username = first(cfg.usernameSelectors); + const password = first(cfg.passwordSelectors); + const captcha = first(cfg.captchaSelectors); + const agreement = first(cfg.agreementSelectors); + const filled = {{ + username: setValue(username, cfg.username), + password: setValue(password, cfg.password), + captcha: captcha ? setValue(captcha, cfg.code) : false, + agreement: false + }}; + if (!filled.username || !filled.password) {{ + throw new Error("missing EDR login username/password input"); + }} + if (agreement) {{ + if (!agreement.checked) {{ + agreement.click(); + }} + agreement.dispatchEvent(new Event("input", {{bubbles: true}})); + agreement.dispatchEvent(new Event("change", {{bubbles: true}})); + filled.agreement = Boolean(agreement.checked); + }} + const submit = first(cfg.submitSelectors) + || Array.from(document.querySelectorAll("button,input[type='button'],input[type='submit'],a")) + .find((el) => /登录|登 录|login/i.test((el.innerText || el.value || el.textContent || "").trim())); + if (submit) {{ + submit.click(); + }} else {{ + const form = username.closest("form") || password.closest("form"); + if (!form) throw new Error("missing EDR login submit button"); + form.dispatchEvent(new Event("submit", {{bubbles: true, cancelable: true}})); + if (typeof form.submit === "function") form.submit(); + }} + return filled; +}})()""" + result = helpers.js(script) + return result if isinstance(result, dict) else {"result": result} + + +def _wait_for_login_success(cfg: RuntimeConfig) -> bool: + deadline = time.time() + cfg.timeout + while time.time() < deadline: + try: + if _is_logged_in(cfg): + return True + except Exception: + pass + time.sleep(0.5) + return False + + +def _missing_login_inputs(cfg: RuntimeConfig) -> list[str]: + missing = [] + if not cfg.username: + missing.append("username") + if not cfg.password: + missing.append("password") + return missing + + +def _refresh_auth_state_with_cdp_login(cfg: RuntimeConfig, captcha_code: str = "") -> dict[str, Any]: + missing = _missing_login_inputs(cfg) + if missing: + return { + "success": False, + "status": "manual_login_required", + "reason": "missing_cdp_login_credentials", + "missing": missing, + "auth_state_path": str(cfg.auth_state_path), + } + + _open_page(_login_url(cfg)) + form_state = _wait_for_login_form_ready(cfg) + last_error = "" + for attempt in range(1, cfg.max_captcha_retry + 1): + try: + code = captcha_code.strip() + if not code: + if not cfg.auto_ocr_code: + return { + "success": False, + "status": "manual_login_required", + "reason": "captcha_code_required", + "auth_state_path": str(cfg.auth_state_path), + } + code = _ocr_verify_code(_captcha_image_from_browser(cfg)) + + filled = _set_login_form_values(cfg, code) + if _wait_for_login_success(cfg): + saved = _save_auth_state(cfg) + return { + "success": True, + "status": "browser_cdp_login_refreshed_auth_state", + "auth_state_path": str(cfg.auth_state_path), + "attempt": attempt, + "form": form_state, + "filled": {key: bool(value) for key, value in filled.items()}, + "saved": saved, + } + last_error = "login_success_check_timeout" + except Exception as exc: + last_error = str(exc) + + if captcha_code: + break + try: + helpers.goto_url(_login_url(cfg)) + helpers.wait_for_load(timeout=10) + except Exception: + pass + + return { + "success": False, + "status": "manual_login_required", + "reason": "browser_cdp_login_failed", + "last_error": last_error, + "auth_state_path": str(cfg.auth_state_path), + } + + +# ── Tool actions ───────────────────────────────────────────────────────────── + +def _auth_state_loaded_output(validation: dict[str, Any]) -> dict[str, Any]: + return { + "success": True, + "status": "auth_state_loaded", + **validation, + } + + +async def handle(ctx: ToolContext) -> ToolResult: + params = dict(ctx.params) + action = str(params.get("action") or "ensure_auth_state").strip() + + try: + if action == "status_auth_state": + status = _saved_auto_login_status(params) + validation: dict[str, Any] | None = None + if status.get("has_base_url"): + try: + validation = _validate_auth_state(_resolve_runtime_config({**params, "persist_credentials": False})) + except Exception as exc: + validation = { + "valid": False, + "reason": "auth_state_validate_failed", + "error": str(exc), + "auth_state_path": status["auth_state_path"], + } + return ToolResult( + success=True, + output={ + "success": True, + "status": "saved_auto_login_status", + **status, + "validation": validation, + }, + ) + + cfg = _resolve_runtime_config(params) + + if action == "validate_auth_state": + validation = _validate_auth_state(cfg) + return ToolResult(success=bool(validation.get("valid")), output=validation) + + if action not in {"ensure_auth_state", "refresh_auth_state"}: + return ToolResult( + success=False, + error="Unsupported Sangfor EDR auth action. Use status_auth_state, ensure_auth_state, validate_auth_state, or refresh_auth_state.", + ) + + force_refresh = action == "refresh_auth_state" or _coerce_bool(params.get("force_refresh"), default=False) + if not force_refresh: + validation = _validate_auth_state(cfg) + if validation.get("valid"): + return ToolResult(success=True, output=_auth_state_loaded_output(validation)) + + result = _refresh_auth_state_with_cdp_login( + cfg, + captcha_code=str(params.get("captcha_code") or ""), + ) + return ToolResult( + success=bool(result.get("success")), + output=result, + error=None if result.get("success") else result.get("reason"), + ) + except Exception as exc: + return ToolResult(success=False, error=str(exc)) 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 new file mode 100644 index 000000000..730338b81 --- /dev/null +++ b/.flocks/plugins/tools/device/sangfor_edr_webcli/sangfor_edr_auth.yaml @@ -0,0 +1,69 @@ +name: sangfor_edr_auth +description: > + Manage Sangfor EDR browser authentication state. Use status_auth_state or + validate_auth_state before opening pages; use ensure_auth_state to reuse a + valid saved auth-state or automatically refresh full browser state through + browser daemon / CDP when credentials are configured. +description_cn: > + 管理深信服 EDR 浏览器登录态。先用 status_auth_state 或 validate_auth_state + 检查已保存 state;当 state 缺失或失效且已配置账密时,ensure_auth_state + 通过 browser daemon / CDP 驱动真实登录页自动登录并重新保存完整浏览器 state。 +category: custom +enabled: true +requires_confirmation: false +provider: sangfor_edr +inputSchema: + type: object + properties: + action: + type: string + enum: [status_auth_state, ensure_auth_state, validate_auth_state, refresh_auth_state] + default: ensure_auth_state + description: > + Authentication action. status_auth_state returns non-sensitive saved + state/credential availability; ensure_auth_state reuses existing state + when it appears valid and refreshes when needed; validate_auth_state + checks only; refresh_auth_state always performs CDP-assisted browser login. + captcha_code: + type: string + description: Optional manual captcha code. When omitted, OCR is used if enabled. + base_url: + type: string + description: Optional EDR base URL to save before running auth, for example https://edr.example.com. + username: + type: string + description: Optional username to save before running auth. + password: + type: string + description: Optional password to save as a secret before running auth. + auth_state_path: + type: string + description: Optional auth-state path. Defaults to ~/.flocks/browser/sangfor-edr/auth-state.json. + username_selector: + type: string + default: "#user,input[name='user'],.username-input,#user_name,#username,input[name='user_name'],input[name='username'],input[type='text']" + description: Optional comma-separated selectors for the EDR username input. + password_selector: + type: string + default: "#password,input[name='password'],.input_text_password,input[name='pwd'],input[type='password']" + description: Optional comma-separated selectors for the EDR password input. + captcha_selector: + type: string + default: "#code,input[name='code'],.code_input_text,#randcode,#captcha,input[name='randcode'],input[name='captcha'],input[name='verify_code']" + description: Optional comma-separated selectors for the EDR captcha input. + agreement_selector: + type: string + description: Optional selector for the EDR user agreement checkbox. + submit_selector: + type: string + default: "#button,input[name='button'],.login-opr-btn,#login,#submit,.login-btn,.btn-login,button[type='submit'],input[type='submit']" + description: Optional comma-separated selectors for the EDR login submit control. + force_refresh: + type: boolean + default: false + description: Force CDP-assisted browser login even if auth-state exists. + required: [action] +handler: + type: script + script_file: sangfor_edr.handler.py + function: handle diff --git a/.flocks/plugins/workflows/loop_host_forensics_fast/meta.json b/.flocks/plugins/workflows/loop_host_forensics_fast/meta.json index 0a779ae10..060000557 100644 --- a/.flocks/plugins/workflows/loop_host_forensics_fast/meta.json +++ b/.flocks/plugins/workflows/loop_host_forensics_fast/meta.json @@ -1,5 +1,6 @@ { "name": "loop_host_forensics_fast", + "nameCn": "批量主机快速巡检工作流", "description": "从文件或 inputs 读取主机列表,循环调用 host-forensics-fast 子 Agent;每台主机结果立即落盘为独立文件,末步仅生成轻量索引与清单,避免全量 summary 超时", "category": "default", "status": "active", @@ -7,4 +8,4 @@ "createdAt": 1775787114059, "updatedAt": 1775817769342, "id": "loop_host_forensics_fast" -} \ No newline at end of file +} diff --git a/.flocks/plugins/workflows/loop_host_forensics_fast/workflow.json b/.flocks/plugins/workflows/loop_host_forensics_fast/workflow.json index 6290bcdd1..e0623b805 100644 --- a/.flocks/plugins/workflows/loop_host_forensics_fast/workflow.json +++ b/.flocks/plugins/workflows/loop_host_forensics_fast/workflow.json @@ -1,6 +1,7 @@ { "id": "loop_host_forensics_fast", "name": "loop_host_forensics_fast", + "nameCn": "批量主机快速巡检工作流", "description": "Batch host quick-triage workflow: loads hosts from inputs/file, performs per-host SSH preflight and fast checks, writes per-host reports, and outputs compact JSON/CSV indexes.", "description_cn": "从文件或 inputs 读取主机列表;可选 ssh_user。每台巡检结束立即写入 host_triage/ 下独立 md;循环态仅保留包含 success/verdict/per_host_md 的轻量结果索引。每台巡检前先做 SSH 预检,超时仅重试一次;末步同时生成 JSON/CSV 索引,避免最后节点 summary 因全量结果过大而超时。", "start": "init_hosts", diff --git a/.flocks/plugins/workflows/tdp_alert_triage/workflow.json b/.flocks/plugins/workflows/tdp_alert_triage/workflow.json index c296544a8..7967d0e0b 100644 --- a/.flocks/plugins/workflows/tdp_alert_triage/workflow.json +++ b/.flocks/plugins/workflows/tdp_alert_triage/workflow.json @@ -1,5 +1,6 @@ { "name": "tdp_alert_triage", + "nameCn": "TDP 告警调查工作流", "description": "NDR/TDP HTTP alert triage workflow. Parallel: threat intel query / vuln query / HTTP attack analysis (5-status unified prompt).", "description_cn": "TDP/NDR HTTP 告警研判工作流 — 情报查询 / 漏洞查询 / HTTP攻击分析(5 分类研判 prompt)三节点并行执行", "start": "receive_alert", @@ -116,4 +117,4 @@ } } } -} \ No newline at end of file +} diff --git a/.github/workflows/dispatch-autotest-windows-upgrade.yml b/.github/workflows/dispatch-autotest-windows-upgrade.yml index b9c08c6ef..59029c4bf 100644 --- a/.github/workflows/dispatch-autotest-windows-upgrade.yml +++ b/.github/workflows/dispatch-autotest-windows-upgrade.yml @@ -53,6 +53,7 @@ jobs: GITHUB_SHA_VALUE: ${{ github.sha }} GITHUB_REF_NAME_VALUE: ${{ github.ref_name }} GITHUB_RUN_ID_VALUE: ${{ github.run_id }} + GITHUB_RUN_ATTEMPT_VALUE: ${{ github.run_attempt }} run: | set -euo pipefail @@ -78,11 +79,15 @@ jobs: exit 1 fi + artifact_time="$(printf "%02d" "${GITHUB_RUN_ATTEMPT_VALUE:-1}")" + artifact_name="flocks-windows-upgrade-${release_tag}-time${artifact_time}" + { echo "release_tag=$release_tag" echo "release_url=$release_url" echo "rollback_version=${INPUT_ROLLBACK_VERSION:-}" echo "run_force_fallback=${INPUT_RUN_FORCE_FALLBACK:-false}" + echo "artifact_name=$artifact_name" } >> "$GITHUB_OUTPUT" { @@ -95,7 +100,7 @@ jobs: echo "- Source sha: ${GITHUB_SHA_VALUE}" echo "- Rollback version: ${INPUT_ROLLBACK_VERSION:-}" echo "- Run force fallback: ${INPUT_RUN_FORCE_FALLBACK:-false}" - echo "- Expected autotest artifact prefix: flocks-windows-upgrade-${release_tag}-time" + echo "- Expected autotest artifact: ${artifact_name}" echo "- Autotest workflow: https://github.com/AgentFlocks/flocks_autotest/actions/workflows/flocks-release-dispatch.yml" } >> "$GITHUB_STEP_SUMMARY" @@ -105,6 +110,7 @@ jobs: RELEASE_URL: ${{ steps.payload.outputs.release_url }} ROLLBACK_VERSION: ${{ steps.payload.outputs.rollback_version }} RUN_FORCE_FALLBACK: ${{ steps.payload.outputs.run_force_fallback }} + ARTIFACT_NAME: ${{ steps.payload.outputs.artifact_name }} SOURCE_REPOSITORY: ${{ github.repository }} SOURCE_RUN_ID: ${{ github.run_id }} SOURCE_SHA: ${{ github.sha }} @@ -126,6 +132,7 @@ jobs: "source_run_id": os.environ["SOURCE_RUN_ID"], "source_sha": os.environ["SOURCE_SHA"], "source_ref": os.environ["SOURCE_REF"], + "artifact_name": os.environ["ARTIFACT_NAME"], "rollback_version": os.environ.get("ROLLBACK_VERSION", ""), "run_force_fallback": os.environ.get("RUN_FORCE_FALLBACK", "false"), }, @@ -136,6 +143,7 @@ jobs: cat "$RUNNER_TEMP/flocks-autotest-dispatch.json" - name: Dispatch flocks_autotest + id: dispatch env: GH_TOKEN: ${{ secrets.AUTOTEST_DISPATCH_TOKEN }} run: | @@ -146,6 +154,9 @@ jobs: exit 1 fi + dispatch_started_at="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" + echo "started_at=${dispatch_started_at}" >> "$GITHUB_OUTPUT" + curl --fail-with-body -L -X POST \ -H "Accept: application/vnd.github+json" \ -H "Authorization: Bearer ${GH_TOKEN}" \ @@ -159,7 +170,9 @@ jobs: env: GH_TOKEN: ${{ secrets.AUTOTEST_DISPATCH_TOKEN }} RELEASE_TAG: ${{ steps.payload.outputs.release_tag }} + EXPECTED_ARTIFACT_NAME: ${{ steps.payload.outputs.artifact_name }} SOURCE_RUN_ID: ${{ github.run_id }} + DISPATCH_STARTED_AT: ${{ steps.dispatch.outputs.started_at }} AUTOTEST_REPOSITORY: AgentFlocks/flocks_autotest AUTOTEST_WORKFLOW_FILE: flocks-release-dispatch.yml AUTOTEST_POLL_INTERVAL_SECONDS: "30" @@ -175,11 +188,14 @@ jobs: import urllib.error import urllib.parse import urllib.request + from datetime import datetime, timedelta, timezone api_root = "https://api.github.com" token = os.environ["GH_TOKEN"] release_tag = os.environ["RELEASE_TAG"] + expected_artifact_name = os.environ["EXPECTED_ARTIFACT_NAME"] source_run_id = os.environ["SOURCE_RUN_ID"] + dispatch_started_at = os.environ["DISPATCH_STARTED_AT"] autotest_repo = os.environ["AUTOTEST_REPOSITORY"] workflow_file = os.environ["AUTOTEST_WORKFLOW_FILE"] poll_interval = int(os.environ["AUTOTEST_POLL_INTERVAL_SECONDS"]) @@ -199,9 +215,31 @@ jobs: with urllib.request.urlopen(request, timeout=30) as response: return json.loads(response.read().decode("utf-8")) + class NoRedirectHandler(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + return None + def download(url, destination): request = urllib.request.Request(url, headers=headers) - with urllib.request.urlopen(request, timeout=300) as response: + opener = urllib.request.build_opener(NoRedirectHandler) + try: + opener.open(request, timeout=30) + except urllib.error.HTTPError as error: + if error.code not in (301, 302, 303, 307, 308): + raise + redirect_url = error.headers.get("Location") + if not redirect_url: + raise RuntimeError("Artifact download redirect did not include Location header") from error + else: + raise RuntimeError("Artifact download did not return the expected redirect") + + # GitHub returns a short-lived signed URL for the artifact archive. + # Do not forward the GitHub PAT to that storage endpoint. + redirect_request = urllib.request.Request( + redirect_url, + headers={"Accept": "application/octet-stream"}, + ) + with urllib.request.urlopen(redirect_request, timeout=300) as response: with open(destination, "wb") as f: f.write(response.read()) @@ -215,92 +253,199 @@ jobs: for key, value in values.items(): f.write(f"{key}={value}\n") + def parse_github_time(value): + if not value: + return datetime.min.replace(tzinfo=timezone.utc) + return datetime.fromisoformat(value.replace("Z", "+00:00")) + workflow_ref = urllib.parse.quote(workflow_file, safe="") runs_url = ( f"{api_root}/repos/{autotest_repo}/actions/workflows/" - f"{workflow_ref}/runs?event=repository_dispatch&per_page=30" + f"{workflow_ref}/runs?per_page=50" ) - artifact_prefix = f"flocks-windows-upgrade-{release_tag}-time" + version_title_prefix = f"Windows upgrade {release_tag} " + version_artifact_prefix = f"flocks-windows-upgrade-{release_tag}-" + dispatch_started = parse_github_time(dispatch_started_at) - timedelta(seconds=30) deadline = time.time() + wait_timeout + source_discovery_deadline = time.time() + 120 matched_run = None - while time.time() < deadline: - runs = api_json(runs_url).get("workflow_runs", []) + def list_workflow_runs(): + return api_json(runs_url).get("workflow_runs", []) + + def latest_successful_version_run(runs=None): + if runs is None: + runs = list_workflow_runs() + candidates = [] for run in runs: title = run.get("display_title") or run.get("name") or "" - if f"#{source_run_id}" in title: - matched_run = run - break - if matched_run: - break - print( - f"Waiting for flocks_autotest workflow run with source_run_id={source_run_id}...", - flush=True, + if not title.startswith(version_title_prefix): + continue + if run.get("status") != "completed" or run.get("conclusion") != "success": + continue + candidates.append(run) + candidates.sort(key=lambda run: run.get("created_at") or "", reverse=True) + return candidates[0] if candidates else None + + def find_artifact(run_id, *, exact_name="", name_prefix="", retry_seconds=120): + artifacts_url = ( + f"{api_root}/repos/{autotest_repo}/actions/runs/{run_id}/artifacts?per_page=100" ) - time.sleep(poll_interval) - - if not matched_run: - write_summary( - [ - "### flocks_autotest result", - "", - f"- Status: timed out waiting for workflow run", - f"- Source run id: {source_run_id}", - f"- Expected artifact prefix: {artifact_prefix}", + lookup_deadline = time.time() + retry_seconds + available_artifact_names = [] + matching_artifacts = [] + while True: + artifacts = api_json(artifacts_url).get("artifacts", []) + available_artifact_names = [ + artifact.get("name") or "" + for artifact in artifacts + if not artifact.get("expired") and artifact.get("name") ] - ) - raise SystemExit("Timed out waiting for flocks_autotest workflow run") - - run_id = matched_run["id"] - run_api_url = matched_run["url"] - run_html_url = matched_run["html_url"] - print(f"Matched flocks_autotest run: {run_html_url}", flush=True) + matching_artifacts = [ + artifact + for artifact in artifacts + if not artifact.get("expired") + and ( + (exact_name and (artifact.get("name") or "") == exact_name) + or (name_prefix and (artifact.get("name") or "").startswith(name_prefix)) + ) + ] + if matching_artifacts or time.time() >= lookup_deadline: + break + available_text = ", ".join(available_artifact_names) if available_artifact_names else "(none)" + expected_text = exact_name or f"{name_prefix}*" + print( + f"Waiting for artifact {expected_text}. Available artifacts: {available_text}", + flush=True, + ) + time.sleep(10) + matching_artifacts.sort(key=lambda artifact: artifact.get("created_at") or "", reverse=True) + return (matching_artifacts[0] if matching_artifacts else None), available_artifact_names + + selected_run = None + selected_artifact = None + selected_available_artifacts = [] + selection_reason = "" + source_run_url = "" + source_failure = "" while time.time() < deadline: - matched_run = api_json(run_api_url) - status = matched_run.get("status") - conclusion = matched_run.get("conclusion") or "" + runs = list_workflow_runs() + source_candidates = [] + for run in runs: + title = run.get("display_title") or run.get("name") or "" + created_at = parse_github_time(run.get("created_at")) + if f"#{source_run_id}" in title and created_at >= dispatch_started: + source_candidates.append(run) + source_candidates.sort(key=lambda run: run.get("created_at") or "", reverse=True) + if source_candidates: + matched_run = source_candidates[0] + break + + fallback_run = latest_successful_version_run(runs) + if fallback_run and time.time() >= source_discovery_deadline: + selected_run = fallback_run + selection_reason = "version_success_fallback_no_source_run" + print( + f"Using latest successful flocks_autotest run for {release_tag}: " + f"{selected_run.get('html_url')}", + flush=True, + ) + break + print( - f"flocks_autotest run {run_id}: status={status} conclusion={conclusion}", + f"Waiting for flocks_autotest workflow run with source_run_id={source_run_id}...", flush=True, ) - if status == "completed": - break time.sleep(poll_interval) - if matched_run.get("status") != "completed": - write_summary( - [ - "### flocks_autotest result", - "", - f"- Status: timed out waiting for completion", - f"- Run: {run_html_url}", - f"- Expected artifact prefix: {artifact_prefix}", - ] - ) - raise SystemExit("Timed out waiting for flocks_autotest completion") - - conclusion = matched_run.get("conclusion") or "unknown" - artifacts_url = f"{api_root}/repos/{autotest_repo}/actions/runs/{run_id}/artifacts?per_page=100" - artifacts = api_json(artifacts_url).get("artifacts", []) - matching_artifacts = [ - artifact - for artifact in artifacts - if not artifact.get("expired") and (artifact.get("name") or "").startswith(artifact_prefix) - ] - matching_artifacts.sort(key=lambda artifact: artifact.get("created_at") or "", reverse=True) + if matched_run: + run_id = matched_run["id"] + run_api_url = matched_run["url"] + source_run_url = matched_run["html_url"] + print(f"Matched flocks_autotest run: {source_run_url}", flush=True) + + while time.time() < deadline: + matched_run = api_json(run_api_url) + status = matched_run.get("status") + conclusion = matched_run.get("conclusion") or "" + print( + f"flocks_autotest run {run_id}: status={status} conclusion={conclusion}", + flush=True, + ) + if status == "completed": + break + time.sleep(poll_interval) + + if matched_run.get("status") != "completed": + source_failure = "timed out waiting for source run completion" + elif matched_run.get("conclusion") == "success": + artifact, available_names = find_artifact( + run_id, + exact_name=expected_artifact_name, + retry_seconds=120, + ) + if artifact: + selected_run = matched_run + selected_artifact = artifact + selected_available_artifacts = available_names + selection_reason = "source_run_exact_artifact" + else: + available_text = ", ".join(available_names) if available_names else "(none)" + selected_available_artifacts = available_names + source_failure = ( + f"source run succeeded but expected artifact was not found; " + f"available artifacts: {available_text}" + ) + else: + source_failure = f"source run concluded with {matched_run.get('conclusion') or 'unknown'}" + + if not selected_artifact: + fallback_run = selected_run or latest_successful_version_run() + if fallback_run: + fallback_run_id = fallback_run["id"] + artifact, available_names = find_artifact( + fallback_run_id, + name_prefix=version_artifact_prefix, + retry_seconds=120, + ) + if artifact: + selected_run = fallback_run + selected_artifact = artifact + selected_available_artifacts = available_names + if not selection_reason: + selection_reason = "version_success_fallback" + print( + f"Using version fallback artifact from flocks_autotest run " + f"{selected_run.get('html_url')}", + flush=True, + ) + else: + available_text = ", ".join(available_names) if available_names else "(none)" + selected_available_artifacts = available_names + source_failure = ( + f"{source_failure}; " if source_failure else "" + ) + ( + f"latest successful {release_tag} run has no matching artifact; " + f"available artifacts: {available_text}" + ) artifact_name = "" artifact_html_url = "" artifact_path = "" - if matching_artifacts: - artifact = matching_artifacts[0] - artifact_name = artifact["name"] + run_id = "" + run_html_url = "" + conclusion = "unknown" + if selected_run and selected_artifact: + run_id = selected_run["id"] + run_html_url = selected_run["html_url"] + conclusion = selected_run.get("conclusion") or "success" + artifact_name = selected_artifact["name"] artifact_html_url = ( - f"https://github.com/{autotest_repo}/actions/runs/{run_id}/artifacts/{artifact['id']}" + f"https://github.com/{autotest_repo}/actions/runs/{run_id}/artifacts/{selected_artifact['id']}" ) artifact_path = os.path.join(runner_temp, f"{artifact_name}.zip") - download(artifact["archive_download_url"], artifact_path) + download(selected_artifact["archive_download_url"], artifact_path) print(f"Downloaded flocks_autotest artifact: {artifact_path}", flush=True) write_outputs( @@ -317,21 +462,33 @@ jobs: summary_lines = [ "### flocks_autotest result", "", - f"- Run: {run_html_url}", + f"- Selection: {selection_reason or 'none'}", + f"- Selected run: {run_html_url or 'not found'}", f"- Conclusion: {conclusion}", - f"- Expected artifact prefix: {artifact_prefix}", + f"- Source run: {source_run_url or 'not found'}", + f"- Source status: {source_failure or 'ok'}", + f"- Version fallback prefix: {version_artifact_prefix}", + f"- Expected artifact: {expected_artifact_name}", ] if artifact_name: summary_lines.append(f"- Artifact: [{artifact_name}]({artifact_html_url})") - summary_lines.append("- Artifact copy: uploaded to this flocks workflow run") + summary_lines.append("- Artifact copy: downloaded; upload step will attach it to this flocks workflow run") else: summary_lines.append("- Artifact: not found") + available_text = ( + ", ".join(selected_available_artifacts) + if selected_available_artifacts + else "(none)" + ) + summary_lines.append(f"- Available artifacts: {available_text}") write_summary(summary_lines) + if not selected_run: + raise SystemExit(source_failure or "flocks_autotest workflow run was not found") if conclusion != "success": raise SystemExit(f"flocks_autotest concluded with {conclusion}") if not artifact_name: - raise SystemExit("flocks_autotest artifact was not found") + raise SystemExit(source_failure or "flocks_autotest artifact was not found") PY - name: Upload flocks_autotest artifact copy diff --git a/flocks/browser/admin.py b/flocks/browser/admin.py index f082a738a..03535e459 100644 --- a/flocks/browser/admin.py +++ b/flocks/browser/admin.py @@ -497,7 +497,13 @@ def row(label: str, ok: bool, detail: str = "") -> None: browser_running, "" if browser_running else "start Chrome, Chromium, or Edge and rerun `flocks browser --setup`", ) - row("daemon alive", daemon, "" if daemon else "not running; wait user open browser inspect page and run `flocks browser --setup` to attach") + row( + "daemon alive", + daemon, + "" + if daemon + else "not running; run `flocks browser --setup` to attach; if setup reports remote debugging is disabled, follow its inspect-page prompt", + ) row("active browser connections", bool(connections), str(len(connections))) for conn in connections: page = conn.get("page") diff --git a/flocks/channel/builtin/feishu/monitor.py b/flocks/channel/builtin/feishu/monitor.py index c85804925..e2339a9d6 100644 --- a/flocks/channel/builtin/feishu/monitor.py +++ b/flocks/channel/builtin/feishu/monitor.py @@ -22,6 +22,7 @@ import hashlib import importlib import json +import re import threading import time import uuid @@ -42,6 +43,7 @@ _CHAT_LOCKS_MAX = 2000 _WS_ACCOUNT_RECONNECT_DELAY_S = 1.0 _WS_ACCOUNT_RECONNECT_MAX_DELAY_S = 30.0 +_MENTION_KEY_EDGE_RE = r"[A-Za-z0-9_.+@-]" class _ObservedWSClient: @@ -774,7 +776,7 @@ def _parse_event( for m in mentions: mention_key = m.get("key", "") if mention_key: - mention_text = mention_text.replace(mention_key, "").strip() + mention_text = _strip_mention_key(mention_text, mention_key) sender_id = sender.get("open_id", "") # Some mobile messages may only have user_id, not open_id @@ -803,6 +805,15 @@ def _parse_event( ) +def _strip_mention_key(text: str, mention_key: str) -> str: + """Remove a Feishu mention key without touching emails or identifiers.""" + pattern = re.compile( + rf"(? Optional[InboundMessage]: chat_id = body.get("chatid") or from_user if chat_type == ChatType.GROUP: - text = re.sub(r"@\S+", "", text).strip() + text = _strip_leading_mentions(text) # WeCom platform only delivers group messages when the bot is @mentioned, # so every group message that reaches here is inherently a mention. @@ -508,6 +509,11 @@ def _extract_sent_message_id(frame: Any) -> str: return str(body.get("msgid") or body.get("message_id") or "") +def _strip_leading_mentions(text: str) -> str: + """Remove only the bot mention prefix from a WeCom group message.""" + return _LEADING_MENTION_RE.sub("", text, count=1).strip() + + def _extract_content(body: dict) -> tuple[str, Optional[str]]: """Extract ``(text, media_url)`` from the frame body.""" msg_type = body.get("msgtype", "") diff --git a/flocks/channel/inbound/session_binding.py b/flocks/channel/inbound/session_binding.py index 465f21b0d..906f913c1 100644 --- a/flocks/channel/inbound/session_binding.py +++ b/flocks/channel/inbound/session_binding.py @@ -441,6 +441,33 @@ async def list_bindings( rows = await cursor.fetchall() return [self._row_to_binding(r) for r in rows] + async def latest_active_user_binding( + self, + *, + channel_id: str, + account_id: Optional[str] = None, + chat_id: Optional[str] = None, + ) -> Optional[SessionBinding]: + """Return the binding only when a channel target resolves uniquely.""" + from flocks.session.session import Session + + candidates = await self.list_bindings(channel_id=channel_id) + if account_id: + candidates = [b for b in candidates if b.account_id == account_id] + if chat_id: + candidates = [b for b in candidates if b.chat_id == chat_id] + + active_candidates: list[SessionBinding] = [] + for binding in candidates: + session = await Session.get_by_id(binding.session_id) + if ( + session + and session.status == "active" + and session.category == "user" + ): + active_candidates.append(binding) + return active_candidates[0] if len(active_candidates) == 1 else None + # --- internal helpers --- async def _find_binding( diff --git a/flocks/cli/commands/__init__.py b/flocks/cli/commands/__init__.py index 14d8b464c..4522cc968 100644 --- a/flocks/cli/commands/__init__.py +++ b/flocks/cli/commands/__init__.py @@ -8,6 +8,7 @@ from flocks.cli.commands.import_ import import_app from flocks.cli.commands.mcp import mcp_app from flocks.cli.commands.browser import BROWSER_CONTEXT_SETTINGS, browser_command +from flocks.cli.commands.doctor import doctor_command from flocks.cli.commands.session import session_app from flocks.cli.commands.skill import skill_app from flocks.cli.commands.stats import stats_app @@ -19,6 +20,7 @@ "mcp_app", "browser_command", "BROWSER_CONTEXT_SETTINGS", + "doctor_command", "export_app", "import_app", "stats_app", diff --git a/flocks/cli/commands/doctor.py b/flocks/cli/commands/doctor.py new file mode 100644 index 000000000..e6e9a9dec --- /dev/null +++ b/flocks/cli/commands/doctor.py @@ -0,0 +1,205 @@ +"""Source-install repair command for the Flocks CLI.""" + +from __future__ import annotations + +import os +import shlex +import shutil +import subprocess +import sys +from pathlib import Path + +import typer +from rich.console import Console + +from flocks.cli.install_profile import cn_installer_environment, is_cn_install_language + +console = Console() + + +def doctor_command() -> None: + """Run the source installer from the Flocks source directory.""" + source_root = _find_source_root() + script = _select_source_install_script(source_root) + command = _build_source_install_command(script) + env = _build_source_install_env() + + console.print(f"[cyan]Flocks source directory:[/cyan] {source_root}") + console.print(f"[cyan]Source install command:[/cyan] {_format_command(command)}") + + if _needs_windows_handoff(): + _start_windows_handoff(source_root, env=env) + console.print( + "[yellow]Windows detected: the installer will continue in this console " + "after the current flocks.exe exits.[/yellow]" + ) + return + + _run_source_install(command, source_root=source_root, env=env) + + console.print("[green]安装正常[/green]") + _print_service_diagnosis() + + +def _find_source_root(start: Path | None = None) -> Path: + """Find the repository root that owns the source install scripts.""" + current = (start or Path(__file__)).resolve() + candidates = (current, *current.parents) + + for candidate in candidates: + if candidate.is_file(): + continue + if (candidate / "pyproject.toml").is_file() and (candidate / "scripts" / "install.sh").is_file(): + return candidate + + raise typer.BadParameter("Could not locate the Flocks source directory.") + + +def _select_source_install_script(source_root: Path) -> Path: + """Select the platform-specific source install script.""" + suffix = ".ps1" if _is_windows() else ".sh" + script = source_root / "scripts" / f"install{suffix}" + + if not script.is_file(): + raise typer.BadParameter(f"Source installer not found: {script}") + + return script + + +def _build_source_install_command(script: Path) -> list[str]: + """Build the subprocess command for the selected installer.""" + if script.suffix == ".ps1": + powershell = _find_powershell() + return [ + powershell, + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + str(script), + ] + + return ["bash", str(script)] + + +def _run_source_install(command: list[str], *, source_root: Path, env: dict[str, str] | None) -> None: + """Run the selected source installer synchronously.""" + try: + subprocess.run(command, cwd=source_root, check=True, env=env) + except FileNotFoundError as error: + console.print(f"[red]Failed to start installer: {error}[/red]") + raise typer.Exit(1) from error + except subprocess.CalledProcessError as error: + raise typer.Exit(error.returncode or 1) from error + + +def _build_source_install_env() -> dict[str, str] | None: + """Build installer environment from the persisted install language.""" + if not is_cn_install_language(): + return None + env = os.environ.copy() + for key, value in cn_installer_environment().items(): + if key == "FLOCKS_INSTALL_LANGUAGE": + env[key] = value + continue + env.setdefault(key, value) + return env + + +def _needs_windows_handoff() -> bool: + """Return whether doctor must release the Windows console entrypoint first.""" + return _is_windows() and os.environ.get("FLOCKS_DOCTOR_WINDOWS_HANDOFF") != "1" + + +def _start_windows_handoff(source_root: Path, *, env: dict[str, str] | None) -> None: + """Start a helper that waits for this process to exit before running doctor.""" + command = _build_windows_handoff_command(source_root, parent_pid=os.getpid()) + handoff_env = os.environ.copy() if env is None else env.copy() + handoff_env["FLOCKS_DOCTOR_WINDOWS_HANDOFF"] = "1" + + try: + subprocess.Popen(command, cwd=source_root, env=handoff_env, close_fds=True) + except FileNotFoundError as error: + console.print(f"[red]Failed to start installer handoff: {error}[/red]") + raise typer.Exit(1) from error + + +def _build_windows_handoff_command(source_root: Path, *, parent_pid: int) -> list[str]: + """Build a PowerShell command that reruns doctor after the current PID exits.""" + python_executable = _source_python_executable(source_root) + helper_script = "; ".join( + [ + "$ErrorActionPreference = 'Stop'", + f"Wait-Process -Id {parent_pid} -ErrorAction SilentlyContinue", + f"& {_quote_powershell_string(str(python_executable))} -m flocks.cli.main doctor", + "exit $LASTEXITCODE", + ] + ) + return [ + _find_powershell(), + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-Command", + helper_script, + ] + + +def _source_python_executable(source_root: Path) -> Path: + """Return the source venv Python, falling back to the current interpreter.""" + windows_python = source_root / ".venv" / "Scripts" / "python.exe" + if windows_python.exists(): + return windows_python + return Path(sys.executable) + + +def _quote_powershell_string(value: str) -> str: + """Quote a string as a PowerShell single-quoted literal.""" + return "'" + value.replace("'", "''") + "'" + + +def _find_powershell() -> str: + """Return the preferred PowerShell executable name or path.""" + return shutil.which("pwsh") or shutil.which("powershell") or "powershell" + + +def _print_service_diagnosis() -> None: + """Print a concise post-install service diagnosis.""" + try: + from flocks.cli.service_manager import build_status_lines + + status_lines = build_status_lines() + except Exception as error: + console.print(f"[yellow]服务状态检查失败:{error}[/yellow]") + console.print("[yellow]运行状态异常,请执行 `flocks restart`[/yellow]") + return + + for line in status_lines: + console.print(line) + + if _service_status_is_healthy(status_lines): + console.print("[green]运行状态正常[/green]") + else: + console.print("[yellow]运行状态异常,请执行 `flocks restart`[/yellow]") + + +def _service_status_is_healthy(status_lines: list[str]) -> bool: + """Return whether the current or legacy service status looks healthy.""" + daemon_running = any("daemon:" in line and "state=running" in line for line in status_lines) + flocks_healthy = any("flocks:" in line and "state=healthy" in line for line in status_lines) + if daemon_running and flocks_healthy: + return True + + backend_running = any("后端运行中" in line for line in status_lines) + webui_running = any("WebUI 运行中" in line for line in status_lines) + return backend_running and webui_running + + +def _format_command(command: list[str]) -> str: + """Return a shell-readable representation of the command.""" + return " ".join(shlex.quote(part) for part in command) + + +def _is_windows() -> bool: + """Return whether the current platform should use PowerShell installers.""" + return sys.platform.startswith("win") diff --git a/flocks/cli/commands/skill.py b/flocks/cli/commands/skill.py index ef9b6c01b..80b8ff028 100644 --- a/flocks/cli/commands/skill.py +++ b/flocks/cli/commands/skill.py @@ -366,11 +366,12 @@ async def _search_safeskill(query: str) -> list: name = item.get("name") or item.get("slug") or source if not source or not name: continue + install_hint = source if str(source).startswith("safeskill://") else f"safeskill:{source}" results.append({ "name": str(name), "description": str(item.get("description") or ""), "source": "safeskill.cn", - "install_hint": f"safeskill:{source}", + "install_hint": install_hint, }) return results except Exception: @@ -389,11 +390,12 @@ def _parse_safeskill_text_results(text: str) -> list: continue source = match.group(1).rstrip(",.;") name = source.rstrip("/").split("/")[-1] + install_hint = source if source.startswith("safeskill://") else f"safeskill:{source}" results.append({ "name": name, "description": clean, "source": "safeskill.cn", - "install_hint": f"safeskill:{source}", + "install_hint": install_hint, }) return results @@ -472,7 +474,8 @@ def install_skill( "Install source:\n" " clawhub: – clawhub.com registry\n" " skills-sh: – skills.sh identifier (owner/repo/skill)\n" - " safeskill: – SafeSkill Hub/GitHub/local source via SafeSkill CLI\n" + " safeskill://... – SafeSkill package URI\n" + " safeskill: – SafeSkill source alias via SafeSkill CLI\n" " github:/ – GitHub repository\n" " / – GitHub shorthand\n" " https://... – direct SKILL.md URL\n" diff --git a/flocks/cli/commands/update.py b/flocks/cli/commands/update.py index 6e884643b..c288b9755 100644 --- a/flocks/cli/commands/update.py +++ b/flocks/cli/commands/update.py @@ -36,6 +36,10 @@ def update_command( async def _update(check: bool, yes: bool, force: bool = False, region: str | None = None) -> None: from flocks.updater import build_updated_frontend, check_update, perform_update, detect_deploy_mode + from flocks.cli.install_profile import is_cn_install_language + + if region is None and is_cn_install_language(): + region = "cn" if not yes and not check and region is None: use_cn_mirror = typer.confirm("\n是否使用中国镜像进行升级?", default=False) diff --git a/flocks/cli/install_profile.py b/flocks/cli/install_profile.py new file mode 100644 index 000000000..74bc57239 --- /dev/null +++ b/flocks/cli/install_profile.py @@ -0,0 +1,87 @@ +"""Persisted installer language profile for CLI maintenance commands.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import Any + +INSTALL_PROFILE_FILE = "install_profile.json" +INSTALL_PROFILE_LANGUAGE_KEY = "Language" +DEFAULT_INSTALL_LANGUAGE = "en" +CN_INSTALL_LANGUAGE = "zh-CN" + + +def install_profile_path() -> Path: + """Return the install profile path under the Flocks config directory.""" + config_dir = os.getenv("FLOCKS_CONFIG_DIR") + if config_dir: + return Path(config_dir).expanduser() / INSTALL_PROFILE_FILE + + root = os.getenv("FLOCKS_ROOT") + if root: + return Path(root).expanduser() / "config" / INSTALL_PROFILE_FILE + + return Path.home() / ".flocks" / "config" / INSTALL_PROFILE_FILE + + +def normalize_install_language(value: str | None) -> str: + """Normalize a persisted or environment installer language value.""" + language = (value or "").strip() + if _is_cn_language(language): + return CN_INSTALL_LANGUAGE + if language: + return language + return DEFAULT_INSTALL_LANGUAGE + + +def read_install_language() -> str: + """Read the persisted installer language, falling back to the environment.""" + path = install_profile_path() + try: + if path.is_file(): + payload = json.loads(path.read_text(encoding="utf-8")) + if isinstance(payload, dict): + return normalize_install_language(_string_value(payload.get(INSTALL_PROFILE_LANGUAGE_KEY))) + except (OSError, json.JSONDecodeError): + pass + + return normalize_install_language(os.getenv("FLOCKS_INSTALL_LANGUAGE")) + + +def is_cn_install_language(language: str | None = None) -> bool: + """Return whether *language* or the persisted profile selects China mirrors.""" + return _is_cn_language(language or read_install_language()) + + +def cn_installer_environment() -> dict[str, str]: + """Return environment variables equivalent to the zh source installer wrapper.""" + return { + "FLOCKS_INSTALL_LANGUAGE": CN_INSTALL_LANGUAGE, + "FLOCKS_INSTALL_REPO_URL": "https://gitee.com/flocks/flocks.git", + "FLOCKS_RAW_INSTALL_SH_URL": "https://gitee.com/flocks/flocks/raw/main/install_zh.sh", + "FLOCKS_RAW_INSTALL_PS1_URL": "https://gitee.com/flocks/flocks/raw/main/install_zh.ps1", + "FLOCKS_UV_DEFAULT_INDEX": "https://mirrors.aliyun.com/pypi/simple", + "FLOCKS_UV_INSTALL_SH_URL": "https://astral.org.cn/uv/install.sh", + "FLOCKS_UV_INSTALL_SH_FALLBACK_URL": "https://uv.agentsmirror.com/install-cn.sh", + "FLOCKS_UV_INSTALL_SH_SECONDARY_FALLBACK_URL": "https://astral.sh/uv/install.sh", + "FLOCKS_UV_INSTALL_PS1_URL": "https://astral.org.cn/uv/install.ps1", + "FLOCKS_UV_INSTALL_PS1_FALLBACK_URL": "https://uv.agentsmirror.com/install-cn.ps1", + "FLOCKS_UV_INSTALL_PS1_SECONDARY_FALLBACK_URL": "https://astral.sh/uv/install.ps1", + "FLOCKS_NPM_REGISTRY": "https://registry.npmmirror.com/", + "FLOCKS_NVM_INSTALL_SCRIPT_URL": "https://gitee.com/mirrors/nvm/raw/v0.40.3/install.sh", + "PUPPETEER_CHROME_DOWNLOAD_BASE_URL": "https://cdn.npmmirror.com/binaries/chrome-for-testing", + "FLOCKS_NODEJS_MANUAL_DOWNLOAD_URL": "https://nodejs.org/zh-cn/download", + } + + +def _string_value(value: Any) -> str | None: + if isinstance(value, str): + return value + return None + + +def _is_cn_language(language: str | None) -> bool: + normalized = (language or "").strip().lower().replace("_", "-") + return normalized.startswith(("zh", "cn")) diff --git a/flocks/cli/main.py b/flocks/cli/main.py index bdfa8d8d5..8fce87905 100644 --- a/flocks/cli/main.py +++ b/flocks/cli/main.py @@ -5,7 +5,6 @@ """ import asyncio -import os import secrets as secrets_lib import sys from pathlib import Path @@ -21,6 +20,7 @@ admin_app, BROWSER_CONTEXT_SETTINGS, browser_command, + doctor_command, export_app, import_app, mcp_app, @@ -30,10 +30,15 @@ task_app, ) from flocks.cli.commands.update import update_command -from flocks.cli.service_manager import ( +from flocks.cli.service_config import ( ServiceConfig, + ServiceConfigError, + build_service_config, + restart_defaults_from_status_payload, +) +from flocks.cli.service_control import read_supervisor_status +from flocks.cli.service_manager import ( ServiceError, - read_runtime_record, resolve_flocks_cli_command, restart_all, runtime_paths, @@ -42,6 +47,7 @@ start_all, stop_all, ) +from flocks.cli.service_supervisor import run_service_daemon from flocks.config.config import Config from flocks.utils.log import Log, LogLevel @@ -66,6 +72,7 @@ app.add_typer(admin_app, name="admin") app.command(name="update")(update_command) +app.command(name="doctor")(doctor_command) app.command( name="browser", context_settings=BROWSER_CONTEXT_SETTINGS, @@ -140,6 +147,8 @@ def main_callback( def _service_config( no_browser: bool = False, skip_webui_build: bool = False, + host: Optional[str] = None, + port: Optional[int] = None, server_host: Optional[str] = None, server_port: Optional[int] = None, webui_host: Optional[str] = None, @@ -151,87 +160,36 @@ def _service_config( ) -> ServiceConfig: """Build service config from environment and CLI toggles.""" global_config = Config.get_global() - return ServiceConfig( - backend_host=_resolve_host( - cli_value=server_host, - env_names=("FLOCKS_SERVER_HOST", "FLOCKS_BACKEND_HOST"), - default=default_server_host or global_config.server_host, - ), - backend_port=_resolve_port( - cli_value=server_port, - env_names=("FLOCKS_SERVER_PORT", "FLOCKS_BACKEND_PORT"), - default=default_server_port or global_config.server_port, - label="server", - ), - frontend_host=_resolve_host( - cli_value=webui_host, - env_names=("FLOCKS_WEBUI_HOST", "FLOCKS_FRONTEND_HOST"), - default=default_webui_host or "127.0.0.1", - ), - frontend_port=_resolve_port( - cli_value=webui_port, - env_names=("FLOCKS_WEBUI_PORT", "FLOCKS_FRONTEND_PORT"), - default=default_webui_port or 5173, - label="webui", - ), + return build_service_config( no_browser=no_browser, - skip_frontend_build=skip_webui_build, + skip_webui_build=skip_webui_build, + public_host=host, + public_port=port, + server_host=server_host, + server_port=server_port, + webui_host=webui_host, + webui_port=webui_port, + default_server_host=default_server_host or global_config.server_host, + default_server_port=default_server_port or global_config.server_port, + default_webui_host=default_webui_host or "127.0.0.1", + default_webui_port=default_webui_port or 5173, ) -def _resolve_host(cli_value: Optional[str], env_names: tuple[str, ...], default: str) -> str: - """Resolve a host value from CLI, environment, and default values.""" - if cli_value is not None: - return cli_value - for env_name in env_names: - env_value = os.getenv(env_name) - if env_value: - return env_value - return default - - -def _resolve_port( - cli_value: Optional[int], - env_names: tuple[str, ...], - default: int, - label: str, -) -> int: - """Resolve a port value from CLI, environment, and default values.""" - if cli_value is not None: - return cli_value - for env_name in env_names: - env_value = os.getenv(env_name) - if not env_value: - continue - try: - return int(env_value) - except ValueError as error: - raise ServiceError(f"{label} port from {env_name} must be an integer.") from error - return default - - def _restart_runtime_defaults() -> dict[str, Any]: - """Load host/port defaults from the last recorded service runtime.""" - paths = runtime_paths() - backend = read_runtime_record(paths.backend_pid) - frontend = read_runtime_record(paths.frontend_pid) - defaults: dict[str, Any] = {} - if backend is not None: - if backend.host: - defaults["default_server_host"] = backend.host - if backend.port is not None: - defaults["default_server_port"] = backend.port - if frontend is not None: - if frontend.host: - defaults["default_webui_host"] = frontend.host - if frontend.port is not None: - defaults["default_webui_port"] = frontend.port - return defaults + """Load host/port defaults from the running supervisor when available.""" + try: + status = read_supervisor_status(paths=runtime_paths(), timeout=1.0) + except Exception: + return {} + return restart_defaults_from_status_payload(getattr(status, "raw", status)) def _restart_service_config( no_browser: bool = False, skip_webui_build: bool = False, + host: Optional[str] = None, + port: Optional[int] = None, server_host: Optional[str] = None, server_port: Optional[int] = None, webui_host: Optional[str] = None, @@ -241,6 +199,8 @@ def _restart_service_config( return _service_config( no_browser=no_browser, skip_webui_build=skip_webui_build, + host=host, + port=port, server_host=server_host, server_port=server_port, webui_host=webui_host, @@ -261,21 +221,25 @@ def start( skip_webui_build: bool = typer.Option( False, "--skip-webui-build", - help="Skip `npm run build` before starting WebUI", + help="Skip WebUI static asset build before starting Flocks service", ), + host: Optional[str] = typer.Option(None, "--host", "-h", help="Public service host"), + port: Optional[int] = typer.Option(None, "--port", "-p", help="Public service port"), server_host: Optional[str] = typer.Option(None, "--server-host", help="Backend server host"), server_port: Optional[int] = typer.Option(None, "--server-port", help="Backend server port"), webui_host: Optional[str] = typer.Option(None, "--webui-host", help="WebUI host"), webui_port: Optional[int] = typer.Option(None, "--webui-port", help="WebUI port"), ): """ - Start backend and WebUI in daemon mode + Start Flocks service in daemon mode. """ try: start_all( _service_config( no_browser=no_browser, skip_webui_build=skip_webui_build, + host=host, + port=port, server_host=server_host, server_port=server_port, webui_host=webui_host, @@ -290,7 +254,7 @@ def start( @app.command() def stop(): """ - Stop backend and WebUI + Stop Flocks service. """ try: stop_all(console) @@ -304,21 +268,25 @@ def restart( skip_webui_build: bool = typer.Option( False, "--skip-webui-build", - help="Skip `npm run build` before starting WebUI", + help="Skip WebUI static asset build before starting Flocks service", ), + host: Optional[str] = typer.Option(None, "--host", "-h", help="Public service host"), + port: Optional[int] = typer.Option(None, "--port", "-p", help="Public service port"), server_host: Optional[str] = typer.Option(None, "--server-host", help="Backend server host"), server_port: Optional[int] = typer.Option(None, "--server-port", help="Backend server port"), webui_host: Optional[str] = typer.Option(None, "--webui-host", help="WebUI host"), webui_port: Optional[int] = typer.Option(None, "--webui-port", help="WebUI port"), ): """ - Restart backend and WebUI + Restart Flocks service. """ try: restart_all( _restart_service_config( no_browser=no_browser, skip_webui_build=skip_webui_build, + host=host, + port=port, server_host=server_host, server_port=server_port, webui_host=webui_host, @@ -326,14 +294,14 @@ def restart( ), console, ) - except ServiceError as error: + except (ServiceConfigError, ServiceError) as error: _handle_service_error(error) @app.command() def status(): """ - Show backend and WebUI status + Show Flocks service status. """ try: show_status(console) @@ -343,13 +311,13 @@ def status(): @app.command() def logs( - backend: bool = typer.Option(False, "--backend", help="Only show backend logs"), - webui: bool = typer.Option(False, "--webui", help="Only show WebUI logs"), + backend: bool = typer.Option(False, "--backend", help="Only show service logs"), + webui: bool = typer.Option(False, "--webui", help="Only show service logs"), follow: bool = typer.Option(True, "--follow/--no-follow", help="Follow logs in real time"), lines: int = typer.Option(50, "--lines", "-n", min=0, help="Number of recent lines to show"), ): """ - Show backend and WebUI logs + Show Flocks service logs. """ try: show_logs(console, backend=backend, webui=webui, follow=follow, lines=lines) @@ -401,6 +369,39 @@ def serve( ) +@app.command(name="service-daemon", hidden=True) +def service_daemon( + server_host: str = typer.Option("127.0.0.1", "--server-host", help="Backend server host"), + server_port: int = typer.Option(5173, "--server-port", help="Public service port"), + webui_host: str = typer.Option("127.0.0.1", "--webui-host", help="WebUI host"), + webui_port: int = typer.Option(5173, "--webui-port", help="WebUI port"), + legacy_server_host: Optional[str] = typer.Option(None, "--legacy-server-host", help="Legacy backend host"), + legacy_server_port: Optional[int] = typer.Option(8000, "--legacy-server-port", help="Legacy backend port"), + server_port_migration_hint: bool = typer.Option( + False, + "--server-port-migration-hint", + help="Print server-port migration hint in parent CLI", + ), + skip_webui_build: bool = typer.Option(False, "--skip-webui-build", help="Skip WebUI static asset build"), +): + """ + Run the Flocks service supervisor daemon. + """ + run_service_daemon( + ServiceConfig( + backend_host=server_host, + backend_port=server_port, + frontend_host=webui_host, + frontend_port=webui_port, + legacy_backend_host=legacy_server_host, + legacy_backend_port=legacy_server_port, + server_port_migration_hint=server_port_migration_hint, + no_browser=True, + skip_frontend_build=skip_webui_build, + ), + ) + + @app.command() def tui( directory: Optional[Path] = typer.Option(None, "--directory", "-d", help="Project directory"), diff --git a/flocks/cli/service_config.py b/flocks/cli/service_config.py new file mode 100644 index 000000000..7e6df10e2 --- /dev/null +++ b/flocks/cli/service_config.py @@ -0,0 +1,244 @@ +"""Service configuration model and serialization helpers.""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from typing import Any + + +class ServiceConfigError(ValueError): + """Raised when service configuration input is invalid.""" + + +@dataclass(frozen=True) +class ServiceConfig: + backend_host: str = "127.0.0.1" + backend_port: int = 5173 + frontend_host: str = "127.0.0.1" + frontend_port: int = 5173 + legacy_backend_host: str | None = "127.0.0.1" + legacy_backend_port: int | None = 8000 + server_port_migration_hint: bool = False + no_browser: bool = False + skip_frontend_build: bool = False + + @property + def backend_url(self) -> str: + return f"http://{_format_host_for_url(loopback_host(self.backend_host))}:{self.backend_port}" + + @property + def frontend_url(self) -> str: + return self.backend_url + + @property + def legacy_cleanup_config(self) -> "ServiceConfig": + return ServiceConfig( + backend_host=self.legacy_backend_host or self.backend_host, + backend_port=self.legacy_backend_port or self.backend_port, + frontend_host=self.frontend_host, + frontend_port=self.frontend_port, + no_browser=self.no_browser, + server_port_migration_hint=self.server_port_migration_hint, + skip_frontend_build=self.skip_frontend_build, + ) + + +def loopback_host(host: str) -> str: + """Return a local access host for wildcard bind addresses.""" + return "127.0.0.1" if host in {"0.0.0.0", "::"} else host + + +def _format_host_for_url(host: str) -> str: + """Wrap IPv6 literals in brackets before composing URLs.""" + if ":" in host and not host.startswith("["): + return f"[{host}]" + return host + + +def service_config_payload(config: ServiceConfig) -> dict[str, object]: + """Serialize service config for the supervisor control API.""" + return { + "backend_host": config.backend_host, + "backend_port": config.backend_port, + "frontend_host": config.frontend_host, + "frontend_port": config.frontend_port, + "legacy_backend_host": config.legacy_backend_host, + "legacy_backend_port": config.legacy_backend_port, + "server_port_migration_hint": config.server_port_migration_hint, + "no_browser": config.no_browser, + "skip_frontend_build": config.skip_frontend_build, + } + + +def service_config_from_payload( + payload: dict[str, Any], + default: ServiceConfig | None = None, + *, + no_browser: bool | None = None, + skip_frontend_build: bool | None = None, +) -> ServiceConfig: + """Deserialize service config from a control or upgrade payload.""" + base = default or ServiceConfig() + resolved_skip_frontend_build = ( + _bool(payload.get("skip_frontend_build"), base.skip_frontend_build) + if skip_frontend_build is None + else skip_frontend_build + ) + resolved_no_browser = _bool(payload.get("no_browser"), base.no_browser) if no_browser is None else no_browser + return ServiceConfig( + backend_host=_string(payload.get("backend_host"), base.backend_host), + backend_port=_positive_int(payload.get("backend_port"), base.backend_port), + frontend_host=_string(payload.get("frontend_host"), base.frontend_host), + frontend_port=_positive_int(payload.get("frontend_port"), base.frontend_port), + legacy_backend_host=_optional_string(payload.get("legacy_backend_host"), base.legacy_backend_host), + legacy_backend_port=_optional_positive_int(payload.get("legacy_backend_port"), base.legacy_backend_port), + server_port_migration_hint=_bool(payload.get("server_port_migration_hint"), base.server_port_migration_hint), + no_browser=resolved_no_browser, + skip_frontend_build=resolved_skip_frontend_build, + ) + + +def service_config_from_status_payload( + payload: dict[str, Any], + *, + default: ServiceConfig | None = None, + no_browser: bool | None = None, + skip_frontend_build: bool | None = None, +) -> ServiceConfig: + """Extract service config from a supervisor status payload.""" + config = payload.get("config") if isinstance(payload.get("config"), dict) else {} + return service_config_from_payload( + config, + default=default, + no_browser=no_browser, + skip_frontend_build=skip_frontend_build, + ) + + +def restart_defaults_from_status_payload(payload: dict[str, Any]) -> dict[str, Any]: + """Return CLI default overrides from a supervisor status payload.""" + config = payload.get("config") if isinstance(payload.get("config"), dict) else {} + defaults: dict[str, Any] = {} + if isinstance(config.get("backend_host"), str): + defaults["default_server_host"] = config["backend_host"] + if _is_positive_int(config.get("backend_port")): + defaults["default_server_port"] = config["backend_port"] + if isinstance(config.get("frontend_host"), str): + defaults["default_webui_host"] = config["frontend_host"] + if _is_positive_int(config.get("frontend_port")): + defaults["default_webui_port"] = config["frontend_port"] + return defaults + + +def build_service_config( + *, + no_browser: bool = False, + skip_webui_build: bool = False, + public_host: str | None = None, + public_port: int | None = None, + server_host: str | None = None, + server_port: int | None = None, + webui_host: str | None = None, + webui_port: int | None = None, + default_server_host: str, + default_server_port: int, + default_webui_host: str = "127.0.0.1", + default_webui_port: int = 5173, +) -> ServiceConfig: + """Build service config from CLI values, environment, and defaults. + + Static WebUI mode uses the old WebUI endpoint as the public FastAPI + listener so remote deployments keep their existing browser URL. + """ + explicit_public_host = _first_host(public_host, ("FLOCKS_HOST", "FLOCKS_PUBLIC_HOST")) + explicit_public_port = _first_port(public_port, ("FLOCKS_PORT", "FLOCKS_PUBLIC_PORT"), "public") + explicit_webui_host = _first_host(webui_host, ("FLOCKS_WEBUI_HOST", "FLOCKS_FRONTEND_HOST")) + explicit_webui_port = _first_port(webui_port, ("FLOCKS_WEBUI_PORT", "FLOCKS_FRONTEND_PORT"), "webui") + explicit_server_host = _first_host(server_host, ("FLOCKS_SERVER_HOST", "FLOCKS_BACKEND_HOST")) + explicit_server_port = _first_port(server_port, ("FLOCKS_SERVER_PORT", "FLOCKS_BACKEND_PORT"), "server") + + resolved_public_host = explicit_public_host or explicit_webui_host or explicit_server_host or default_webui_host + resolved_public_port = explicit_public_port or explicit_webui_port or explicit_server_port or default_webui_port + legacy_host = explicit_server_host or default_server_host + legacy_port = explicit_server_port or default_server_port + show_server_port_hint = ( + explicit_server_port is not None + and (explicit_public_port is not None or explicit_webui_port is not None) + and explicit_server_port != resolved_public_port + ) + + return ServiceConfig( + backend_host=resolved_public_host, + backend_port=resolved_public_port, + frontend_host=resolved_public_host, + frontend_port=resolved_public_port, + legacy_backend_host=legacy_host, + legacy_backend_port=legacy_port, + server_port_migration_hint=show_server_port_hint, + no_browser=no_browser, + skip_frontend_build=skip_webui_build, + ) + + +def with_frontend_build(config: ServiceConfig, *, skip_frontend_build: bool) -> ServiceConfig: + """Return config with only the WebUI build behavior changed.""" + return ServiceConfig( + backend_host=config.backend_host, + backend_port=config.backend_port, + frontend_host=config.frontend_host, + frontend_port=config.frontend_port, + legacy_backend_host=config.legacy_backend_host, + legacy_backend_port=config.legacy_backend_port, + server_port_migration_hint=config.server_port_migration_hint, + no_browser=config.no_browser, + skip_frontend_build=skip_frontend_build, + ) + + +def _first_host(cli_value: str | None, env_names: tuple[str, ...]) -> str | None: + if cli_value is not None: + return cli_value + for env_name in env_names: + env_value = os.getenv(env_name) + if env_value: + return env_value + return None + + +def _first_port(cli_value: int | None, env_names: tuple[str, ...], label: str) -> int | None: + if cli_value is not None: + return cli_value + for env_name in env_names: + env_value = os.getenv(env_name) + if not env_value: + continue + try: + return int(env_value) + except ValueError as error: + raise ServiceConfigError(f"{label} port from {env_name} must be an integer.") from error + return None + + +def _string(value: Any, fallback: str) -> str: + return value if isinstance(value, str) and value else fallback + + +def _optional_string(value: Any, fallback: str | None) -> str | None: + return value if isinstance(value, str) and value else fallback + + +def _positive_int(value: Any, fallback: int) -> int: + return value if _is_positive_int(value) else fallback + + +def _optional_positive_int(value: Any, fallback: int | None) -> int | None: + return value if _is_positive_int(value) else fallback + + +def _is_positive_int(value: Any) -> bool: + return isinstance(value, int) and not isinstance(value, bool) and value > 0 + + +def _bool(value: Any, fallback: bool) -> bool: + return value if isinstance(value, bool) else fallback diff --git a/flocks/cli/service_control.py b/flocks/cli/service_control.py new file mode 100644 index 000000000..34320fa9f --- /dev/null +++ b/flocks/cli/service_control.py @@ -0,0 +1,283 @@ +"""Local supervisor control API client helpers.""" + +from __future__ import annotations + +import os +import socket +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Iterator + +import httpx + +from flocks.cli.service_config import ServiceConfig, service_config_from_status_payload, service_config_payload + +SUPERVISOR_CONTROL_PORT = 48765 +SUPERVISOR_LOG_FILENAME = "daemon.log" +SUPERVISOR_SOCKET_FILENAME = "service-daemon.sock" + + +@dataclass(frozen=True) +class DaemonStatus: + pid: int | None + uptime: float | None + version: str | None + state: str + log_path: str | None + + +@dataclass(frozen=True) +class ManagedServiceStatus: + pid: int | None + host: str + port: int | None + state: str + health: str + last_error: str | None + restart_count: int + last_restart_at: float | None + log_path: str | None + command: tuple[str, ...] + paused: bool = False + + +@dataclass(frozen=True) +class SupervisorStatus: + daemon: DaemonStatus + backend: ManagedServiceStatus + webui: ManagedServiceStatus + config: ServiceConfig + raw: dict[str, Any] + + +def _default_runtime_paths(): + from flocks.cli.service_manager import runtime_paths + + return runtime_paths() + + +def supervisor_log_path(paths) -> Path: + """Return the supervisor daemon log path.""" + return paths.log_dir / SUPERVISOR_LOG_FILENAME + + +def supervisor_socket_path(paths) -> Path: + """Return the Unix control socket path for the supervisor daemon.""" + return paths.run_dir / SUPERVISOR_SOCKET_FILENAME + + +def supervisor_control_port() -> int: + """Return the local TCP control port used on Windows.""" + raw = os.getenv("FLOCKS_CONTROL_PORT") + if raw and raw.isdigit(): + value = int(raw) + if 0 < value < 65536: + return value + return SUPERVISOR_CONTROL_PORT + + +def supervisor_uses_tcp_control() -> bool: + """Return True when the daemon control API should use localhost TCP.""" + return sys.platform == "win32" or not hasattr(socket, "AF_UNIX") + + +def supervisor_control_client(paths=None, timeout: float | None = 2.0) -> httpx.Client: + """Create a client for the local daemon control API.""" + if supervisor_uses_tcp_control(): + return httpx.Client( + base_url=f"http://127.0.0.1:{supervisor_control_port()}", + timeout=timeout, + trust_env=False, + ) + current = paths or _default_runtime_paths() + transport = httpx.HTTPTransport(uds=str(supervisor_socket_path(current))) + return httpx.Client(base_url="http://flocks.local", timeout=timeout, trust_env=False, transport=transport) + + +def control_api_request( + method: str, + path: str, + *, + paths=None, + timeout: float | None = 2.0, + **kwargs, +) -> httpx.Response: + """Send one local control API request.""" + with supervisor_control_client(paths, timeout=timeout) as client: + response = client.request(method, path, **kwargs) + response.raise_for_status() + return response + + +def supervisor_is_running(paths=None) -> bool: + """Return True when the local supervisor control API responds.""" + try: + control_api_request("GET", "/status", paths=paths, timeout=0.75) + return True + except Exception: + return False + + +def _read_control_json(path: str, *, paths=None, timeout: float | None = 2.0) -> dict[str, Any]: + response = control_api_request("GET", path, paths=paths, timeout=timeout) + payload = response.json() + if not isinstance(payload, dict): + raise RuntimeError("daemon control API returned an invalid response.") + return payload + + +def _post_control_json( + path: str, + *, + payload: dict[str, Any] | None = None, + paths=None, + timeout: float | None = 5.0, +) -> dict[str, Any]: + response = control_api_request("POST", path, paths=paths, timeout=timeout, json=payload or {}) + data = response.json() + if not isinstance(data, dict): + raise RuntimeError("daemon control API returned an invalid response.") + return data + + +def read_supervisor_status(paths=None, timeout: float | None = 2.0) -> SupervisorStatus: + """Read and parse the current supervisor status.""" + return parse_supervisor_status(_read_control_json("/status", paths=paths, timeout=timeout)) + + +def request_stop(paths=None, timeout: float | None = 2.0) -> dict[str, Any]: + """Ask the supervisor daemon to stop itself and its children.""" + return _post_control_json("/stop", paths=paths, timeout=timeout) + + +def request_restart( + config: ServiceConfig, + *, + paths=None, + timeout: float | None = 180.0, +) -> SupervisorStatus: + """Ask the supervisor daemon to restart all managed services.""" + payload = _post_control_json("/restart", payload=service_config_payload(config), paths=paths, timeout=timeout) + return parse_supervisor_status(payload) + + +def request_restart_backend(*, paths=None, timeout: float | None = 180.0) -> SupervisorStatus: + """Ask the supervisor daemon to restart backend.""" + payload = _post_control_json("/restart/backend", paths=paths, timeout=timeout) + return parse_supervisor_status(payload) + + +def request_restart_webui( + config: ServiceConfig, + *, + force_frontend_build: bool = False, + paths=None, + timeout: float | None = 180.0, +) -> SupervisorStatus: + """Ask the supervisor daemon to restart WebUI.""" + payload = service_config_payload(config) + if force_frontend_build: + payload["force_frontend_build"] = True + data = _post_control_json("/restart/webui", payload=payload, paths=paths, timeout=timeout) + return parse_supervisor_status(data) + + +def request_prepare_upgrade(*, paths=None, timeout: float | None = 30.0) -> SupervisorStatus: + """Ask the supervisor daemon to pause managed services for upgrade handoff.""" + payload = _post_control_json("/upgrade/prepare", paths=paths, timeout=timeout) + return parse_supervisor_status(payload) + + +def request_resume_upgrade( + config: ServiceConfig, + *, + paths=None, + timeout: float | None = 180.0, +) -> SupervisorStatus: + """Ask the supervisor daemon to resume managed services after upgrade handoff.""" + payload = _post_control_json("/upgrade/resume", payload=service_config_payload(config), paths=paths, timeout=timeout) + return parse_supervisor_status(payload) + + +def read_logs( + *, + service: str, + lines: int, + paths=None, + timeout: float | None = 5.0, +) -> dict[str, Any]: + """Read recent service logs through the supervisor control API.""" + return _read_control_json( + f"/logs?service={service}&lines={lines}&follow=false", + paths=paths, + timeout=timeout, + ) + + +def stream_logs( + *, + service: str, + lines: int, + paths=None, + timeout: float | None = None, +) -> Iterator[str]: + """Stream service logs through the supervisor control API.""" + params = {"service": service, "lines": str(lines), "follow": "true"} + with supervisor_control_client(paths, timeout=timeout) as client: + with client.stream("GET", "/logs", params=params) as response: + response.raise_for_status() + yield from response.iter_lines() + + +def parse_supervisor_status(payload: dict[str, Any]) -> SupervisorStatus: + """Parse a supervisor status payload into typed status objects.""" + daemon = payload.get("daemon") if isinstance(payload.get("daemon"), dict) else {} + backend = payload.get("backend") if isinstance(payload.get("backend"), dict) else {} + webui = payload.get("webui") if isinstance(payload.get("webui"), dict) else {} + return SupervisorStatus( + daemon=_parse_daemon_status(daemon), + backend=_parse_service_status(backend), + webui=_parse_service_status(webui), + config=service_config_from_status_payload(payload), + raw=payload, + ) + + +def _parse_daemon_status(payload: dict[str, Any]) -> DaemonStatus: + return DaemonStatus( + pid=_optional_int(payload.get("pid")), + uptime=_optional_float(payload.get("uptime")), + version=str(payload["version"]) if payload.get("version") is not None else None, + state=str(payload.get("state") or "unknown"), + log_path=str(payload["log_path"]) if payload.get("log_path") is not None else None, + ) + + +def _parse_service_status(payload: dict[str, Any]) -> ManagedServiceStatus: + command = payload.get("command") if isinstance(payload.get("command"), list) else [] + return ManagedServiceStatus( + pid=_optional_int(payload.get("pid")), + host=str(payload.get("host") or "127.0.0.1"), + port=_optional_int(payload.get("port")), + state=str(payload.get("state") or "unknown"), + health=str(payload.get("health") or payload.get("state") or "unknown"), + last_error=str(payload["last_error"]) if payload.get("last_error") is not None else None, + restart_count=_optional_int(payload.get("restart_count")) or 0, + last_restart_at=_optional_float(payload.get("last_restart_at")), + log_path=str(payload["log_path"]) if payload.get("log_path") is not None else None, + command=tuple(str(item) for item in command), + paused=bool(payload.get("paused")), + ) + + +def _optional_int(value: Any) -> int | None: + return value if isinstance(value, int) and not isinstance(value, bool) else None + + +def _optional_float(value: Any) -> float | None: + if isinstance(value, bool): + return None + if isinstance(value, (float, int)): + return float(value) + return None diff --git a/flocks/cli/service_manager.py b/flocks/cli/service_manager.py index df534ffe7..cc4ce8d44 100644 --- a/flocks/cli/service_manager.py +++ b/flocks/cli/service_manager.py @@ -21,11 +21,23 @@ from dataclasses import dataclass from pathlib import Path from shutil import which -from typing import Iterable, Sequence +from typing import Any, Iterable, Sequence import httpx from flocks.browser.admin import stop_all_daemons as stop_all_browser_daemons +from flocks.cli.service_config import ServiceConfig, loopback_host +from flocks.cli.service_control import ( + read_logs, + read_supervisor_status, + request_restart, + request_stop, + stream_logs, + supervisor_is_running, + supervisor_log_path, + supervisor_socket_path, + supervisor_uses_tcp_control, +) try: import fcntl @@ -48,26 +60,14 @@ "src\\win\\async.c", "src/win/async.c", ) +WATCHDOG_PID_FILENAME = "watchdog.pid" +SUPERVISOR_START_TIMEOUT_SECONDS = 180.0 class ServiceError(RuntimeError): """Raised when a service lifecycle action fails.""" -@dataclass(frozen=True) -class ServiceConfig: - backend_host: str = "127.0.0.1" - backend_port: int = 8000 - frontend_host: str = "127.0.0.1" - frontend_port: int = 5173 - no_browser: bool = False - skip_frontend_build: bool = False - - @property - def frontend_url(self) -> str: - return f"http://{_loopback_host(self.frontend_host)}:{self.frontend_port}" - - @dataclass(frozen=True) class RuntimePaths: root: Path @@ -144,13 +144,23 @@ def ensure_runtime_dirs(paths: RuntimePaths | None = None) -> RuntimePaths: return current +def watchdog_pid_path(paths: RuntimePaths) -> Path: + """Return the watchdog runtime record path.""" + return paths.run_dir / WATCHDOG_PID_FILENAME + + def ensure_install_layout(root: Path | None = None) -> Path: """Validate that the installed repo still contains backend and WebUI code.""" current = root or repo_root() + from flocks.server.static_webui import resolve_webui_dist_dir + if not (current / "pyproject.toml").exists(): - raise ServiceError(f"未找到安装目录中的 pyproject.toml: {current}") + if resolve_webui_dist_dir() is None: + raise ServiceError(f"未找到安装目录中的 pyproject.toml 或 WebUI 静态资源: {current}") + return current if not (current / "webui" / "package.json").exists(): - raise ServiceError("未找到 WebUI 源码,请重新安装 Flocks,或设置 FLOCKS_REPO_ROOT 指向有效安装目录。") + if resolve_webui_dist_dir() is None: + raise ServiceError("未找到 WebUI 静态资源,请重新安装 Flocks,或设置 FLOCKS_REPO_ROOT 指向有效安装目录。") return current @@ -393,36 +403,15 @@ def read_runtime_record(pid_file: Path) -> RuntimeRecord | None: return _parse_runtime_record(raw) -def write_runtime_record(pid_file: Path, record: RuntimeRecord) -> None: - """Persist runtime metadata in a backward-compatible JSON format.""" - payload: dict[str, object] = {"pid": record.pid} - if record.pgid is not None: - payload["pgid"] = record.pgid - if record.host is not None: - payload["host"] = record.host - if record.port is not None: - payload["port"] = record.port - if record.command: - payload["command"] = list(record.command) - if record.started_at is not None: - payload["started_at"] = record.started_at - pid_file.write_text(json.dumps(payload, ensure_ascii=True, sort_keys=True), encoding="utf-8") - - def process_runtime_record( process: subprocess.Popen, *, - host: str, - port: int, + host: str | None, + port: int | None, command: Sequence[str], ) -> RuntimeRecord: """Build runtime metadata for a freshly started service process.""" - pgid = None - if sys.platform != "win32": - try: - pgid = os.getpgid(process.pid) - except OSError: - pgid = None + pgid = _process_group_id(process) return RuntimeRecord( pid=process.pid, pgid=pgid, @@ -433,17 +422,30 @@ def process_runtime_record( ) +def _process_group_id(process: subprocess.Popen) -> int | None: + """Return a cached or live Unix process group id for a managed process.""" + if sys.platform == "win32": + return None + cached = getattr(process, "_flocks_pgid", None) + if isinstance(cached, int) and cached > 0: + return cached + try: + pgid = os.getpgid(process.pid) + except OSError: + return None + try: + setattr(process, "_flocks_pgid", pgid) + except Exception: + pass + return pgid + + def read_pid(pid_file: Path) -> int | None: """Read a pid file if it exists and contains a valid integer.""" record = read_runtime_record(pid_file) return record.pid if record else None -def write_pid(pid_file: Path, pid: int) -> None: - """Persist a process id.""" - write_runtime_record(pid_file, RuntimeRecord(pid=pid)) - - def _unix_process_stat(pid: int) -> str | None: """Return the Unix process status code for a pid, if available.""" if sys.platform == "win32" or pid <= 0: @@ -766,12 +768,6 @@ def _resolve_upgrade_runtime(console, *, frontend_port: int, attempt_recover: bo return result -def _effective_frontend_port(paths: RuntimePaths, default: int) -> int: - recorded_port = _recorded_port(paths.frontend_pid, default) - upgrade_info = _read_upgrade_runtime_info(recorded_port) - return upgrade_info.frontend_port or recorded_port - - def cleanup_stale_pid_file(pid_file: Path) -> None: """Remove pid files that no longer point to running processes.""" if not pid_file.exists(): @@ -787,20 +783,6 @@ def cleanup_stale_pid_file(pid_file: Path) -> None: pid_file.unlink(missing_ok=True) -def backend_is_running(config: ServiceConfig, paths: RuntimePaths | None = None) -> bool: - """Return True if the tracked backend process is running.""" - current = paths or runtime_paths() - cleanup_stale_pid_file(current.backend_pid) - return runtime_record_is_running(read_runtime_record(current.backend_pid)) or port_is_in_use(config.backend_port) - - -def frontend_is_running(config: ServiceConfig, paths: RuntimePaths | None = None) -> bool: - """Return True if the tracked frontend process is running.""" - current = paths or runtime_paths() - cleanup_stale_pid_file(current.frontend_pid) - return runtime_record_is_running(read_runtime_record(current.frontend_pid)) or port_is_in_use(config.frontend_port) - - def _port_owner_lookup_available() -> bool: """Return True when the current platform can resolve listener pids.""" return sys.platform == "win32" or bool(which("lsof") or which("fuser")) @@ -861,6 +843,189 @@ def port_is_in_use(port: int, listeners: Sequence[int] | None = None) -> bool: return not _bind_port_available(port) +def _process_command_line(pid: int) -> str: + """Return a process command line for best-effort orphan detection.""" + if pid <= 0: + return "" + if sys.platform == "win32": + snapshot = _windows_process_snapshot(pid) + return str(snapshot.get("command_line") or "") if snapshot else "" + completed = subprocess.run( + ["ps", "-p", str(pid), "-o", "command="], + check=False, + capture_output=True, + text=True, + ) + return completed.stdout.strip() + + +def _trusted_flocks_port_owner(pid: int, *, service: str, root: Path) -> bool: + """Return True only for port owners that look like Flocks leftovers.""" + command_line = _process_command_line(pid).lower() + if not command_line: + return False + root_text = str(root).lower() + webui_text = str(root / "webui").lower() + if service == "backend": + looks_like_uvicorn_backend = "uvicorn" in command_line and "flocks.server.app:app" in command_line + return ( + looks_like_uvicorn_backend + or ("flocks.cli.main" in command_line and "serve" in command_line) + or ("flocks" in command_line and "serve" in command_line and root_text in command_line) + ) + if service == "webui": + looks_like_vite = "vite" in command_line and ( + "preview" in command_line or "--host" in command_line or "--port" in command_line + ) + looks_like_flocks_webui = ( + webui_text in command_line + or root_text in command_line + or "/flocks/webui/" in command_line + or "\\flocks\\webui\\" in command_line + ) + return looks_like_vite and looks_like_flocks_webui + return False + + +def _terminate_orphan_pid(pid: int, label: str, console, *, timeout: float = 5.0) -> None: + """Terminate a trusted orphan process tree by pid.""" + console.print(f"[flocks] 清理残留 {label} 进程(PID={pid})...") + if sys.platform == "win32": + subprocess.run(["taskkill", "/PID", str(pid), "/T", "/F"], check=False, capture_output=True) + return + + pgid: int | None = None + try: + candidate_pgid = os.getpgid(pid) + if candidate_pgid != os.getpgrp(): + pgid = candidate_pgid + except OSError: + pgid = None + + targets = collect_process_tree_pids(pid) + signal_process_group(signal.SIGTERM, pgid) + signal_pid_list(signal.SIGTERM, targets) + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if not any(pid_is_running(target) for target in targets) and not process_group_is_running(pgid): + return + time.sleep(0.25) + signal_process_group(signal.SIGKILL, pgid) + signal_pid_list(signal.SIGKILL, targets) + + +def cleanup_trusted_port_owners(port: int, *, service: str, label: str, console, root: Path | None = None) -> list[int]: + """Clean Flocks-owned orphan processes that are still occupying a service port.""" + current_root = root or ensure_install_layout() + listeners = port_owner_pids(port) + trusted = [pid for pid in listeners if _trusted_flocks_port_owner(pid, service=service, root=current_root)] + for pid in trusted: + _terminate_orphan_pid(pid, label, console) + if trusted: + deadline = time.monotonic() + 5.0 + while time.monotonic() < deadline: + current = port_owner_pids(port) + if not any(pid in trusted for pid in current): + break + time.sleep(0.25) + return trusted + + +def _process_list_pids() -> list[int]: + """Return process ids for best-effort trusted orphan cleanup.""" + if sys.platform == "win32": + completed = subprocess.run( + [ + "powershell", + "-NoProfile", + "-Command", + "Get-CimInstance Win32_Process | ForEach-Object { $_.ProcessId }", + ], + check=False, + capture_output=True, + text=True, + ) + else: + completed = subprocess.run( + ["ps", "-eo", "pid="], + check=False, + capture_output=True, + text=True, + ) + if completed.returncode != 0: + return [] + pids = [] + for line in completed.stdout.splitlines(): + value = line.strip() + if value.isdigit(): + pids.append(int(value)) + return sorted(dict.fromkeys(pids)) + + +def _windows_trusted_daemon_process_pids(*, root: Path) -> list[int]: + """Return trusted Windows daemon pids with a single process query.""" + if sys.platform != "win32": + return [] + root_text = str(root).lower() + env = os.environ.copy() + env["FLOCKS_DAEMON_ROOT_MATCH"] = root_text + env["FLOCKS_DAEMON_CURRENT_PID"] = str(os.getpid()) + powershell = which("powershell") or which("powershell.exe") + if not powershell: + return [] + script = ( + "$root = [Environment]::GetEnvironmentVariable('FLOCKS_DAEMON_ROOT_MATCH'); " + "$currentPid = [int][Environment]::GetEnvironmentVariable('FLOCKS_DAEMON_CURRENT_PID'); " + "Get-CimInstance Win32_Process | Where-Object { " + "$_.ProcessId -ne $currentPid -and $_.CommandLine -and " + "$_.CommandLine.ToLowerInvariant().Contains('service-daemon') -and " + "$_.CommandLine.ToLowerInvariant().Contains('flocks') -and " + "$_.CommandLine.ToLowerInvariant().Contains($root) " + "} | ForEach-Object { $_.ProcessId }" + ) + completed = subprocess.run( + [powershell, "-NoProfile", "-Command", script], + check=False, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + env=env, + ) + if completed.returncode != 0: + return [] + return sorted( + dict.fromkeys(int(line.strip()) for line in completed.stdout.splitlines() if line.strip().isdigit()) + ) + + +def _trusted_flocks_daemon_owner(pid: int, *, root: Path) -> bool: + """Return True only for daemon processes that belong to this Flocks install.""" + if pid <= 0 or pid == os.getpid(): + return False + command_line = _process_command_line(pid).lower() + if not command_line: + return False + root_text = str(root).lower() + return "service-daemon" in command_line and "flocks" in command_line and root_text in command_line + + +def trusted_daemon_process_pids(*, root: Path | None = None) -> list[int]: + """Return trusted daemon pids for the current Flocks install.""" + current_root = root or ensure_install_layout() + if sys.platform == "win32": + return _windows_trusted_daemon_process_pids(root=current_root) + return [pid for pid in _process_list_pids() if _trusted_flocks_daemon_owner(pid, root=current_root)] + + +def cleanup_trusted_daemon_processes(*, console, root: Path | None = None) -> list[int]: + """Clean trusted Flocks daemon processes whose control API is unavailable.""" + trusted = trusted_daemon_process_pids(root=root) + for pid in trusted: + _terminate_orphan_pid(pid, "daemon", console) + return trusted + + def _is_reachable_response(response: httpx.Response) -> bool: """Return True when an HTTP endpoint is reachable enough for startup checks.""" return response.status_code < 500 @@ -877,6 +1042,17 @@ def _is_running_status_response(response: httpx.Response) -> bool: return isinstance(payload, dict) and payload.get("status") == "running" +def _is_healthy_status_response(response: httpx.Response) -> bool: + """Return True when the backend health endpoint reports healthy.""" + if response.status_code != 200: + return False + try: + payload = response.json() + except ValueError: + return False + return isinstance(payload, dict) and payload.get("status") == "healthy" + + def wait_for_http( urls: Sequence[str], name: str, @@ -902,37 +1078,61 @@ def wait_for_http( raise ServiceError(f"{name} 启动超时,请检查日志。") -def start_backend(config: ServiceConfig, console) -> None: - """Start the backend API service if needed.""" - root = ensure_install_layout() - paths = ensure_runtime_dirs() - cleanup_stale_pid_file(paths.backend_pid) +class _StdoutConsole: + """Console adapter for daemon logs redirected to a file.""" - runtime_record = read_runtime_record(paths.backend_pid) - tracked_pid = runtime_record.pid if runtime_record else None - listeners = port_owner_pids(config.backend_port) - if listeners: - if tracked_pid and tracked_pid in listeners: - console.print(f"[flocks] 后端已在运行,PID={tracked_pid}") + def print(self, *args, **_kwargs) -> None: + sys.stdout.write(" ".join(str(arg) for arg in args) + "\n") + sys.stdout.flush() + + +def _backend_health_url(host: str, port: int) -> str: + return f"http://{_format_host_for_url(access_host(host))}:{port}/api/health" + + +def _terminate_process( + process: subprocess.Popen | None, + name: str, + console, + *, + timeout: float = 10.0, +) -> None: + """Terminate a process and its process group without scanning service ports.""" + if process is None: + return + + record = process_runtime_record(process, host=None, port=None, command=()) + if process.poll() is not None and not process_group_is_running(record.pgid): + return + + console.print(f"[flocks] 停止 {name}(PID={process.pid})...") + if sys.platform == "win32": + if process.poll() is None: + subprocess.run(["taskkill", "/PID", str(process.pid), "/T", "/F"], check=False, capture_output=True) + else: + if record.pgid is not None: + signal_process_group(signal.SIGTERM, record.pgid) + else: + signal_pid_list(signal.SIGTERM, collect_process_tree_pids(process.pid)) + + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if process.poll() is not None and not process_group_is_running(record.pgid): return - raise ServiceError( - f"后端端口 {config.backend_port} 已被占用 (PID: {_join_pids(listeners)})," - "与当前运行时记录不一致,请先执行 `flocks stop` 或手动清理残留进程。" - ) - if port_is_in_use(config.backend_port, listeners): - raise ServiceError( - f"后端端口 {config.backend_port} 已被占用,但当前环境无法识别占用 PID;" - "请先安装 lsof 或手动清理残留进程。" - ) + time.sleep(0.25) - if runtime_record is not None and runtime_record_is_running(runtime_record): - raise ServiceError( - "后端运行记录仍存活,但端口未监听;请先执行 `flocks stop` 清理异常状态后重试。" - ) + console.print(f"[flocks] {name} 未在预期时间内退出,强制终止...") + if sys.platform == "win32": + if process.poll() is None: + subprocess.run(["taskkill", "/PID", str(process.pid), "/T", "/F"], check=False, capture_output=True) + else: + if record.pgid is not None: + signal_process_group(signal.SIGKILL, record.pgid) + signal_pid_list(signal.SIGKILL, collect_process_tree_pids(process.pid)) - if runtime_record is not None: - paths.backend_pid.unlink(missing_ok=True) +def _backend_command_and_env(root: Path, config: ServiceConfig) -> tuple[list[str], dict[str, str]]: + """Build the backend service command and environment.""" command = resolve_flocks_cli_command(root) + [ "serve", "--host", @@ -940,201 +1140,161 @@ def start_backend(config: ServiceConfig, console) -> None: "--port", str(config.backend_port), ] + env = os.environ.copy() + env["_FLOCKS_WEBUI_HOST"] = config.frontend_host + env["_FLOCKS_WEBUI_PORT"] = str(config.frontend_port) + env["PYTHONUNBUFFERED"] = "1" + env.setdefault("FLOCKS_CONSOLE_BASE_URL", DEFAULT_FLOCKS_CONSOLE_BASE_URL) + return command, env - backend_env = os.environ.copy() - backend_env["_FLOCKS_WEBUI_HOST"] = config.frontend_host - backend_env["_FLOCKS_WEBUI_PORT"] = str(config.frontend_port) - backend_env["PYTHONUNBUFFERED"] = "1" - backend_env.setdefault("FLOCKS_CONSOLE_BASE_URL", DEFAULT_FLOCKS_CONSOLE_BASE_URL) - - console.print("[flocks] 启动后端服务...") - process = _spawn_process( - command, - cwd=root, - log_path=paths.backend_log, - env=backend_env, - ) - write_runtime_record( - paths.backend_pid, - process_runtime_record( - process, - host=config.backend_host, - port=config.backend_port, - command=command, - ), - ) - _log_startup_config(paths.backend_log, "backend", config.backend_host, config.backend_port, read_runtime_record(paths.backend_pid)) - try: - wait_for_http( - [backend_access_base_url(config)], - "后端服务", - delay=3.0, - validator=_is_running_status_response, +def _build_webui_dist(root: Path, config: ServiceConfig, console) -> None: + """Build the production WebUI static bundle.""" + npm = resolve_npm_executable() + if not npm: + raise ServiceError("WebUI dist 不存在,且未检测到 npm;请先安装 Node.js 22+(包含 npm)后重试。") + if not node_version_satisfies_requirement(): + raise ServiceError(f"检测到的 Node.js 版本过低。构建 WebUI 至少需要 Node.js {MIN_NODE_MAJOR}+。") + + webui_dir = root / "webui" + if not (webui_dir / "package.json").exists(): + raise ServiceError("未找到 WebUI 源码,无法构建静态资源。") + + console.print("[flocks] 准备 Flocks 静态资源...") + frontend_env = build_frontend_env(config) + run_kwargs: dict[str, object] = {"cwd": webui_dir, "check": False, "env": frontend_env} + if sys.platform == "win32": + run_kwargs.update({"capture_output": True, "text": True, "encoding": "utf-8", "errors": "replace"}) + completed = subprocess.run([npm, "run", "build"], **run_kwargs) + if completed.returncode != 0: + output = "\n".join( + value for value in (getattr(completed, "stdout", None), getattr(completed, "stderr", None)) if value ) - except ServiceError: - _emit_service_log_tail(console, paths.backend_log, "后端") - stop_one(config.backend_port, paths.backend_pid, "后端", console) - raise + if windows_frontend_build_assertion_is_recoverable(webui_dir, output): + console.print("[flocks] WebUI 构建产物已生成,忽略 Windows Node.js 退出断言。") + else: + if output: + console.print(output) + raise ServiceError("WebUI 构建失败。") - console.print(f"[flocks] 后端已启动,日志: {paths.backend_log}") +def _ensure_webui_dist(root: Path, config: ServiceConfig, console) -> None: + """Ensure the FastAPI process can serve the production WebUI bundle.""" + from flocks.server.static_webui import WebUIDistMissingError, ensure_webui_dist_dir -def start_frontend(config: ServiceConfig, console) -> None: - """Build and start the WebUI preview service if needed.""" - root = ensure_install_layout() - paths = ensure_runtime_dirs() - cleanup_stale_pid_file(paths.frontend_pid) + try: + ensure_webui_dist_dir() + return + except WebUIDistMissingError: + if config.skip_frontend_build: + raise + + _build_webui_dist(root, config, console) + ensure_webui_dist_dir() + + +def _cleanup_backend_start_port(port: int, console, *, root: Path) -> list[int]: + """Clean trusted leftovers that can occupy the unified public service port.""" + cleaned: list[int] = [] + cleaned.extend( + cleanup_trusted_port_owners( + port, + service="backend", + label="后端", + console=console, + root=root, + ) + ) + cleaned.extend( + cleanup_trusted_port_owners( + port, + service="webui", + label="WebUI", + console=console, + root=root, + ) + ) + return sorted(dict.fromkeys(cleaned)) - runtime_record = read_runtime_record(paths.frontend_pid) - tracked_pid = runtime_record.pid if runtime_record else None - listeners = port_owner_pids(config.frontend_port) - if listeners: - if tracked_pid and tracked_pid in listeners: - console.print(f"[flocks] WebUI 已在运行,PID={tracked_pid}") - return - upgrade_info = _read_upgrade_runtime_info(config.frontend_port) - if upgrade_info.page_active: - _resolve_upgrade_runtime( - console, - frontend_port=upgrade_info.frontend_port or config.frontend_port, - attempt_recover=False, - ) - cleanup_stale_pid_file(paths.frontend_pid) - runtime_record = read_runtime_record(paths.frontend_pid) - tracked_pid = runtime_record.pid if runtime_record else None - listeners = port_owner_pids(config.frontend_port) - if tracked_pid and tracked_pid in listeners: - console.print(f"[flocks] WebUI 已在运行,PID={tracked_pid}") - return - if not listeners: - tracked_pid = runtime_record.pid if runtime_record else None - else: - raise ServiceError( - f"WebUI 端口 {config.frontend_port} 已被占用 (PID: {_join_pids(listeners)})," - "与当前运行时记录不一致,请先执行 `flocks stop` 或手动清理残留进程。" - ) +def _start_backend_process( + config: ServiceConfig, + console, + *, + paths: RuntimePaths | None = None, +) -> subprocess.Popen: + """Start the backend child process for the supervisor.""" + root = ensure_install_layout() + current = paths if paths is not None else ensure_runtime_dirs() + _ensure_webui_dist(root, config, console) - else: + listeners = port_owner_pids(config.backend_port) + if listeners: + _cleanup_backend_start_port(config.backend_port, console, root=root) + listeners = port_owner_pids(config.backend_port) + if listeners: raise ServiceError( - f"WebUI 端口 {config.frontend_port} 已被占用 (PID: {_join_pids(listeners)})," - "与当前运行时记录不一致,请先执行 `flocks stop` 或手动清理残留进程。" + f"server 端口 {config.backend_port} 已被占用 (PID: {_join_pids(listeners)})," + "请先执行 `flocks stop` 或手动清理残留进程。" ) - elif port_is_in_use(config.frontend_port, listeners): + if port_is_in_use(config.backend_port, listeners): raise ServiceError( - f"WebUI 端口 {config.frontend_port} 已被占用,但当前环境无法识别占用 PID;" + f"server 端口 {config.backend_port} 已被占用,但当前环境无法识别占用 PID;" "请先安装 lsof 或手动清理残留进程。" ) - if runtime_record is not None and runtime_record_is_running(runtime_record): - raise ServiceError( - "WebUI 运行记录仍存活,但端口未监听;请先执行 `flocks stop` 清理异常状态后重试。" - ) - - if runtime_record is not None: - paths.frontend_pid.unlink(missing_ok=True) - - npm = resolve_npm_executable() - if not npm: - raise ServiceError("未检测到 npm,请先安装 Node.js 22+(包含 npm)后重试。") - if not node_version_satisfies_requirement(): - raise ServiceError(f"检测到的 Node.js 版本过低。启动 WebUI 至少需要 Node.js {MIN_NODE_MAJOR}+。") - - webui_dir = root / "webui" - frontend_env = build_frontend_env(config) - if not config.skip_frontend_build: - console.print("[flocks] 构建 WebUI...") - run_kwargs: dict[str, object] = {"cwd": webui_dir, "check": False, "env": frontend_env} - if sys.platform == "win32": - run_kwargs.update({"capture_output": True, "text": True, "encoding": "utf-8", "errors": "replace"}) - completed = subprocess.run([npm, "run", "build"], **run_kwargs) - if completed.returncode != 0: - output = "\n".join( - value for value in (getattr(completed, "stdout", None), getattr(completed, "stderr", None)) if value - ) - if windows_frontend_build_assertion_is_recoverable(webui_dir, output): - console.print("[flocks] WebUI 构建产物已生成,忽略 Windows Node.js 退出断言。") - else: - if output: - console.print(output) - raise ServiceError("WebUI 构建失败。") - - command = [ - npm, - "run", - "preview", - "--", - "--host", - config.frontend_host, - "--port", - str(config.frontend_port), - ] - - console.print("[flocks] 启动 WebUI...") - process = _spawn_process( - command, - cwd=webui_dir, - log_path=paths.frontend_log, - env=frontend_env, - ) - write_runtime_record( - paths.frontend_pid, - process_runtime_record( - process, - host=config.frontend_host, - port=config.frontend_port, - command=command, - ), + command, env = _backend_command_and_env(root, config) + process = _spawn_process(command, cwd=root, log_path=current.backend_log, env=env) + record = process_runtime_record( + process, + host=config.backend_host, + port=config.backend_port, + command=command, ) - _log_startup_config(paths.frontend_log, "webui", config.frontend_host, config.frontend_port, read_runtime_record(paths.frontend_pid)) + _log_startup_config(current.backend_log, "backend", config.backend_host, config.backend_port, record) try: - wait_for_http([config.frontend_url], "WebUI") + wait_for_http( + [backend_access_base_url(config)], + "后端服务", + delay=3.0, + validator=_is_running_status_response, + ) except ServiceError: - _emit_service_log_tail(console, paths.frontend_log, "WebUI") - stop_one(config.frontend_port, paths.frontend_pid, "WebUI", console) + _emit_service_log_tail(console, current.backend_log, "后端") + _terminate_process(process, "后端", console) raise - - console.print(f"[flocks] WebUI 已启动,日志: {paths.frontend_log}") - - -def _tracked_processes_stopped( - port: int, - record: RuntimeRecord | None, - tracked_pids: Iterable[int], -) -> bool: - """Return True when the tracked service no longer has running processes.""" - listeners = port_owner_pids(port) - if port_is_in_use(port, listeners): - return False - if runtime_record_is_running(record): - return False - return not any(pid_is_running(pid) for pid in tracked_pids) + return process -def _runtime_record_pids(record: RuntimeRecord | None) -> list[int]: - """Collect the latest pids implied by a runtime record.""" +def stop_runtime_record_process(pid_file: Path, name: str, console) -> None: + """Stop a legacy pid/runtime record without scanning ports.""" + cleanup_stale_pid_file(pid_file) + record = read_runtime_record(pid_file) if record is None: - return [] + pid_file.unlink(missing_ok=True) + return - result: list[int] = [] - if record.pid > 0: - result = append_unique_pids(result, collect_process_tree_pids(record.pid)) - if record.pgid is not None and sys.platform != "win32": - result = append_unique_pids(result, _process_group_member_pids(record.pgid)) - return result + targets = collect_process_tree_pids(record.pid) + console.print(f"[flocks] 清理旧 {name} 进程(PID={record.pid})...") + if sys.platform == "win32": + subprocess.run(["taskkill", "/PID", str(record.pid), "/T", "/F"], check=False, capture_output=True) + else: + if record.pgid is not None: + signal_process_group(signal.SIGTERM, record.pgid) + else: + signal_pid_list(signal.SIGTERM, targets) + deadline = time.monotonic() + 5.0 + while time.monotonic() < deadline: + if not runtime_record_is_running(record): + pid_file.unlink(missing_ok=True) + return + time.sleep(0.25) + if record.pgid is not None: + signal_process_group(signal.SIGKILL, record.pgid) + signal_pid_list(signal.SIGKILL, targets) - -def _current_stop_targets( - port: int, - record: RuntimeRecord | None, - tracked_pids: Iterable[int], -) -> list[int]: - """Refresh the pid list that stop_one() should verify or force kill.""" - result = append_unique_pids([], tracked_pids) - result = append_unique_pids(result, _runtime_record_pids(record)) - return append_unique_pids(result, port_owner_pids(port)) + pid_file.unlink(missing_ok=True) def signal_process_group(sig: signal.Signals, pgid: int | None) -> None: @@ -1147,86 +1307,8 @@ def signal_process_group(sig: signal.Signals, pgid: int | None) -> None: pass -def stop_one(port: int, pid_file: Path, name: str, console) -> None: - """Stop a single service by tracked pid and/or listening port.""" - cleanup_stale_pid_file(pid_file) - runtime_record = read_runtime_record(pid_file) - tracked_pid = runtime_record.pid if runtime_record else None - listeners = port_owner_pids(port) - - target_pids: list[int] = [] - if tracked_pid is not None: - target_pids = append_unique_pids(target_pids, collect_process_tree_pids(tracked_pid)) - target_pids = append_unique_pids(target_pids, listeners) - if sys.platform == "win32" and runtime_record is not None: - filtered_targets: list[int] = [] - for pid in target_pids: - if pid in listeners: - filtered_targets = append_unique_pids(filtered_targets, [pid]) - continue - if pid == runtime_record.pid and not _windows_runtime_record_matches_pid(runtime_record, pid, listeners): - continue - filtered_targets = append_unique_pids(filtered_targets, [pid]) - target_pids = filtered_targets - - group_running = process_group_is_running(runtime_record.pgid if runtime_record else None) - if not target_pids and not group_running: - if port_is_in_use(port, listeners): - raise ServiceError( - f"{name} 端口 {port} 已被占用,但当前环境无法识别占用 PID;" - "请先安装 lsof 或手动处理该进程。" - ) - pid_file.unlink(missing_ok=True) - console.print(f"[flocks] {name} 未运行。") - return - - details = _join_pids(target_pids) if target_pids else "none" - if runtime_record and runtime_record.pgid is not None and sys.platform != "win32": - details = f"{details}; PGID={runtime_record.pgid}" - console.print(f"[flocks] 停止 {name}(端口 {port},PID: {details})...") - - if sys.platform == "win32": - for pid in target_pids: - subprocess.run(["taskkill", "/PID", str(pid), "/T", "/F"], check=False, capture_output=True) - else: - if runtime_record and runtime_record.pgid is not None: - signal_process_group(signal.SIGTERM, runtime_record.pgid) - else: - signal_pid_list(signal.SIGTERM, target_pids) - for _ in range(10): - current_targets = _current_stop_targets(port, runtime_record, target_pids) - if _tracked_processes_stopped(port, runtime_record, current_targets): - pid_file.unlink(missing_ok=True) - console.print(f"[flocks] {name} 已停止。") - return - time.sleep(1) - - console.print(f"[flocks] {name} 未在预期时间内退出,强制终止...") - force_targets = _current_stop_targets(port, runtime_record, target_pids) - if runtime_record and runtime_record.pgid is not None: - signal_process_group(signal.SIGKILL, runtime_record.pgid) - signal_pid_list(signal.SIGKILL, force_targets) - - for _ in range(10): - force_targets = _current_stop_targets(port, runtime_record, target_pids) - if _tracked_processes_stopped(port, runtime_record, force_targets): - pid_file.unlink(missing_ok=True) - console.print(f"[flocks] {name} 已停止。") - return - if sys.platform == "win32": - for pid in force_targets: - subprocess.run(["taskkill", "/PID", str(pid), "/T", "/F"], check=False, capture_output=True) - else: - if runtime_record and runtime_record.pgid is not None: - signal_process_group(signal.SIGKILL, runtime_record.pgid) - signal_pid_list(signal.SIGKILL, force_targets) - time.sleep(1) - - raise ServiceError(f"{name} 未在预期时间内退出,请手动检查端口 {port}。") - - def _recorded_port(pid_file: Path, default: int) -> int: - """Return the port from a runtime record, falling back to *default*.""" + """Return the port from a legacy runtime record, falling back to *default*.""" record = read_runtime_record(pid_file) if record is not None and record.port is not None: return record.port @@ -1234,7 +1316,7 @@ def _recorded_port(pid_file: Path, default: int) -> int: def _recorded_host(pid_file: Path, default: str) -> str: - """Return the host from a runtime record, falling back to *default*.""" + """Return the host from a legacy runtime record, falling back to *default*.""" record = read_runtime_record(pid_file) if record is not None and record.host: return record.host @@ -1243,7 +1325,7 @@ def _recorded_host(pid_file: Path, default: str) -> str: @contextlib.contextmanager def service_lock(paths: RuntimePaths): - """Serialize lifecycle commands with a cross-process lock file.""" + """Serialize CLI lifecycle commands while starting/stopping the daemon.""" lock_path = paths.run_dir / "service.lock" lock_path.parent.mkdir(parents=True, exist_ok=True) handle = lock_path.open("a+", encoding="utf-8") @@ -1295,157 +1377,429 @@ def _log_startup_config( handle.write(line) -def _resolve_stop_ports( +def _wait_for_supervisor_ready( paths: RuntimePaths, - config: ServiceConfig | None = None, -) -> tuple[int, int]: - """Resolve frontend/backend ports for stop flows. + *, + process: subprocess.Popen | None = None, + timeout: float = SUPERVISOR_START_TIMEOUT_SECONDS, +) -> dict[str, Any]: + """Wait for the supervisor control API and managed services to become ready.""" + deadline = time.monotonic() + timeout + last_payload: dict[str, Any] | None = None + while time.monotonic() < deadline: + if process is not None and process.poll() is not None: + raise ServiceError(f"Flocks daemon 启动失败,退出码: {process.returncode}") + try: + status = read_supervisor_status(paths=paths, timeout=1.0) + last_payload = status.raw + backend_state = status.backend.state + webui_state = status.webui.state + if backend_state == "healthy" and webui_state in {"healthy", "static"}: + return status.raw + if backend_state == "degraded" or webui_state == "degraded": + return status.raw + except Exception: + pass + time.sleep(0.5) + if last_payload is not None: + return last_payload + raise ServiceError("Flocks daemon 启动超时,请检查日志。") + + +def _startup_payload_is_ready(payload: dict[str, Any]) -> bool: + """Return whether startup status represents a usable Flocks service.""" + backend = payload.get("backend") if isinstance(payload.get("backend"), dict) else {} + webui = payload.get("webui") if isinstance(payload.get("webui"), dict) else {} + backend_state = str(backend.get("state") or "").lower() + webui_state = str(webui.get("state") or "").lower() + return backend_state == "healthy" and webui_state in {"healthy", "static"} + + +def _startup_failure_message(payload: dict[str, Any]) -> str: + """Build a concise error for failed startup status payloads.""" + daemon = payload.get("daemon") if isinstance(payload.get("daemon"), dict) else {} + backend = payload.get("backend") if isinstance(payload.get("backend"), dict) else {} + webui = payload.get("webui") if isinstance(payload.get("webui"), dict) else {} + details = [] + backend_error = backend.get("last_error") + webui_error = webui.get("last_error") + details.append(f"flocks state={backend.get('state') or 'unknown'}") + if backend_error: + details.append(f"last_error={backend_error}") + if webui.get("state") not in {"healthy", "static"}: + details.append(f"webui state={webui.get('state') or 'unknown'}") + if webui_error and webui_error != backend_error: + details.append(f"webui_error={webui_error}") + log_path = backend.get("log_path") or daemon.get("log_path") + suffix = f";日志: {log_path}" if log_path else "" + return f"Flocks service 启动失败({', '.join(details)}){suffix}" + + +def _start_supervisor_process(config: ServiceConfig, paths: RuntimePaths, console) -> subprocess.Popen: + """Spawn the detached service supervisor daemon.""" + root = ensure_install_layout() + log_path = supervisor_log_path(paths) + if not supervisor_uses_tcp_control(): + supervisor_socket_path(paths).unlink(missing_ok=True) + command = resolve_flocks_cli_command(root) + [ + "service-daemon", + "--server-host", + config.backend_host, + "--server-port", + str(config.backend_port), + "--webui-host", + config.frontend_host, + "--webui-port", + str(config.frontend_port), + ] + if config.legacy_backend_host is not None: + command.extend(["--legacy-server-host", config.legacy_backend_host]) + if config.legacy_backend_port is not None: + command.extend(["--legacy-server-port", str(config.legacy_backend_port)]) + if config.server_port_migration_hint: + command.append("--server-port-migration-hint") + if config.skip_frontend_build: + command.append("--skip-webui-build") + env = os.environ.copy() + env["PYTHONUNBUFFERED"] = "1" + return _spawn_process(command, cwd=root, log_path=log_path, env=env) - When a runtime record is missing or uses the legacy pid-only format, - ``start`` and ``restart`` should fall back to the current CLI config - rather than the static default ports. - """ - frontend_default = config.frontend_port if config is not None else ServiceConfig.frontend_port - backend_default = config.backend_port if config is not None else ServiceConfig.backend_port + +def _service_config_matches(left: ServiceConfig, right: ServiceConfig) -> bool: + """Return True when two configs manage the same service endpoints.""" return ( - _effective_frontend_port(paths, frontend_default), - _recorded_port(paths.backend_pid, backend_default), + left.backend_host == right.backend_host + and left.backend_port == right.backend_port + and left.frontend_host == right.frontend_host + and left.frontend_port == right.frontend_port ) -def _stop_all_locked( - paths: RuntimePaths, - console, - *, - config: ServiceConfig | None = None, -) -> None: - """Stop frontend then backend while reusing the caller's lock.""" - fe_port, be_port = _resolve_stop_ports(paths, config) - try: - _resolve_upgrade_runtime(console, frontend_port=fe_port, attempt_recover=False) - stop_one(fe_port, paths.frontend_pid, "WebUI", console) - stop_one(be_port, paths.backend_pid, "后端", console) - finally: +def _supervisor_backend_is_healthy(status) -> bool: + """Return whether a supervisor status represents an accessible Flocks service.""" + return ( + not status.backend.paused + and status.backend.state.lower() == "healthy" + and status.backend.health.lower() == "healthy" + ) + + +def _legacy_runtime_config(paths: RuntimePaths, fallback: ServiceConfig) -> ServiceConfig: + """Build cleanup config from legacy runtime records when present.""" + return ServiceConfig( + backend_host=_recorded_host(paths.backend_pid, fallback.backend_host), + backend_port=_recorded_port(paths.backend_pid, fallback.backend_port), + frontend_host=_recorded_host(paths.frontend_pid, fallback.frontend_host), + frontend_port=_recorded_port(paths.frontend_pid, fallback.frontend_port), + legacy_backend_host=fallback.legacy_backend_host, + legacy_backend_port=fallback.legacy_backend_port, + no_browser=fallback.no_browser, + skip_frontend_build=fallback.skip_frontend_build, + ) + + +def _unique_cleanup_configs(*configs: ServiceConfig) -> list[ServiceConfig]: + """Deduplicate cleanup configs by backend/WebUI ports.""" + result: list[ServiceConfig] = [] + seen: set[tuple[int, int, int | None]] = set() + for config in configs: + key = (config.backend_port, config.frontend_port, config.legacy_backend_port) + if key in seen: + continue + seen.add(key) + result.append(config) + return result + + +def cleanup_legacy_runtime_processes(paths: RuntimePaths, console) -> None: + """Clean legacy pid/runtime records left by pre-daemon service starts.""" + for pid_file, name in ( + (watchdog_pid_path(paths), "watchdog"), + (paths.frontend_pid, "WebUI"), + (paths.backend_pid, "后端"), + ): + stop_runtime_record_process(pid_file, name, console) + + +def _stop_all_unlocked(console, *, paths: RuntimePaths) -> None: + """Stop managed services; caller must hold the lifecycle lock.""" + cleanup_config = ServiceConfig() + legacy_config = _legacy_runtime_config(paths, cleanup_config) + stop_status = None + if not supervisor_is_running(paths): + console.print("[flocks] Flocks daemon 未运行。") + cleanup_legacy_runtime_processes(paths, console) + cleanup_orphan_service_ports(cleanup_config, console, extra_configs=[legacy_config]) stop_all_browser_daemons() + return + try: + stop_status = read_supervisor_status(paths=paths, timeout=1.0) + cleanup_config = stop_status.config + legacy_config = _legacy_runtime_config(paths, cleanup_config) + except Exception: + pass + try: + request_stop(paths=paths, timeout=2.0) + except Exception as exc: + raise ServiceError(f"无法请求 Flocks daemon 停止: {exc}") from exc + + deadline = time.monotonic() + 20.0 + while time.monotonic() < deadline: + if not supervisor_is_running(paths): + cleanup_legacy_runtime_processes(paths, console) + cleanup_orphan_service_ports(cleanup_config, console, extra_configs=[legacy_config]) + stop_all_browser_daemons() + _print_stop_summary(console, stop_status) + return + time.sleep(0.5) + raise ServiceError("Flocks daemon 未在预期时间内退出。") def stop_all(console) -> None: - """Stop frontend then backend using ports persisted in runtime records.""" + """Stop managed services through the supervisor control API.""" paths = ensure_runtime_dirs() with service_lock(paths): - _stop_all_locked(paths, console) + _stop_all_unlocked(console, paths=paths) def _start_all_without_stop(config: ServiceConfig, console) -> None: - """Start backend and frontend, then print access summary.""" - ensure_runtime_dirs() - start_backend(config, console) - start_frontend(config, console) - show_start_summary(config, console) + """Start the supervisor daemon, then print access summary.""" + paths = ensure_runtime_dirs() + _print_static_port_migration_hint(config, console) + console.print("[flocks] Flocks daemon 启动中...") + cleanup_legacy_runtime_processes(paths, console) + cleanup_orphan_service_ports(config, console) + _ensure_webui_dist(ensure_install_layout(), config, console) + process = _start_supervisor_process(config, paths, console) + console.print("[flocks] Flocks daemon 已启动。") + payload = _wait_for_supervisor_ready(paths, process=process) + _print_status_payload(payload, console, include_daemon_step=False) + if not _startup_payload_is_ready(payload): + raise ServiceError(_startup_failure_message(payload)) if not config.no_browser: open_default_browser(config.frontend_url, console) +def _start_all_unlocked(config: ServiceConfig, console, *, paths: RuntimePaths) -> None: + """Ensure the supervisor daemon is running; caller must hold lifecycle lock.""" + _resolve_upgrade_runtime(console, frontend_port=config.frontend_port, attempt_recover=False) + if supervisor_is_running(paths): + status = None + try: + status = read_supervisor_status(paths=paths, timeout=1.0) + except Exception: + status = None + if status is not None and not _service_config_matches(config, status.config): + console.print("[flocks] Flocks daemon 已在运行,但配置已变化,正在按新配置重启...") + _stop_all_unlocked(console, paths=paths) + _start_all_without_stop(config, console) + return + if status is not None and (status.backend.paused or status.backend.state.lower() == "paused"): + console.print("[flocks] Flocks daemon 已在运行,但 Flocks service 处于暂停状态,正在重新启动...") + _stop_all_unlocked(console, paths=paths) + _start_all_without_stop(config, console) + return + if status is not None and not _supervisor_backend_is_healthy(status): + console.print("[flocks] Flocks daemon 已在运行,但 Flocks service 不可用,正在重启...") + status = request_restart(config, paths=paths) + _print_status_payload(status.raw, console, include_daemon_step=False) + if not _startup_payload_is_ready(status.raw): + raise ServiceError(_startup_failure_message(status.raw)) + if not config.no_browser and _supervisor_backend_is_healthy(status): + open_default_browser(_frontend_url_from_status(status, config.frontend_url), console) + return + console.print("[flocks] Flocks daemon 已在运行。") + show_status(console) + if status is not None and not config.no_browser and _supervisor_backend_is_healthy(status): + try: + url = _frontend_url_from_status(status, config.frontend_url) + except Exception: + url = config.frontend_url + open_default_browser(url, console) + return + _start_all_without_stop(config, console) + + def start_all(config: ServiceConfig, console) -> None: - """Ensure backend and frontend are restarted with a clean state.""" + """Ensure the supervisor daemon is running.""" paths = ensure_runtime_dirs() with service_lock(paths): - _stop_all_locked(paths, console, config=config) - _start_all_without_stop(config, console) + _start_all_unlocked(config, console, paths=paths) def restart_all(config: ServiceConfig, console) -> None: - """Restart backend and frontend.""" + """Restart by stopping the daemon first, then starting a fresh daemon.""" paths = ensure_runtime_dirs() with service_lock(paths): - _stop_all_locked(paths, console, config=config) - _start_all_without_stop(config, console) + _stop_all_unlocked(console, paths=paths) + _start_all_unlocked(config, console, paths=paths) -def build_status_lines(paths: RuntimePaths | None = None) -> list[str]: - """Return a human-readable status summary.""" - current = paths or runtime_paths() - cleanup_stale_pid_file(current.backend_pid) - cleanup_stale_pid_file(current.frontend_pid) - - backend_record = read_runtime_record(current.backend_pid) - frontend_record = read_runtime_record(current.frontend_pid) - backend_port = _recorded_port(current.backend_pid, ServiceConfig.backend_port) - frontend_port = _recorded_port(current.frontend_pid, ServiceConfig.frontend_port) - backend_host = _loopback_host(_recorded_host(current.backend_pid, ServiceConfig.backend_host)) - frontend_host = _loopback_host(_recorded_host(current.frontend_pid, ServiceConfig.frontend_host)) - upgrade_info = _read_upgrade_runtime_info(frontend_port) - if frontend_record is None and upgrade_info.frontend_port is not None: - frontend_port = upgrade_info.frontend_port - if frontend_record is None and upgrade_info.frontend_host: - frontend_host = _loopback_host(upgrade_info.frontend_host) - backend_pid = backend_record.pid if backend_record else None - frontend_pid = frontend_record.pid if frontend_record else None - backend_listeners = port_owner_pids(backend_port) - frontend_listeners = port_owner_pids(frontend_port) - backend_in_use = port_is_in_use(backend_port, backend_listeners) - frontend_in_use = port_is_in_use(frontend_port, frontend_listeners) - - lines: list[str] = [] - if backend_listeners: - lines.append( - f"[flocks] 后端运行中: PID={_join_pids(backend_listeners)} URL=http://{backend_host}:{backend_port}" - ) - elif backend_in_use: - lines.append(f"[flocks] 后端运行中: PID=unknown URL=http://{backend_host}:{backend_port}") - elif pid_is_running(backend_pid): - lines.append(f"[flocks] 后端主进程仍在运行,但端口 {backend_port} 未监听: PID={backend_pid}") - elif process_group_is_running(backend_record.pgid if backend_record else None): - lines.append(f"[flocks] 后端进程组仍在运行,但端口 {backend_port} 未监听: PGID={backend_record.pgid}") - else: - lines.append("[flocks] 后端未运行") +def _print_static_port_migration_hint(config: ServiceConfig, console) -> None: + """Explain legacy server-port behavior when it differs from public WebUI port.""" + if ( + not config.server_port_migration_hint + or config.legacy_backend_port is None + or config.legacy_backend_port == config.backend_port + ): + return + console.print( + "[flocks] API 已与 WebUI 同源," + f"当前统一监听端口为 {config.backend_port};旧 server 端口 {config.legacy_backend_port} 仅用于残留清理。" + ) + + +def _print_stop_summary(console, status) -> None: + """Print stopped services from the last available supervisor status.""" + if status is not None: + if status.backend.pid is not None: + console.print(f"[flocks] flocks 已停止(PID={status.backend.pid})。") + console.print("[flocks] daemon 已停止。") - if upgrade_info.page_active: - lines.append( - f"[flocks] WebUI 临时升级页运行中: PID={_join_pids(upgrade_info.listener_pids)} URL=http://{frontend_host}:{frontend_port}" + +def cleanup_orphan_service_ports(config: ServiceConfig, console, *, extra_configs: Sequence[ServiceConfig] = ()) -> None: + """Clean trusted Flocks leftovers on configured backend/WebUI ports.""" + root = ensure_install_layout() + cleanup_trusted_daemon_processes(console=console, root=root) + candidates: list[ServiceConfig] = [] + for candidate in (config, config.legacy_cleanup_config, *extra_configs): + candidates.append(candidate) + candidates.append(candidate.legacy_cleanup_config) + for cleanup_config in _unique_cleanup_configs(*candidates): + cleanup_trusted_port_owners( + cleanup_config.backend_port, + service="backend", + label="后端", + console=console, + root=root, ) - elif frontend_listeners: - lines.append( - f"[flocks] WebUI 运行中: PID={_join_pids(frontend_listeners)} URL=http://{frontend_host}:{frontend_port}" + cleanup_trusted_port_owners( + cleanup_config.backend_port, + service="webui", + label="WebUI", + console=console, + root=root, ) - elif frontend_in_use: - lines.append(f"[flocks] WebUI 运行中: PID=unknown URL=http://{frontend_host}:{frontend_port}") - elif pid_is_running(frontend_pid): - lines.append(f"[flocks] WebUI 主进程仍在运行,但端口 {frontend_port} 未监听: PID={frontend_pid}") - elif process_group_is_running(frontend_record.pgid if frontend_record else None): - lines.append(f"[flocks] WebUI 进程组仍在运行,但端口 {frontend_port} 未监听: PGID={frontend_record.pgid}") - else: - lines.append("[flocks] WebUI 未运行") + cleanup_trusted_port_owners( + cleanup_config.frontend_port, + service="webui", + label="WebUI", + console=console, + root=root, + ) + cleanup_trusted_port_owners( + cleanup_config.frontend_port, + service="backend", + label="后端", + console=console, + root=root, + ) + + +def build_status_lines(paths: RuntimePaths | None = None) -> list[str]: + """Return a human-readable status summary from the supervisor control API.""" + current = paths or runtime_paths() + try: + status = read_supervisor_status(paths=current) + except Exception: + residual_daemons = [] + try: + residual_daemons = trusted_daemon_process_pids(root=ensure_install_layout()) + except Exception: + residual_daemons = [] + if residual_daemons: + return [ + "[flocks] Flocks daemon control API 未运行", + f"[flocks] 检测到残留 daemon 进程: PID={_join_pids(residual_daemons)}", + f"[flocks] 日志: {supervisor_log_path(current)}", + "[flocks] 可执行 `flocks stop` 清理残留进程。", + ] + return [ + "[flocks] Flocks daemon 未运行", + f"[flocks] 日志: {supervisor_log_path(current)}", + ] + return _status_lines_from_payload(status.raw) + + +def _status_lines_from_payload(payload: dict[str, Any]) -> list[str]: + daemon = payload.get("daemon") if isinstance(payload.get("daemon"), dict) else {} + backend = payload.get("backend") if isinstance(payload.get("backend"), dict) else {} + lines = [ + "[flocks] 服务", + _daemon_status_line(daemon), + _service_status_line("flocks", backend), + "", + "[flocks] 日志", + f"[flocks] daemon: {daemon.get('log_path')}", + ] + log_path = backend.get("log_path") + if log_path: + lines.append(f"[flocks] flocks: {log_path}") + return lines + + +def _service_status_line(label: str, payload: dict[str, Any]) -> str: + host = _loopback_host(str(payload.get("host") or "127.0.0.1")) + port = payload.get("port") + pid = payload.get("pid") + state = payload.get("state") or "unknown" + error = payload.get("last_error") + suffix = f" last_error={error}" if error else "" + pid_part = f" PID={pid}" if pid is not None else "" + return f"[flocks] {label}: state={state}{pid_part} URL=http://{host}:{port}{suffix}" - if upgrade_info.payload_present: - lines.append("[flocks] 检测到未完成的升级恢复状态") - lines.append(f"[flocks] 后端日志: {current.backend_log}") - lines.append(f"[flocks] WebUI 日志: {current.frontend_log}") +def _daemon_status_line(payload: dict[str, Any]) -> str: + pid = payload.get("pid") + state = payload.get("state") or "unknown" + error = payload.get("last_error") + suffix = f" last_error={error}" if error else "" + return f"[flocks] daemon: state={state} PID={pid}{suffix}" + + +def _startup_step_status(state: object, *, ready_states: set[str]) -> str: + return "已启动" if str(state or "").lower() in ready_states else "启动异常" + + +def _startup_status_lines_from_payload(payload: dict[str, Any], *, include_daemon_step: bool = True) -> list[str]: + daemon = payload.get("daemon") if isinstance(payload.get("daemon"), dict) else {} + backend = payload.get("backend") if isinstance(payload.get("backend"), dict) else {} + lines = [] + if include_daemon_step: + lines.append(f"[flocks] Flocks daemon {_startup_step_status(daemon.get('state'), ready_states={'running'})}。") + lines.extend([ + f"[flocks] Flocks service {_startup_step_status(backend.get('state'), ready_states={'healthy'})}。", + "", + "[flocks] 服务", + _daemon_status_line(daemon), + _service_status_line("flocks", backend), + "", + "[flocks] 日志", + f"[flocks] daemon: {daemon.get('log_path')}", + ]) + log_path = backend.get("log_path") + if log_path: + lines.append(f"[flocks] flocks: {log_path}") return lines +def _frontend_url_from_status(status, fallback: str) -> str: + if status.backend.port is not None: + return f"http://{_format_host_for_url(_loopback_host(status.backend.host))}:{status.backend.port}" + return fallback + + +def _print_status_payload(payload: dict[str, Any], console, *, include_daemon_step: bool = True) -> None: + for line in _startup_status_lines_from_payload(payload, include_daemon_step=include_daemon_step): + console.print(line) + + def show_status(console) -> None: """Print service status.""" for line in build_status_lines(): console.print(line) -def show_start_summary(config: ServiceConfig, console) -> None: - """Print URLs and log locations after startup.""" - paths = ensure_runtime_dirs() - console.print() - console.print("[flocks] 日志:") - console.print(f"[flocks] 后端: {paths.backend_log}") - console.print(f"[flocks] WebUI: {paths.frontend_log}") - console.print() - console.print("[flocks] 后端接口:") - console.print(f"[flocks] http://{_loopback_host(config.backend_host)}:{config.backend_port}") - console.print() - console.print("[flocks] 打开浏览器访问:") - console.print(f"[flocks] {config.frontend_url}") - - def show_logs( console, *, @@ -1454,58 +1808,111 @@ def show_logs( follow: bool = True, lines: int = 50, ) -> None: - """Print recent service logs and optionally follow them.""" + """Print recent service logs through the supervisor control API.""" paths = ensure_runtime_dirs() - selections = selected_log_paths(paths, backend=backend, webui=webui) - prefixes = {paths.backend_log: "backend", paths.frontend_log: "webui"} + service = "all" + if backend and not webui: + service = "backend" + elif webui and not backend: + service = "webui" + if not follow: + try: + payload = read_logs(service=service, lines=lines, paths=paths, timeout=5.0) + except Exception as exc: + console.print(f"[flocks] Flocks daemon 日志接口不可用,改为读取本地日志文件: {exc}") + _show_local_logs(console, paths, backend=backend, webui=webui, follow=False, lines=lines) + return + logs = payload.get("logs") if isinstance(payload.get("logs"), dict) else {} + for prefix, entry in logs.items(): + if not isinstance(entry, dict): + continue + console.print(f"[{prefix}] --- {entry.get('path')} ---") + for line in entry.get("lines") or []: + console.print(f"[{prefix}] {line}") + return - for path in selections: + console.print("[flocks] 按 Ctrl+C 退出日志跟随。") + try: + for line in stream_logs(service=service, lines=lines, paths=paths, timeout=None): + console.print(line) + except KeyboardInterrupt: + return + except Exception as exc: + console.print(f"[flocks] Flocks daemon 日志接口不可用,改为跟随本地日志文件: {exc}") + _show_local_logs(console, paths, backend=backend, webui=webui, follow=True, lines=lines) + + +def selected_log_paths( + paths: RuntimePaths, + *, + backend: bool = False, + webui: bool = False, +) -> list[Path]: + """Return the log files selected by CLI flags.""" + if backend and not webui: + return [paths.backend_log] + if webui and not backend: + return [paths.backend_log] + return [paths.backend_log] + + +def _selected_log_entries(paths: RuntimePaths, *, backend: bool = False, webui: bool = False) -> list[tuple[str, Path]]: + """Return local log files selected by CLI flags.""" + if backend and not webui: + return [("flocks", paths.backend_log)] + if webui and not backend: + return [("flocks", paths.backend_log)] + return [ + ("flocks", paths.backend_log), + ("daemon", supervisor_log_path(paths)), + ] + + +def _show_local_logs( + console, + paths: RuntimePaths, + *, + backend: bool = False, + webui: bool = False, + follow: bool = True, + lines: int = 50, +) -> None: + """Print local log files when the daemon control API is unavailable.""" + selections = _selected_log_entries(paths, backend=backend, webui=webui) + for prefix, path in selections: + path.parent.mkdir(parents=True, exist_ok=True) path.touch(exist_ok=True) - console.print(f"[{prefixes[path]}] --- {path} ---") + console.print(f"[{prefix}] --- {path} ---") for line in tail_lines(path, lines): - console.print(f"[{prefixes[path]}] {line}") + console.print(f"[{prefix}] {line}") if not follow: return - console.print("[flocks] 按 Ctrl+C 退出日志跟随。") handles = {} try: - for path in selections: + for prefix, path in selections: handle = path.open("r", encoding="utf-8", errors="replace") handle.seek(0, os.SEEK_END) - handles[path] = handle - + handles[prefix] = handle while True: emitted = False - for path, handle in handles.items(): + for prefix, handle in handles.items(): while True: line = handle.readline() if not line: break emitted = True - console.print(f"[{prefixes[path]}] {line.rstrip()}") + console.print(f"[{prefix}] {line.rstrip()}") if not emitted: time.sleep(FOLLOW_POLL_INTERVAL) + except KeyboardInterrupt: + return finally: for handle in handles.values(): handle.close() -def selected_log_paths( - paths: RuntimePaths, - *, - backend: bool = False, - webui: bool = False, -) -> list[Path]: - """Return the log files selected by CLI flags.""" - if backend and not webui: - return [paths.backend_log] - if webui and not backend: - return [paths.frontend_log] - return [paths.backend_log, paths.frontend_log] - - def tail_lines(path: Path, lines: int) -> list[str]: """Read the last N lines from a text file.""" with path.open("r", encoding="utf-8", errors="replace") as handle: @@ -1596,9 +2003,18 @@ def signal_pid_list(sig: signal.Signals, pids: Iterable[int]) -> None: def open_default_browser(url: str, console) -> None: """Best-effort browser open.""" + if sys.platform == "win32": + startfile = getattr(os, "startfile", None) + if startfile is not None: + try: + startfile(url) + console.print(f"[flocks] 浏览器已打开: {url}") + return + except Exception: + pass try: if webbrowser.open(url): - console.print(f"[flocks] 已使用默认浏览器打开: {url}") + console.print(f"[flocks] 浏览器已打开: {url}") return except Exception: pass @@ -1607,7 +2023,7 @@ def open_default_browser(url: str, console) -> None: def access_host(host: str) -> str: """Return the host that local health checks and browser requests should use.""" - return _loopback_host(host) + return loopback_host(host) def _format_host_for_url(host: str) -> str: @@ -1707,7 +2123,7 @@ def _spawn_process( _cap_service_log_file(log_path, MAX_SERVICE_LOG_BYTES) handle = log_path.open("a", encoding="utf-8") try: - return subprocess.Popen( + process = subprocess.Popen( list(command), cwd=cwd, env=env, @@ -1717,6 +2133,8 @@ def _spawn_process( creationflags=creationflags, **kwargs, ) + _process_group_id(process) + return process finally: handle.close() @@ -1792,7 +2210,7 @@ def _join_pids(pids: Iterable[int]) -> str: def _loopback_host(host: str) -> str: - return "127.0.0.1" if host in {"0.0.0.0", "::"} else host + return loopback_host(host) def _http_to_ws_url(url: str) -> str: diff --git a/flocks/cli/service_process.py b/flocks/cli/service_process.py new file mode 100644 index 000000000..d6407417e --- /dev/null +++ b/flocks/cli/service_process.py @@ -0,0 +1,95 @@ +"""Process adapters used by the service supervisor.""" + +from __future__ import annotations + +import socket +import subprocess +from dataclasses import dataclass +from typing import Protocol + +import httpx + +from flocks.cli.service_config import ServiceConfig + + +@dataclass(frozen=True) +class ServiceProbeResult: + healthy: bool + reason: str | None = None + restart: bool = False + + +class ProcessAdapter(Protocol): + name: str + label: str + + def start(self, config: ServiceConfig, paths, *, built_once: bool = False) -> subprocess.Popen: + """Start the service process.""" + + def stop(self, process: subprocess.Popen | None) -> None: + """Stop the service process group.""" + + def probe(self, process: subprocess.Popen | None, host: str, port: int) -> ServiceProbeResult: + """Probe service process and listener health.""" + + +class BackendProcessAdapter: + name = "backend" + label = "后端" + + def start(self, config: ServiceConfig, paths, *, built_once: bool = False) -> subprocess.Popen: + del built_once + from flocks.cli.service_manager import _StdoutConsole, _start_backend_process + + return _start_backend_process(config, _StdoutConsole(), paths=paths) + + def stop(self, process: subprocess.Popen | None) -> None: + from flocks.cli.service_manager import _StdoutConsole, _terminate_process + + _terminate_process(process, self.label, _StdoutConsole()) + + def probe(self, process: subprocess.Popen | None, host: str, port: int) -> ServiceProbeResult: + if process is None: + return ServiceProbeResult(healthy=False, reason="stopped") + if process.poll() is not None: + return ServiceProbeResult( + healthy=False, + reason=f"process exited with code {process.returncode}", + restart=True, + ) + if not tcp_port_accepts_connections(host, port): + return ServiceProbeResult(healthy=False, reason=f"port {port} is not listening", restart=True) + + from flocks.cli.service_manager import _backend_health_url, _is_healthy_status_response, backend_access_base_url + + url = _backend_health_url(host, port) + try: + with httpx.Client(timeout=2.0, trust_env=False) as client: + response = client.get(url) + root_response = client.get( + backend_access_base_url(ServiceConfig(backend_host=host, backend_port=port)), + headers={"Accept": "text/html"}, + ) + healthy = _is_healthy_status_response(response) and _is_static_webui_response(root_response) + reason = f"health status={response.status_code}, root status={root_response.status_code}" + except Exception as exc: + healthy = False + reason = f"health failed: {exc}" + return ServiceProbeResult(healthy=healthy, reason=reason) + + +def _is_static_webui_response(response: httpx.Response) -> bool: + """Return True only when the unified service serves the SPA index.""" + content_type = response.headers.get("content-type", "").lower() + return response.status_code == 200 and "text/html" in content_type + + +def tcp_port_accepts_connections(host: str, port: int) -> bool: + """Return True when a local service accepts TCP connections.""" + from flocks.cli.service_manager import access_host + + try: + with socket.create_connection((access_host(host), port), timeout=1.0): + return True + except OSError: + return False diff --git a/flocks/cli/service_supervisor.py b/flocks/cli/service_supervisor.py new file mode 100644 index 000000000..e94397300 --- /dev/null +++ b/flocks/cli/service_supervisor.py @@ -0,0 +1,580 @@ +"""Supervisor daemon for the local Flocks service.""" + +from __future__ import annotations + +import datetime +import json +import os +import signal +import socket +import subprocess +import sys +import threading +import time +from dataclasses import dataclass +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any +from urllib.parse import parse_qs, urlparse + +from flocks.browser.admin import stop_all_daemons as stop_all_browser_daemons +from flocks.cli.service_config import service_config_from_payload, service_config_payload +from flocks.cli.service_control import ( + supervisor_control_port, + supervisor_log_path, + supervisor_socket_path, + supervisor_uses_tcp_control, +) +from flocks.cli.service_process import BackendProcessAdapter, ProcessAdapter + +SUPERVISOR_CHECK_INTERVAL_SECONDS = 5.0 +SUPERVISOR_HEALTH_FAILURE_THRESHOLD = 2 +SUPERVISOR_BACKOFF_SECONDS = (1.0, 2.0, 5.0, 10.0, 30.0) +_CLIENT_DISCONNECT_ERRORS = (BrokenPipeError, ConnectionResetError, ConnectionAbortedError) + + +@dataclass +class ManagedService: + name: str + label: str + host: str + port: int + log_path: Path + process: subprocess.Popen | None = None + command: tuple[str, ...] = () + state: str = "stopped" + last_error: str | None = None + restart_count: int = 0 + last_restart_at: float | None = None + health_failure_count: int = 0 + next_restart_at: float = 0.0 + built_once: bool = False + + @property + def pid(self) -> int | None: + return self.process.pid if self.process is not None else None + + +def _daemon_log(event: str, details: dict[str, object] | None = None) -> None: + """Write a structured daemon log line to stdout.""" + timestamp = datetime.datetime.now().isoformat(timespec="seconds") + suffix = "" + if details: + suffix = " " + json.dumps(details, ensure_ascii=True, sort_keys=True) + sys.stdout.write(f"[{timestamp}] daemon.{event}{suffix}\n") + sys.stdout.flush() + + +def _health_status_from_service_state(state: str) -> str: + if state in {"healthy", "static", "starting", "restarting", "stopped", "paused"}: + return state + return "degraded" + + +def _service_payload(service: ManagedService, *, paused: bool = False) -> dict[str, object]: + return { + "pid": service.pid, + "host": service.host, + "port": service.port, + "state": "paused" if paused else service.state, + "health": _health_status_from_service_state("paused" if paused else service.state), + "last_error": service.last_error, + "restart_count": service.restart_count, + "last_restart_at": service.last_restart_at, + "log_path": str(service.log_path), + "command": list(service.command), + "paused": paused, + } + + +if hasattr(socket, "AF_UNIX"): + + class _UnixControlServer(ThreadingHTTPServer): + address_family = socket.AF_UNIX + +else: # pragma: no cover - exercised by importing on Windows + _UnixControlServer = None + + +class SupervisorDaemon: + """Owns backend/WebUI child processes and exposes a local control API.""" + + def __init__( + self, + config, + *, + interval: float = SUPERVISOR_CHECK_INTERVAL_SECONDS, + failure_threshold: int = SUPERVISOR_HEALTH_FAILURE_THRESHOLD, + backend_adapter: ProcessAdapter | None = None, + ) -> None: + from flocks.cli.service_manager import ensure_runtime_dirs + + self.config = config + self.paths = ensure_runtime_dirs() + self.interval = interval + self.failure_threshold = failure_threshold + self.backend_adapter = backend_adapter or BackendProcessAdapter() + self.started_at = time.time() + self._lock = threading.RLock() + self._shutdown_requested = threading.Event() + self._server: ThreadingHTTPServer | None = None + self._server_thread: threading.Thread | None = None + self._backend_paused = False + self._webui_paused = False + self.backend = ManagedService( + name="backend", + label="后端", + host=config.backend_host, + port=config.backend_port, + log_path=self.paths.backend_log, + ) + self.webui = ManagedService( + name="webui", + label="WebUI", + host=config.backend_host, + port=config.backend_port, + log_path=self.paths.backend_log, + state="static", + ) + + def run(self) -> None: + """Run the supervisor until the control API asks it to stop.""" + self._install_signal_handlers() + self._cleanup_legacy_runtime() + self._start_control_server() + try: + self.restart_all(reason="startup") + while not self._shutdown_requested.wait(self.interval): + self.tick() + finally: + self.shutdown_children() + self._stop_control_server() + stop_all_browser_daemons() + _daemon_log("stopped") + + def _install_signal_handlers(self) -> None: + if threading.current_thread() is not threading.main_thread(): + return + + def _handle(_signum, _frame) -> None: + self.request_stop() + + for sig in (signal.SIGINT, signal.SIGTERM): + try: + signal.signal(sig, _handle) + except (OSError, ValueError): # pragma: no cover - platform defensive + pass + + def _cleanup_legacy_runtime(self) -> None: + from flocks.cli import service_manager + + console = service_manager._StdoutConsole() + for pid_file, name in ( + (service_manager.watchdog_pid_path(self.paths), "watchdog"), + (self.paths.frontend_pid, "WebUI"), + (self.paths.backend_pid, "backend"), + ): + record = service_manager.read_runtime_record(pid_file) + if record is not None and service_manager.runtime_record_is_running(record): + service_manager.stop_runtime_record_process(pid_file, name, console) + else: + pid_file.unlink(missing_ok=True) + + def _start_control_server(self) -> None: + handler = self._handler_class() + if supervisor_uses_tcp_control(): + server: ThreadingHTTPServer = ThreadingHTTPServer(("127.0.0.1", supervisor_control_port()), handler) + else: + socket_path = supervisor_socket_path(self.paths) + socket_path.parent.mkdir(parents=True, exist_ok=True) + socket_path.unlink(missing_ok=True) + assert _UnixControlServer is not None + server = _UnixControlServer(str(socket_path), handler) + self._server = server + self._server_thread = threading.Thread(target=server.serve_forever, name="flocks-supervisor-control", daemon=True) + self._server_thread.start() + _daemon_log("control_started", {"platform": sys.platform}) + + def _stop_control_server(self) -> None: + if self._server is not None: + self._server.shutdown() + self._server.server_close() + if self._server_thread is not None: + self._server_thread.join(timeout=5.0) + if not supervisor_uses_tcp_control(): + supervisor_socket_path(self.paths).unlink(missing_ok=True) + + def _handler_class(self): + daemon = self + + class ControlHandler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.0" + + def log_message(self, _format, *_args) -> None: + return + + def _send_json(self, payload: dict[str, object], status: int = 200) -> None: + body = json.dumps(payload, ensure_ascii=False, sort_keys=True).encode("utf-8") + try: + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + except _CLIENT_DISCONNECT_ERRORS: + return + + def _read_json(self) -> dict[str, Any]: + length = int(self.headers.get("Content-Length") or "0") + if length <= 0: + return {} + try: + payload = json.loads(self.rfile.read(length).decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError): + return {} + return payload if isinstance(payload, dict) else {} + + def do_GET(self) -> None: + parsed = urlparse(self.path) + try: + if parsed.path == "/status": + self._send_json(daemon.status_payload()) + return + if parsed.path == "/logs": + daemon.handle_logs_request(self, parse_qs(parsed.query)) + return + self._send_json({"error": "not found"}, status=404) + except _CLIENT_DISCONNECT_ERRORS: + return + except Exception as exc: # pragma: no cover - defensive control path + self._send_json({"error": str(exc)}, status=500) + + def do_POST(self) -> None: + parsed = urlparse(self.path) + payload = self._read_json() + try: + if parsed.path == "/stop": + daemon.request_stop() + self._send_json({"status": "stopping"}) + return + if parsed.path == "/restart": + daemon.update_config(payload) + daemon.restart_all(reason="control restart") + self._send_json(daemon.status_payload()) + return + if parsed.path == "/restart/backend": + daemon.restart_backend(reason="control restart") + self._send_json(daemon.status_payload()) + return + if parsed.path == "/restart/webui": + daemon.update_config(payload) + daemon.restart_webui( + reason="control restart", + force_frontend_build=bool(payload.get("force_frontend_build")), + ) + self._send_json(daemon.status_payload()) + return + if parsed.path == "/stop/webui": + self._send_json({"error": "static WebUI is served by Flocks service and cannot be stopped separately"}, status=409) + return + if parsed.path == "/upgrade/prepare": + daemon.prepare_upgrade(reason="control upgrade prepare") + self._send_json(daemon.status_payload()) + return + if parsed.path == "/upgrade/resume": + daemon.update_config(payload) + daemon.resume_upgrade(reason="control upgrade resume") + self._send_json(daemon.status_payload()) + return + self._send_json({"error": "not found"}, status=404) + except _CLIENT_DISCONNECT_ERRORS: + return + except Exception as exc: # pragma: no cover - defensive control path + self._send_json({"error": str(exc)}, status=500) + + return ControlHandler + + def update_config(self, payload: dict[str, Any]) -> None: + with self._lock: + self.config = service_config_from_payload(payload, self.config) + self.backend.host = self.config.backend_host + self.backend.port = self.config.backend_port + self.webui.host = self.config.backend_host + self.webui.port = self.config.backend_port + self.webui.log_path = self.paths.backend_log + + def request_stop(self) -> None: + self._shutdown_requested.set() + + def status_payload(self) -> dict[str, object]: + try: + from flocks import __version__ + except Exception: # pragma: no cover - defensive + __version__ = "unknown" + with self._lock: + return { + "daemon": { + "pid": os.getpid(), + "uptime": time.time() - self.started_at, + "version": __version__, + "state": "stopping" if self._shutdown_requested.is_set() else "running", + "log_path": str(supervisor_log_path(self.paths)), + }, + "backend": _service_payload(self.backend, paused=self._backend_paused), + "webui": _service_payload(self.webui, paused=self._webui_paused), + "config": service_config_payload(self.config), + } + + def handle_logs_request(self, handler: BaseHTTPRequestHandler, query: dict[str, list[str]]) -> None: + from flocks.cli.service_manager import FOLLOW_POLL_INTERVAL, _coerce_positive_int, tail_lines + + service_name = (query.get("service") or ["backend"])[0] + lines = _coerce_positive_int((query.get("lines") or ["50"])[0]) or 50 + follow = (query.get("follow") or ["false"])[0].lower() == "true" + selections = self._log_paths_for_service(service_name) + if not selections: + body = json.dumps({"error": "unknown service"}, ensure_ascii=False).encode("utf-8") + handler.send_response(400) + handler.send_header("Content-Type", "application/json; charset=utf-8") + handler.send_header("Content-Length", str(len(body))) + handler.end_headers() + handler.wfile.write(body) + return + + for _prefix, log_path in selections: + log_path.touch(exist_ok=True) + if not follow: + body = json.dumps( + { + "service": service_name, + "logs": { + prefix: { + "path": str(log_path), + "lines": tail_lines(log_path, lines), + } + for prefix, log_path in selections + }, + }, + ensure_ascii=False, + ).encode("utf-8") + handler.send_response(200) + handler.send_header("Content-Type", "application/json; charset=utf-8") + handler.send_header("Content-Length", str(len(body))) + handler.end_headers() + handler.wfile.write(body) + return + + handler.send_response(200) + handler.send_header("Content-Type", "text/plain; charset=utf-8") + handler.end_headers() + for prefix, log_path in selections: + handler.wfile.write((f"[{prefix}] --- {log_path} ---\n").encode("utf-8", errors="replace")) + for line in tail_lines(log_path, lines): + handler.wfile.write((f"[{prefix}] {line}\n").encode("utf-8", errors="replace")) + handler.wfile.flush() + handles = {} + try: + for prefix, log_path in selections: + handle = log_path.open("r", encoding="utf-8", errors="replace") + handle.seek(0, os.SEEK_END) + handles[prefix] = handle + while not self._shutdown_requested.is_set(): + emitted = False + for prefix, handle in handles.items(): + while True: + line = handle.readline() + if not line: + break + emitted = True + handler.wfile.write((f"[{prefix}] {line}").encode("utf-8", errors="replace")) + if emitted: + handler.wfile.flush() + else: + time.sleep(FOLLOW_POLL_INTERVAL) + finally: + for handle in handles.values(): + handle.close() + + def _log_paths_for_service(self, service_name: str) -> list[tuple[str, Path]]: + if service_name == "backend": + return [("flocks", self.paths.backend_log)] + if service_name == "webui": + return [("flocks", self.paths.backend_log)] + if service_name == "daemon": + return [("daemon", supervisor_log_path(self.paths))] + if service_name == "all": + return [ + ("flocks", self.paths.backend_log), + ("daemon", supervisor_log_path(self.paths)), + ] + return [] + + def restart_all(self, *, reason: str) -> None: + with self._lock: + self._backend_paused = False + self._webui_paused = False + self._restart_service(self.backend, reason=reason, immediate=True) + self._start_backend_locked(immediate=True) + self._sync_static_webui_state() + + def restart_backend(self, *, reason: str) -> None: + with self._lock: + self._backend_paused = False + self._restart_service(self.backend, reason=reason, immediate=True) + self._start_backend_locked(immediate=True) + self._sync_static_webui_state() + + def restart_webui(self, *, reason: str, force_frontend_build: bool = False) -> None: + with self._lock: + self._webui_paused = False + if force_frontend_build: + from flocks.cli.service_config import with_frontend_build + + self.config = with_frontend_build(self.config, skip_frontend_build=False) + self._restart_service(self.backend, reason=f"{reason}: static webui", immediate=True) + self._start_backend_locked(immediate=True) + self._sync_static_webui_state() + + def prepare_upgrade(self, *, reason: str) -> None: + with self._lock: + self._backend_paused = True + self._webui_paused = True + _daemon_log("service_pause", {"service": "backend", "reason": reason}) + _daemon_log("service_pause", {"service": "webui", "reason": reason}) + self.backend.last_error = reason + self.webui.last_error = reason + self._stop_service(self.backend) + self.webui.state = "paused" + + def resume_upgrade(self, *, reason: str) -> None: + with self._lock: + self._backend_paused = False + self._webui_paused = False + _daemon_log("service_resume", {"service": "backend", "reason": reason}) + _daemon_log("service_resume", {"service": "webui", "reason": reason}) + self._probe_backend_locked() + self._start_backend_locked(immediate=True) + self._sync_static_webui_state() + + def shutdown_children(self) -> None: + with self._lock: + self._stop_service(self.backend) + self.webui.state = "stopped" + + def tick(self) -> None: + with self._lock: + if not self._backend_paused: + self._probe_backend_locked() + if not self._backend_paused: + self._start_backend_locked(immediate=False) + self._sync_static_webui_state() + + def _restart_service(self, service: ManagedService, *, reason: str, immediate: bool) -> None: + _daemon_log("service_restart", {"service": service.name, "reason": reason}) + self._stop_service(service) + service.state = "restarting" + service.last_error = reason + service.health_failure_count = 0 + service.restart_count += 1 + service.last_restart_at = time.time() + service.next_restart_at = time.monotonic() if immediate else self._next_restart_time(service.restart_count) + + def _stop_service(self, service: ManagedService) -> None: + adapter = self._adapter_for(service) + adapter.stop(service.process) + service.process = None + service.command = () + service.state = "stopped" + + def _start_backend_locked(self, *, immediate: bool) -> None: + if self.backend.process is not None and self.backend.process.poll() is None: + return + if not immediate and time.monotonic() < self.backend.next_restart_at: + return + self.backend.state = "starting" + try: + process = self.backend_adapter.start(self.config, self.paths) + except Exception as exc: + self._mark_start_failed(self.backend, exc) + return + self.backend.process = process + self.backend.command = tuple(str(item) for item in process.args) + self.backend.state = "healthy" + self.backend.last_error = None + self.backend.health_failure_count = 0 + self._sync_static_webui_state() + + def _mark_start_failed(self, service: ManagedService, error: Exception) -> None: + service.process = None + service.state = "degraded" + service.last_error = str(error) + service.next_restart_at = self._next_restart_time(service.restart_count) + _daemon_log( + "service_start_failed", + {"service": service.name, "error": str(error), "retry_at": service.next_restart_at}, + ) + + def _next_restart_time(self, restart_count: int) -> float: + index = min(max(restart_count, 1) - 1, len(SUPERVISOR_BACKOFF_SECONDS) - 1) + return time.monotonic() + SUPERVISOR_BACKOFF_SECONDS[index] + + def _probe_backend_locked(self) -> None: + result = self.backend_adapter.probe(self.backend.process, self.backend.host, self.backend.port) + if self.backend.process is None: + self.backend.state = "stopped" + return + if result.restart: + self._restart_service(self.backend, reason=result.reason or "backend probe failed", immediate=True) + return + if result.healthy: + self.backend.state = "healthy" + self.backend.health_failure_count = 0 + self.backend.last_error = None + return + + self.backend.health_failure_count += 1 + self.backend.state = "degraded" + self.backend.last_error = result.reason + if self.backend.health_failure_count >= self.failure_threshold: + self._restart_service(self.backend, reason=result.reason or "backend health failed", immediate=True) + + def _adapter_for(self, service: ManagedService) -> ProcessAdapter: + return self.backend_adapter + + def _sync_static_webui_state(self) -> None: + self.webui.host = self.backend.host + self.webui.port = self.backend.port + self.webui.log_path = self.paths.backend_log + self.webui.process = None + self.webui.command = () + if self._webui_paused: + self.webui.state = "paused" + return + if self.backend.state == "healthy": + self.webui.state = "static" + self.webui.last_error = None + elif self.backend.state in {"starting", "restarting"}: + self.webui.state = self.backend.state + self.webui.last_error = self.backend.last_error + else: + self.webui.state = "degraded" + self.webui.last_error = self.backend.last_error or "server is not healthy" + + +def run_service_daemon( + config, + *, + interval: float = SUPERVISOR_CHECK_INTERVAL_SECONDS, + failure_threshold: int = SUPERVISOR_HEALTH_FAILURE_THRESHOLD, +) -> None: + """Run the local supervisor daemon.""" + _daemon_log( + "started", + { + "backend_host": config.backend_host, + "backend_port": config.backend_port, + "frontend_host": config.frontend_host, + "frontend_port": config.frontend_port, + }, + ) + SupervisorDaemon(config, interval=interval, failure_threshold=failure_threshold).run() diff --git a/flocks/config/config.py b/flocks/config/config.py index 387f9ce4b..9e5fc41fe 100644 --- a/flocks/config/config.py +++ b/flocks/config/config.py @@ -358,6 +358,55 @@ class FlocksProConfig(BaseModel): url: Optional[str] = None +class UIConfig(BaseModel): + """WebUI display preferences.""" + + model_config = {"populate_by_name": True} + + display_name: Optional[str] = Field( + None, + alias="displayName", + max_length=48, + description="Custom product display name used by visible WebUI branding.", + ) + favicon_path: Optional[str] = Field( + None, + alias="faviconPath", + max_length=256, + description="Relative path to a custom WebUI favicon stored in the user config directory.", + ) + + @field_validator("display_name", mode="before") + @classmethod + def normalize_display_name(cls, value: Any) -> Optional[str]: + if value is None: + return None + if not isinstance(value, str): + raise ValueError("displayName must be a string") + normalized = value.strip() + if not normalized: + return None + if any(ord(ch) < 32 or ord(ch) == 127 for ch in normalized): + raise ValueError("displayName must not contain control characters") + return normalized + + @field_validator("favicon_path", mode="before") + @classmethod + def normalize_favicon_path(cls, value: Any) -> Optional[str]: + if value is None: + return None + if not isinstance(value, str): + raise ValueError("faviconPath must be a string") + normalized = value.strip().replace("\\", "/") + if not normalized: + return None + if normalized.startswith("/") or ".." in normalized.split("/"): + raise ValueError("faviconPath must be a safe relative path") + if any(ord(ch) < 32 or ord(ch) == 127 for ch in normalized): + raise ValueError("faviconPath must not contain control characters") + return normalized + + class ExperimentalConfig(BaseModel): """Experimental features configuration""" model_config = {"extra": "allow", "populate_by_name": True} @@ -619,6 +668,7 @@ class ConfigInfo(BaseModel): ) agent_logic: Optional[Literal["base", "rex"]] = Field(None, alias="agentLogic") flockspro: Optional[FlocksProConfig] = None + ui: Optional[UIConfig] = None compaction: Optional[CompactionConfig] = None tool_output: Optional[ToolOutputConfig] = Field( None, diff --git a/flocks/console/login.py b/flocks/console/login.py index ad79843c7..0201056ed 100644 --- a/flocks/console/login.py +++ b/flocks/console/login.py @@ -23,6 +23,88 @@ def _shared_console_session_path() -> Path: return Path(raw).expanduser() / "run" / "console-session.json" +def _flocks_root() -> Path: + return Path(os.getenv("FLOCKS_ROOT", str(Path.home() / ".flocks"))).expanduser() + + +def _read_json_file(path: Path) -> dict[str, Any]: + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except Exception: + return {} + return payload if isinstance(payload, dict) else {} + + +def _read_pro_bundle_marker() -> dict[str, Any]: + return _read_json_file(_flocks_root() / "run" / "pro-bundle-installed.json") + + +def _marker_has_pro_bundle(marker: dict[str, Any]) -> bool: + return any(str(marker.get(key) or "").strip() for key in ("bundle_version", "flockspro_component_version")) + + +def _local_pro_license_path() -> Path: + return _flocks_root() / "flockspro" / "license.json" + + +def _read_local_pro_license_state() -> dict[str, Any]: + return _read_json_file(_local_pro_license_path()) + + +def _read_local_pro_license_id() -> str: + state = _read_local_pro_license_state() + payload = state.get("payload") if isinstance(state.get("payload"), dict) else {} + return str(state.get("license_id") or payload.get("license_id") or "").strip() + + +def _read_local_pro_license_status() -> str: + state = _read_local_pro_license_state() + payload = state.get("payload") if isinstance(state.get("payload"), dict) else {} + return str( + state.get("license_status") + or state.get("status") + or payload.get("license_status") + or payload.get("status") + or "" + ).strip() + + +def _pending_pro_bundle_install_receipt_path() -> Path: + return _flocks_root() / "run" / "pro-bundle-install-receipt-pending.json" + + +def _pending_pro_bundle_downgrade_receipt_path() -> Path: + return _flocks_root() / "run" / "pro-bundle-downgrade-receipt-pending.json" + + +def _sync_local_pro_license_from_heartbeat_response(data: dict[str, Any]) -> None: + license_path = _local_pro_license_path() + state = _read_json_file(license_path) + now_ts = int(datetime.now(UTC).timestamp()) + if state: + changed = False + patch_token = data.get("license_patch") or data.get("latest_patch") + if isinstance(patch_token, str) and patch_token: + patches = state.get("patches") if isinstance(state.get("patches"), list) else [] + if patch_token not in patches: + state["patches"] = [*patches, patch_token] + changed = True + if state.get("last_sync_at") != now_ts: + state["last_sync_at"] = now_ts + state["last_heartbeat_ok_at"] = now_ts + changed = True + if changed: + license_path.parent.mkdir(parents=True, exist_ok=True) + license_path.write_text(json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8") + + revoked_license_ids = data.get("revoked_license_ids") + if isinstance(revoked_license_ids, list): + revocation_path = _flocks_root() / "flockspro" / "revocation.json" + revocation_path.parent.mkdir(parents=True, exist_ok=True) + payload = {"revoked_license_ids": sorted({str(item) for item in revoked_license_ids})} + revocation_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + + def _write_shared_console_session(session: dict[str, Any]) -> None: path = _shared_console_session_path() path.parent.mkdir(parents=True, exist_ok=True) @@ -42,6 +124,29 @@ def _write_shared_console_session(session: dict[str, Any]) -> None: pass +def read_shared_console_session() -> dict[str, Any] | None: + path = _shared_console_session_path() + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (FileNotFoundError, OSError, json.JSONDecodeError): + return None + if not isinstance(payload, dict): + return None + token = str(payload.get("console_session_token") or "").strip() + fingerprint = str(payload.get("fingerprint") or "").strip() + install_id = str(payload.get("install_id") or "").strip() + if not token or not fingerprint or not install_id: + return None + expires_at = str(payload.get("expires_at") or "").strip() + if expires_at: + try: + if _parse_iso(expires_at) <= datetime.now(UTC): + return None + except ValueError: + return None + return payload + + def _delete_shared_console_session() -> None: path = _shared_console_session_path() try: @@ -284,39 +389,175 @@ def _runtime_version() -> str: return str(__version__).lstrip("v") @classmethod - async def send_heartbeat(cls) -> dict[str, Any]: - session = await cls._require_session() - console_base = cls.console_base_url() + def runtime_version_payload(cls, *, pro_component_version: str | None = None) -> dict[str, str]: + marker = _read_pro_bundle_marker() + if _marker_has_pro_bundle(marker): + return { + "edition": "flockspro", + "core_version": str(marker.get("core_version") or "").strip(), + "bundle_version": str(marker.get("bundle_version") or "").strip(), + "flockspro_component_version": str(marker.get("flockspro_component_version") or "").strip(), + } + + core_version = cls._runtime_version() payload = { - "fingerprint": session["fingerprint"], - "install_id": session["install_id"], + "edition": "oss", + } + if core_version: + payload["core_version"] = core_version + return {key: value for key, value in payload.items() if value} + + @classmethod + def heartbeat_payload( + cls, + session: dict[str, Any], + *, + status: str = "ok", + license_id: str | None = None, + pro_component_version: str | None = None, + ) -> dict[str, Any]: + version_payload = cls.runtime_version_payload(pro_component_version=pro_component_version) + return { + "fingerprint": session.get("fingerprint"), + "install_id": session.get("install_id"), "console_login_id": session.get("console_login_id"), "sent_at": _now_iso(), - "status": "ok", + "status": status, + "license_id": license_id or None, + **version_payload, } - if not console_base: + + @classmethod + async def send_heartbeat_for_session( + cls, + *, + session: dict[str, Any], + status: str = "ok", + license_id: str | None = None, + heartbeat_url: str | None = None, + report_install_receipt: bool = False, + pro_component_version: str | None = None, + ) -> dict[str, Any]: + console_base = cls.console_base_url() + payload = cls.heartbeat_payload( + session, + status=status, + license_id=license_id, + pro_component_version=pro_component_version, + ) + target_url = heartbeat_url or (f"{console_base}/v1/heartbeats" if console_base else "") + if not target_url: return {"ok": True, "mode": "mock", "node": payload} + token = str(session.get("console_session_token") or "").strip() + if not token: + raise ValueError("console_session_token 缺失,无法发送心跳") async with httpx.AsyncClient(timeout=10) as client: resp = await client.post( - f"{console_base}/v1/heartbeats", + target_url, json=payload, - headers={"Authorization": f"Bearer {session['console_session_token']}"}, + headers={"Authorization": f"Bearer {token}"}, ) if resp.status_code in {401, 403}: raise ValueError("console 会话已失效,请重新登录") resp.raise_for_status() - return resp.json() + data = resp.json() + _sync_local_pro_license_from_heartbeat_response(data) + if report_install_receipt: + await cls._report_pending_pro_bundle_install_receipt(client=client, session=session) + return data + + @classmethod + async def send_heartbeat(cls) -> dict[str, Any]: + session = await cls._require_session() + return await cls.send_heartbeat_for_session( + session=session, + status=_read_local_pro_license_status() or "ok", + license_id=_read_local_pro_license_id() or None, + report_install_receipt=True, + ) + + @classmethod + async def report_pending_pro_bundle_install_receipt(cls) -> bool: + try: + session = await cls._require_session() + except Exception: + session = read_shared_console_session() + if not session: + return False + async with httpx.AsyncClient(timeout=10) as client: + return await cls._report_pending_pro_bundle_install_receipt(client=client, session=session) + + @classmethod + def _console_base_url_for_session(cls, session: dict[str, Any]) -> str: + console_base = cls.console_base_url() or str(session.get("console_base_url") or "").strip().rstrip("/") + if console_base: + return console_base + shared_session = read_shared_console_session() + return str((shared_session or {}).get("console_base_url") or "").strip().rstrip("/") + + @classmethod + async def _report_pending_pro_bundle_install_receipt( + cls, + *, + client: httpx.AsyncClient, + session: dict[str, Any], + ) -> bool: + install_reported = await cls._report_pending_pro_bundle_receipt( + client=client, + session=session, + path=_pending_pro_bundle_install_receipt_path(), + ) + downgrade_reported = await cls._report_pending_pro_bundle_receipt( + client=client, + session=session, + path=_pending_pro_bundle_downgrade_receipt_path(), + ) + return install_reported or downgrade_reported + + @classmethod + async def _report_pending_pro_bundle_receipt( + cls, + *, + client: httpx.AsyncClient, + session: dict[str, Any], + path: Path, + ) -> bool: + console_base = cls._console_base_url_for_session(session) + token = str(session.get("console_session_token") or "").strip() + if not console_base or not token: + return False + payload = _read_json_file(path) + if not payload: + return False + payload = { + **payload, + "fingerprint": session.get("fingerprint"), + "install_id": session.get("install_id"), + "license_id": payload.get("license_id") or _read_local_pro_license_id() or None, + } + try: + resp = await client.post( + f"{console_base}/v1/pro-bundles/installations", + json=payload, + headers={"Authorization": f"Bearer {token}"}, + ) + if resp.status_code in {200, 201, 202}: + path.unlink(missing_ok=True) + return True + except Exception: + return False + return False @classmethod async def sync_node_profile(cls, *, force: bool = False, source: str = "scheduled") -> dict[str, Any]: _ = force session = await cls._require_session() console_base = cls.console_base_url() + version_payload = cls.runtime_version_payload() payload = { "fingerprint": session["fingerprint"], "install_id": session["install_id"], - "edition": cls._edition(), - "version": cls._runtime_version(), + **version_payload, "source": source, "sent_at": _now_iso(), } diff --git a/flocks/console/scheduler.py b/flocks/console/scheduler.py index 7bc98d878..06e9a20da 100644 --- a/flocks/console/scheduler.py +++ b/flocks/console/scheduler.py @@ -60,6 +60,20 @@ async def _tick_once(cls) -> None: await cls._maybe_refresh_session(now_ts) await cls._maybe_sync_profile(now_ts) + @classmethod + async def send_startup_heartbeat(cls) -> None: + """Send one heartbeat on every server start, regardless of interval.""" + now_ts = int(time.time()) + try: + result = await ConsoleLoginService.send_heartbeat() + await Storage.set(_HEARTBEAT_TS_KEY, now_ts, "number") + log.info("console.sync.startup_heartbeat.ok", {"at": now_ts, "result": result}) + except ValueError: + # Not bound / invalid session is expected and should not spam logs. + return + except Exception as exc: + log.warning("console.sync.startup_heartbeat.failed", {"error": str(exc)}) + @classmethod async def _maybe_send_heartbeat(cls, now_ts: int) -> None: raw_last = await Storage.get(_HEARTBEAT_TS_KEY) diff --git a/flocks/contracts/access/driver.py b/flocks/contracts/access/driver.py index c9f1a3a9e..25af7108a 100644 --- a/flocks/contracts/access/driver.py +++ b/flocks/contracts/access/driver.py @@ -66,12 +66,21 @@ def execute(self, plan: QueryPlan) -> DriverResult: table = _sqlite_identifier(options.get("table"), "records") record_column = _sqlite_identifier(options.get("recordColumn"), "record_json") date_column = _sqlite_identifier(options.get("dateColumn"), "record_date") + event_time_column = _sqlite_identifier_optional(options.get("eventTimeColumn")) query = f"SELECT {record_column} FROM {table}" + conditions: list[str] = [] query_params: list[Any] = [] start_date, end_date = JsonlDriverExecutor()._request_date_range(plan.params) if start_date and end_date and date_column: - query += f" WHERE {date_column} BETWEEN ? AND ?" + conditions.append(f"{date_column} BETWEEN ? AND ?") query_params.extend([start_date, end_date]) + start_time, end_time = JsonlDriverExecutor()._request_event_time_range(plan.params) + sql_event_time_filtered = start_time is not None and end_time is not None and bool(event_time_column) + if sql_event_time_filtered: + conditions.append(f"{event_time_column} BETWEEN ? AND ?") + query_params.extend([start_time, end_time]) + if conditions: + query += f" WHERE {' AND '.join(conditions)}" query += " ORDER BY rowid" rows: list[dict[str, Any]] = [] @@ -103,6 +112,8 @@ def execute(self, plan: QueryPlan) -> DriverResult: continue if record.get("_type") == "file_header": continue + if not sql_event_time_filtered and not self._matches_event_time_range(record, start_time, end_time): + continue total_raw += 1 if record.get("is_duplicate") is True: @@ -144,6 +155,9 @@ def _assert_allowed(self, path: Path, allowlist_roots: tuple[Path, ...]) -> None def _matches_predicates(self, record: dict[str, Any], predicates: tuple[Predicate, ...]) -> bool: return JsonlDriverExecutor()._matches_predicates(record, predicates) + def _matches_event_time_range(self, record: dict[str, Any], start_time: int | None, end_time: int | None) -> bool: + return JsonlDriverExecutor()._matches_event_time_range(record, start_time, end_time) + class JsonlDriverExecutor: def execute(self, plan: QueryPlan) -> DriverResult: @@ -154,6 +168,7 @@ def execute(self, plan: QueryPlan) -> DriverResult: duplicates = 0 filtered_unique = 0 parse_errors = 0 + start_time, end_time = self._request_event_time_range(plan.params) for path in files: self._assert_allowed(path, plan.binding.driver_allowlist_roots) for record in self._iter_records(path): @@ -162,6 +177,8 @@ def execute(self, plan: QueryPlan) -> DriverResult: continue if record.get("_type") == "file_header": continue + if not self._matches_event_time_range(record, start_time, end_time): + continue total_raw += 1 if record.get("is_duplicate") is True: @@ -243,6 +260,21 @@ def _request_date_range(self, params: dict[str, Any]) -> tuple[str, str] | tuple return from_date, to_date return None, None + def _request_event_time_range(self, params: dict[str, Any]) -> tuple[int | None, int | None]: + start_time = _epoch_seconds_from_value( + params.get("startTime") or params.get("fromTime") or params.get("eventStartTime") + ) + end_time = _epoch_seconds_from_value( + params.get("endTime") or params.get("toTime") or params.get("eventEndTime") + ) + if start_time is not None and end_time is None: + end_time = start_time + if end_time is not None and start_time is None: + start_time = end_time + if start_time is not None and end_time is not None and start_time > end_time: + start_time, end_time = end_time, start_time + return start_time, end_time + def _assert_allowed(self, path: Path, allowlist_roots: tuple[Path, ...]) -> None: resolved = path.resolve() for root in allowlist_roots: @@ -282,6 +314,18 @@ def _matches_predicates(self, record: dict[str, Any], predicates: tuple[Predicat return False return True + def _matches_event_time_range(self, record: dict[str, Any], start_time: int | None, end_time: int | None) -> bool: + if start_time is None or end_time is None: + return True + record_time = _epoch_seconds_from_value(record.get("time")) + if record_time is None: + meta = record.get("_syslog_meta") + if isinstance(meta, dict): + record_time = _epoch_seconds_from_value(meta.get("timestamp")) + if record_time is None: + return False + return start_time <= record_time <= end_time + def _data_file_date(root: Path, path: Path) -> str: try: @@ -306,6 +350,32 @@ def _date_from_value(value: Any) -> str: return "" +def _epoch_seconds_from_value(value: Any) -> int | None: + if value is None: + return None + if isinstance(value, bool): + return None + if isinstance(value, (int, float)): + seconds = float(value) + if seconds > 10_000_000_000: + seconds /= 1000 + return int(seconds) + text = str(value).strip() + if not text: + return None + try: + seconds = float(text) + except ValueError: + try: + parsed = datetime.fromisoformat(text.replace("Z", "+00:00")) + except ValueError: + return None + return int(parsed.timestamp()) + if seconds > 10_000_000_000: + seconds /= 1000 + return int(seconds) + + def _normalize_compare(value: Any) -> str: if isinstance(value, float) and value.is_integer(): return str(int(value)) @@ -326,3 +396,17 @@ def _sqlite_identifier(value: Any, fallback: str) -> str: admin_message=f"Invalid SQLite identifier: {text}", ) return text + + +def _sqlite_identifier_optional(value: Any) -> str: + text = str(value or "").strip() + if not text: + return "" + if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", text): + raise ContractRuntimeError( + "data_source_unavailable", + status_code=400, + user_message="WebUI contract SQLite source is misconfigured.", + admin_message=f"Invalid SQLite identifier: {text}", + ) + return text diff --git a/flocks/contracts/webui/models.py b/flocks/contracts/webui/models.py index 863745fc0..912bf6a24 100644 --- a/flocks/contracts/webui/models.py +++ b/flocks/contracts/webui/models.py @@ -12,6 +12,7 @@ class WebUIPageManifest(BaseModel): id: str = Field(..., description="Stable page identifier") title: str = Field(..., description="Navigation label") + titleEn: Optional[str] = Field(None, description="English navigation label", alias="titleEn") route: str = Field(..., description="WebUI route path") icon: str = Field("LayoutDashboard", description="Lucide icon name") order: int = Field(100, description="Sort order in navigation") @@ -29,6 +30,7 @@ class WebUIWorkspaceManifest(BaseModel): id: str = Field(..., description="Stable workspace identifier") title: str = Field(..., description="Navigation label") + titleEn: Optional[str] = Field(None, description="English navigation label", alias="titleEn") icon: str = Field("LayoutDashboard", description="Lucide icon name") order: int = Field(100, description="Sort order in navigation") enabled: bool = Field(True, description="Whether workspace appears in navigation") @@ -48,6 +50,7 @@ class WebUIWorkspaceSectionManifest(BaseModel): id: str = Field(..., description="Stable section identifier") label: str = Field(..., description="Section label") + labelEn: Optional[str] = Field(None, description="English section label", alias="labelEn") pageIds: list[str] = Field( default_factory=list, description="Page ids in this section", @@ -96,6 +99,7 @@ class WebUIPageListItem(BaseModel): id: str title: str + titleEn: Optional[str] = Field(None, alias="titleEn") route: str icon: str order: int @@ -105,6 +109,7 @@ class WebUIPageListItem(BaseModel): buildStatus: str = Field("idle", alias="buildStatus") workspaceId: Optional[str] = Field(None, alias="workspaceId") workspaceTitle: Optional[str] = Field(None, alias="workspaceTitle") + workspaceTitleEn: Optional[str] = Field(None, alias="workspaceTitleEn") workspaceRoute: Optional[str] = Field(None, alias="workspaceRoute") @@ -113,6 +118,7 @@ class WebUIWorkspaceListItem(BaseModel): id: str title: str + titleEn: Optional[str] = Field(None, alias="titleEn") route: str icon: str order: int diff --git a/flocks/contracts/webui/store.py b/flocks/contracts/webui/store.py index cec1111bc..f47a2f8d9 100644 --- a/flocks/contracts/webui/store.py +++ b/flocks/contracts/webui/store.py @@ -223,6 +223,7 @@ def list_pages(self, *, enabled_only: bool = False) -> list[WebUIPageListItem]: WebUIPageListItem( id=manifest.id, title=manifest.title, + titleEn=manifest.titleEn, route=manifest.route, icon=manifest.icon, order=manifest.order, @@ -232,6 +233,7 @@ def list_pages(self, *, enabled_only: bool = False) -> list[WebUIPageListItem]: buildStatus=build.status, workspaceId=workspace.id if workspace else None, workspaceTitle=workspace.title if workspace else None, + workspaceTitleEn=workspace.titleEn if workspace else None, workspaceRoute=webui_contract_workspace_route(workspace.id) if workspace else None, ) ) @@ -268,6 +270,7 @@ def list_workspaces(self, *, enabled_only: bool = False) -> list[WebUIWorkspaceL WebUIPageListItem( id=page_manifest.id, title=page_manifest.title, + titleEn=page_manifest.titleEn, route=page_manifest.route, icon=page_manifest.icon, order=page_manifest.order, @@ -277,6 +280,7 @@ def list_workspaces(self, *, enabled_only: bool = False) -> list[WebUIWorkspaceL buildStatus=build.status, workspaceId=manifest.id, workspaceTitle=manifest.title, + workspaceTitleEn=manifest.titleEn, workspaceRoute=webui_contract_workspace_route(manifest.id), ) ) @@ -285,6 +289,7 @@ def list_workspaces(self, *, enabled_only: bool = False) -> list[WebUIWorkspaceL WebUIWorkspaceListItem( id=manifest.id, title=manifest.title, + titleEn=manifest.titleEn, route=webui_contract_workspace_route(manifest.id), icon=manifest.icon, order=manifest.order, diff --git a/flocks/hub/catalog.py b/flocks/hub/catalog.py index 5378d3aae..bb08cb066 100644 --- a/flocks/hub/catalog.py +++ b/flocks/hub/catalog.py @@ -28,6 +28,10 @@ }, } +_TOOL_TYPE_DIRS = frozenset({"api", "device", "python", "mcp", "generated"}) +_SKIP_PLUGIN_DIRS = frozenset({"__pycache__"}) +_PATH_SIGNATURE_MISSING = -1 + def _read_json(path: Path) -> dict: return json.loads(path.read_text(encoding="utf-8")) @@ -43,6 +47,59 @@ def _read_yaml(path: Path) -> dict[str, Any]: return {} +def _path_signature(path: Path) -> tuple[str, int, int]: + try: + stat = path.stat() + except OSError: + return (str(path), _PATH_SIGNATURE_MISSING, _PATH_SIGNATURE_MISSING) + return (str(path), stat.st_mtime_ns, stat.st_size) + + +def _is_plugin_dir(path: Path) -> bool: + name = path.name + return path.is_dir() and not name.startswith("_") and name not in _SKIP_PLUGIN_DIRS + + +def _iter_tool_plugin_dirs(tools_root: Path) -> Iterable[Path]: + """Yield tool plugin roots under ``tools/`` without descending into payload dirs.""" + if not tools_root.is_dir(): + return + for child in sorted(tools_root.iterdir(), key=lambda item: item.name): + if not _is_plugin_dir(child): + continue + if child.name in _TOOL_TYPE_DIRS: + if _has_direct_tool_payload(child): + yield child + for plugin_dir in sorted(child.iterdir(), key=lambda item: item.name): + if _is_plugin_dir(plugin_dir): + yield plugin_dir + continue + yield child + + +def _plugin_manifest_signature(plugin_type: PluginType, root: Path) -> tuple[tuple[str, int, int], ...]: + if plugin_type == "skill": + candidates = [root / "SKILL.md"] + elif plugin_type == "agent": + candidates = [root / "agent.yaml"] + elif plugin_type == "workflow": + candidates = [root / "workflow.json", root / "workflow.md"] + else: + try: + candidates = [ + path + for path in root.iterdir() + if path.is_file() + and ( + path.name == "_provider.yaml" + or (path.suffix in {".yaml", ".yml", ".py"} and not path.name.startswith("_")) + ) + ] + except OSError: + candidates = [] + return tuple(_path_signature(path) for path in sorted(candidates, key=lambda item: item.name)) + + def _contains_cjk(value: str) -> bool: return any("\u4e00" <= char <= "\u9fff" for char in value or "") @@ -157,6 +214,7 @@ def _base_manifest( name: str, description: str, category: str, + name_cn: Optional[str] = None, version: str = "1.0.0", description_cn: Optional[str] = None, tags: Optional[list[str]] = None, @@ -175,6 +233,7 @@ def _base_manifest( id=plugin_id, type=plugin_type, name=name or plugin_id, + nameCn=name_cn, description=description or "", descriptionCn=description_cn, version=version or "1.0.0", @@ -260,6 +319,14 @@ def _workflow_manifest(plugin_id: str, root: Path) -> Optional[HubPluginManifest description_raw = str(data.get("description") or "") description_en = str(data.get("description_en") or "").strip() description_cn = str(data.get("description_cn") or data.get("descriptionCn") or "").strip() + name_cn = str( + data.get("nameCn") + or data.get("name_cn") + or data.get("nameZh") + or data.get("zhName") + or data.get("cnName") + or "" + ).strip() description = description_en or description_raw if not description_cn and _contains_cjk(description_raw): description_cn = description_raw @@ -267,6 +334,7 @@ def _workflow_manifest(plugin_id: str, root: Path) -> Optional[HubPluginManifest plugin_type="workflow", plugin_id=plugin_id, name=str(data.get("name") or data.get("id") or plugin_id), + name_cn=name_cn or None, description=description, category="workflow-automation", description_cn=description_cn or None, @@ -396,7 +464,30 @@ def _tool_manifest(plugin_id: str, root: Path) -> Optional[HubPluginManifest]: ) -def _system_plugin_roots() -> dict[tuple[PluginType, str], Path]: +def _system_plugin_roots_cache_key() -> tuple[tuple[str, int, int], ...]: + signature: list[tuple[str, int, int]] = [] + + for plugin_type in ("skill", "agent", "workflow"): + base = local.install_root(plugin_type, "project") + signature.append(_path_signature(base)) + if not base.is_dir(): + continue + for child in sorted(base.iterdir(), key=lambda item: item.name): + if not _is_plugin_dir(child): + continue + signature.append(_path_signature(child)) + signature.extend(_plugin_manifest_signature(plugin_type, child)) + + tools_root = local.install_root("tool", "project") + signature.append(_path_signature(tools_root)) + for directory in _iter_tool_plugin_dirs(tools_root): + signature.append(_path_signature(directory)) + signature.extend(_plugin_manifest_signature("tool", directory)) + + return tuple(signature) + + +def _discover_system_plugin_roots() -> dict[tuple[PluginType, str], Path]: roots: dict[tuple[PluginType, str], Path] = {} for plugin_type, detector in ( @@ -408,23 +499,51 @@ def _system_plugin_roots() -> dict[tuple[PluginType, str], Path]: if not base.is_dir(): continue for child in sorted(base.iterdir(), key=lambda item: item.name): - if child.is_dir() and detector(child.name, child): + if _is_plugin_dir(child) and detector(child.name, child): roots[(plugin_type, child.name)] = child tools_root = local.install_root("tool", "project") - if tools_root.is_dir(): - for directory in sorted((path for path in tools_root.rglob("*") if path.is_dir()), key=lambda item: item.as_posix()): - manifest = _tool_manifest(directory.name, directory) - if manifest: - # The manifest type already reflects ``integration_type: - # device`` (see :func:`_tool_manifest`), so we just defer - # to it instead of hardcoding ``"tool"``. - roots[(manifest.type, directory.name)] = directory + for directory in _iter_tool_plugin_dirs(tools_root): + manifest = _tool_manifest(directory.name, directory) + if manifest: + # The manifest type already reflects ``integration_type: + # device`` (see :func:`_tool_manifest`), so we just defer + # to it instead of hardcoding ``"tool"``. + roots[(manifest.type, directory.name)] = directory return roots -def _bundled_tool_roots() -> dict[tuple[PluginType, str], Path]: +@lru_cache(maxsize=8) +def _cached_system_plugin_roots( + _signature: tuple[tuple[str, int, int], ...], +) -> tuple[tuple[PluginType, str, Path], ...]: + return tuple( + (plugin_type, plugin_id, path) + for (plugin_type, plugin_id), path in _discover_system_plugin_roots().items() + ) + + +def _system_plugin_roots() -> dict[tuple[PluginType, str], Path]: + return { + (plugin_type, plugin_id): path + for plugin_type, plugin_id, path in _cached_system_plugin_roots(_system_plugin_roots_cache_key()) + } + + +def _bundled_tool_roots_cache_key() -> tuple[tuple[str, int, int], ...]: + from flocks.hub.paths import bundled_tool_plugin_roots + + signature: list[tuple[str, int, int]] = [] + for tools_root in bundled_tool_plugin_roots(): + signature.append(_path_signature(tools_root)) + for directory in _iter_tool_plugin_dirs(tools_root): + signature.append(_path_signature(directory)) + signature.extend(_plugin_manifest_signature("tool", directory)) + return tuple(signature) + + +def _discover_bundled_tool_roots() -> dict[tuple[PluginType, str], Path]: """Tool plugin directories shipped pre-bundled inside flockshub. Returns ``(plugin_type, plugin_id) -> Path`` for every directory @@ -444,20 +563,8 @@ def _bundled_tool_roots() -> dict[tuple[PluginType, str], Path]: roots: dict[tuple[PluginType, str], Path] = {} for tools_root in bundled_tool_plugin_roots(): - if not tools_root.is_dir(): - continue - for directory in sorted( - (path for path in tools_root.rglob("*") if path.is_dir()), - key=lambda item: item.as_posix(), - ): - # Skip the type-organisation subdirs (api/, python/) and - # housekeeping noise. Their direct children are the actual - # plugin directories we want to surface. + for directory in _iter_tool_plugin_dirs(tools_root): name = directory.name - if name.startswith("_") or name == "__pycache__": - continue - if directory.parent == tools_root and name in {"api", "device", "python", "mcp", "generated"}: - continue manifest = _tool_manifest(name, directory) if not manifest: continue @@ -470,6 +577,32 @@ def _bundled_tool_roots() -> dict[tuple[PluginType, str], Path]: return roots +@lru_cache(maxsize=8) +def _cached_bundled_tool_roots( + _signature: tuple[tuple[str, int, int], ...], +) -> tuple[tuple[PluginType, str, Path], ...]: + return tuple( + (plugin_type, plugin_id, path) + for (plugin_type, plugin_id), path in _discover_bundled_tool_roots().items() + ) + + +def _bundled_tool_roots() -> dict[tuple[PluginType, str], Path]: + return { + (plugin_type, plugin_id): path + for plugin_type, plugin_id, path in _cached_bundled_tool_roots(_bundled_tool_roots_cache_key()) + } + + +def clear_catalog_caches() -> None: + """Clear Hub filesystem discovery caches after installs or explicit refreshes.""" + load_index.cache_clear() + load_taxonomy.cache_clear() + _manifest_path_lookup.cache_clear() + _cached_system_plugin_roots.cache_clear() + _cached_bundled_tool_roots.cache_clear() + + def system_plugin_root(plugin_type: PluginType, plugin_id: str) -> Optional[Path]: """Resolve the on-disk root for a plugin, preferring local installs. @@ -551,6 +684,7 @@ def _entry_from_manifest(manifest: HubPluginManifest) -> HubCatalogEntry: id=manifest.id, type=manifest.type, name=manifest.name, + nameCn=getattr(manifest, "nameCn", None), description=manifest.description, descriptionCn=getattr(manifest, "descriptionCn", None), version=manifest.version, @@ -606,6 +740,7 @@ def _entry_from_index( id=item.id, type=item.type, name=item.name, + nameCn=item.nameCn, description=item.description, descriptionCn=item.descriptionCn, version=item.version, @@ -628,6 +763,7 @@ def _entry_from_system_manifest(manifest: HubPluginManifest, root: Path) -> HubC id=manifest.id, type=manifest.type, name=manifest.name, + nameCn=getattr(manifest, "nameCn", None), description=manifest.description, descriptionCn=getattr(manifest, "descriptionCn", None), version=manifest.version, @@ -692,6 +828,7 @@ def _entry_from_bundled_tool( id=manifest.id, type=manifest.type, name=manifest.name, + nameCn=getattr(manifest, "nameCn", None), description=manifest.description, descriptionCn=getattr(manifest, "descriptionCn", None), version=manifest.version, diff --git a/flocks/hub/installer.py b/flocks/hub/installer.py index 7f37d2b9a..bcbbf2407 100644 --- a/flocks/hub/installer.py +++ b/flocks/hub/installer.py @@ -2,18 +2,28 @@ from __future__ import annotations +import asyncio import shutil import tempfile from pathlib import Path +from typing import Awaitable, Callable from flocks.hub import local -from flocks.hub.catalog import load_manifest +from flocks.hub.catalog import clear_catalog_caches, load_manifest from flocks.hub.files import plugin_root -from flocks.hub.models import InstalledPluginRecord, PluginType +from flocks.hub.models import ( + HubComponentRef, + HubInstallProgressEvent, + HubInstallProgressItem, + HubPluginManifest, + InstalledPluginRecord, + PluginType, +) from flocks.hub.security import SKIP_NAMES, validate_package _TOOL_TYPE_DIRS = {"api", "device", "python", "mcp", "generated"} +InstallProgressCallback = Callable[[HubInstallProgressEvent], Awaitable[None]] def _copytree_skip_caches(src: Path, dst: Path) -> None: @@ -85,14 +95,7 @@ def _copy_package(src: Path, dst: Path) -> None: parent.mkdir(parents=True, exist_ok=True) tmp = Path(tempfile.mkdtemp(prefix=f".{dst.name}.", dir=str(parent))) try: - for item in src.iterdir(): - if item.name == "manifest.json" or item.name in SKIP_NAMES: - continue - target = tmp / item.name - if item.is_dir(): - _copytree_skip_caches(item, target) - else: - shutil.copy2(item, target) + _copy_package_contents(src, tmp) backup = None if dst.exists(): backup = parent / f".{dst.name}.bak" @@ -108,6 +111,91 @@ def _copy_package(src: Path, dst: Path) -> None: raise +def _copy_package_contents(src: Path, dst: Path) -> None: + for item in src.iterdir(): + if item.name == "manifest.json" or item.name in SKIP_NAMES: + continue + target = dst / item.name + if item.is_dir(): + _copytree_skip_caches(item, target) + else: + shutil.copy2(item, target) + + +def _replace_dir(src: Path, dst: Path) -> None: + parent = dst.parent + backup = None + if dst.exists(): + backup = parent / f".{dst.name}.bak" + if backup.exists(): + shutil.rmtree(backup) + dst.replace(backup) + src.replace(dst) + if backup and backup.exists(): + shutil.rmtree(backup) + + +def _contracts_access_dir(plugin_id: str, scope: str) -> Path: + return local.install_root("webui", scope).parent / "access" / plugin_id + + +def _copy_attached_access_contracts(plugin_type: PluginType, plugin_id: str, src: Path, scope: str) -> Path | None: + if plugin_type != "webui": + return None + access_src = src / "access" + if not access_src.is_dir(): + return None + access_dst = _contracts_access_dir(plugin_id, scope) + _copy_package(access_src, access_dst) + return access_dst + + +def _remove_attached_access_contracts(plugin_type: PluginType, plugin_id: str, scope: str) -> None: + if plugin_type != "webui": + return + access_dst = _contracts_access_dir(plugin_id, scope) + if access_dst.is_dir(): + shutil.rmtree(access_dst) + elif access_dst.exists(): + access_dst.unlink() + + +def _build_webui_pages(plugin_id: str, install_dir: Path) -> None: + from flocks.contracts.webui.builder import WebUIPageBuilder + from flocks.contracts.webui.store import WebUIPagesStore + + store = WebUIPagesStore(root=install_dir, project_root=None, legacy_root=None) + pages = store.list_pages(enabled_only=False) + if not pages: + raise RuntimeError(f"WebUI package {plugin_id} does not contain any pages to build.") + + builder = WebUIPageBuilder(store) + for page in pages: + try: + meta = builder.build(page.id) + except Exception as exc: + raise RuntimeError( + f"Failed to build WebUI page bundle for {plugin_id}/{page.id}: {exc}" + ) from exc + if meta.status != "ready": + detail = f": {meta.error}" if meta.error else "" + raise RuntimeError(f"Failed to build WebUI page bundle for {plugin_id}/{page.id}{detail}") + + +def _copy_webui_package_with_build(plugin_id: str, src: Path, dst: Path) -> None: + parent = dst.parent + parent.mkdir(parents=True, exist_ok=True) + tmp = Path(tempfile.mkdtemp(prefix=f".{dst.name}.", dir=str(parent))) + try: + _copy_package_contents(src, tmp) + _build_webui_pages(plugin_id, tmp) + _replace_dir(tmp, dst) + except Exception: + if tmp.exists(): + shutil.rmtree(tmp, ignore_errors=True) + raise + + async def _refresh_runtime(plugin_type: PluginType) -> None: if plugin_type == "skill": from flocks.skill.skill import Skill @@ -134,8 +222,8 @@ async def _refresh_runtime(plugin_type: PluginType) -> None: from flocks.tool.device.plugin_index import clear_device_template_cache from flocks.tool.registry import ToolRegistry - ToolRegistry.init() - ToolRegistry.refresh_plugin_tools() + await ToolRegistry.init_async() + await asyncio.to_thread(ToolRegistry.refresh_plugin_tools) clear_device_template_cache() # Drop the descriptor cache so freshly installed/uninstalled # API plugins surface in ``_load_provider_yaml_metadata`` (and @@ -149,6 +237,250 @@ async def _refresh_runtime(plugin_type: PluginType) -> None: await scan_skill_workflows() except Exception: pass + elif plugin_type == "webui": + try: + from flocks.contracts.webui.bootstrap import reconcile_webui_pages + + await reconcile_webui_pages() + except Exception: + pass + try: + from flocks.server.routes.event import publish_event + + await publish_event("contracts.webui.pages.nav_changed", {"source": "hub"}) + except Exception: + pass + + +def component_install_items(manifest: HubPluginManifest) -> list[HubInstallProgressItem]: + items: list[HubInstallProgressItem] = [] + seen: set[tuple[PluginType, str]] = set() + for ref in manifest.components: + key = (ref.type, ref.id) + if key in seen: + continue + seen.add(key) + name = ref.id + name_cn = None + try: + ref_manifest = load_manifest(ref.type, ref.id) + name = ref_manifest.name or ref.id + name_cn = ref_manifest.nameCn + except Exception: + pass + items.append( + HubInstallProgressItem( + type=ref.type, + id=ref.id, + name=name, + nameCn=name_cn, + optional=ref.optional, + ) + ) + return items + + +async def _emit_component_progress( + callback: InstallProgressCallback | None, + manifest: HubPluginManifest, + event: str, + *, + item: HubInstallProgressItem | None = None, + items: list[HubInstallProgressItem] | None = None, + record: InstalledPluginRecord | None = None, + message: str | None = None, +) -> None: + if callback is None: + return + event_item = item.model_copy(deep=True) if item is not None else None + event_items = [entry.model_copy(deep=True) for entry in items] if items is not None else [] + await callback( + HubInstallProgressEvent( + event=event, + id=manifest.id, + type=manifest.type, + name=manifest.name, + nameCn=manifest.nameCn, + total=len(event_items) if items is not None else len(component_install_items(manifest)), + item=event_item, + items=event_items, + record=record, + message=message, + ) + ) + + +async def _rollback_component_ref_installs( + refs: list[tuple[PluginType, str]], + component_key: str, +) -> None: + seen: set[tuple[PluginType, str]] = set() + for plugin_type, plugin_id in reversed(refs): + key = (plugin_type, plugin_id) + if key in seen: + continue + seen.add(key) + record = local.get_record(plugin_type, plugin_id) + if record is None or record.installedBy != component_key: + continue + try: + await uninstall_plugin(plugin_type, plugin_id) + except FileNotFoundError: + local.remove_installed_record(plugin_type, plugin_id) + except Exception: + continue + + +def _rollback_install_path(plugin_type: PluginType, plugin_id: str, install_path: Path, scope: str) -> None: + if ".flocks/plugins" in install_path.as_posix(): + if install_path.is_dir(): + shutil.rmtree(install_path, ignore_errors=True) + elif install_path.exists(): + install_path.unlink() + _remove_attached_access_contracts(plugin_type, plugin_id, scope) + local.remove_installed_record(plugin_type, plugin_id) + + +def _is_project_install_path(plugin_type: PluginType, install_path: Path) -> bool: + try: + project_root = local.install_root(plugin_type, "project").resolve() + resolved_install_path = install_path.resolve() + except OSError: + return False + return resolved_install_path == project_root or project_root in resolved_install_path.parents + + +def _bundled_source_for_ref(ref: HubComponentRef) -> str | None: + try: + ref_manifest = load_manifest(ref.type, ref.id) + except Exception: + return None + if ref_manifest.source.kind != "bundled": + return None + return f"bundled:{ref_manifest.source.path or ''}" + + +def _can_adopt_existing_ref( + ref: HubComponentRef, + record: InstalledPluginRecord, + component_key: str, + install_path: Path | None, +) -> bool: + if not ref.adoptExisting: + return False + if record.installedBy not in {None, component_key}: + return False + if record.scope == "project": + return False + if install_path is not None and _is_project_install_path(ref.type, install_path): + return False + return record.source == _bundled_source_for_ref(ref) + + +async def _install_component_refs( + manifest: HubPluginManifest, + *, + scope: str, + progress: InstallProgressCallback | None = None, +) -> list[tuple[PluginType, str]]: + seen: set[tuple[PluginType, str]] = set() + component_key = f"component:{manifest.id}" + rollback_refs: list[tuple[PluginType, str]] = [] + adopted_records: list[InstalledPluginRecord] = [] + progress_items = component_install_items(manifest) + await _emit_component_progress(progress, manifest, "start", items=progress_items) + item_lookup = {(item.type, item.id): item for item in progress_items} + try: + for ref in manifest.components: + key = (ref.type, ref.id) + if key in seen: + continue + seen.add(key) + item = item_lookup.get(key) or HubInstallProgressItem(type=ref.type, id=ref.id, optional=ref.optional) + if ref.type == "component": + item.status = "failed" + item.message = "Nested Hub components are not supported" + await _emit_component_progress(progress, manifest, "item", item=item) + raise ValueError("Nested Hub components are not supported") + existing_path = local.infer_local_install(ref.type, ref.id) + if existing_path is not None: + existing_record = local.get_record(ref.type, ref.id) + if existing_record is not None and _can_adopt_existing_ref(ref, existing_record, component_key, existing_path): + adopted_records.append(existing_record) + local.save_installed_record(existing_record.model_copy(update={"installedBy": component_key})) + item.status = "installed" + item.message = "Already installed; adopted by component" + await _emit_component_progress(progress, manifest, "item", item=item) + continue + item.status = "skipped" + item.message = "Already installed" + await _emit_component_progress(progress, manifest, "item", item=item) + continue + item.status = "installing" + await _emit_component_progress(progress, manifest, "item", item=item) + rollback_refs.append(key) + try: + await install_plugin(ref.type, ref.id, scope=scope, installed_by=component_key) + except Exception as exc: + await _rollback_component_ref_installs([key], component_key) + if ref.optional: + item.status = "skipped" + item.message = f"Optional dependency failed to install: {exc}" + await _emit_component_progress(progress, manifest, "item", item=item) + continue + item.status = "failed" + item.message = str(exc) or "Install failed" + await _emit_component_progress(progress, manifest, "item", item=item) + raise + item.status = "installed" + await _emit_component_progress(progress, manifest, "item", item=item) + except Exception: + for original_record in reversed(adopted_records): + local.save_installed_record(original_record) + await _rollback_component_ref_installs(rollback_refs, component_key) + raise + return rollback_refs + + +async def _uninstall_component_refs(manifest: HubPluginManifest) -> bool: + component_key = f"component:{manifest.id}" + seen: set[tuple[PluginType, str]] = set() + removed = False + for ref in reversed(manifest.components): + key = (ref.type, ref.id) + if key in seen or ref.type == "component": + continue + seen.add(key) + record = local.get_record(ref.type, ref.id) + if record is None: + install_path = local.infer_local_install(ref.type, ref.id) + if install_path is None or _is_project_install_path(ref.type, install_path): + continue + elif record.installedBy != component_key: + install_path = Path(record.installPath) if record.installPath else local.infer_local_install(ref.type, ref.id) + if not _can_adopt_existing_ref(ref, record, component_key, install_path): + continue + removed = True + try: + await uninstall_plugin(ref.type, ref.id) + except FileNotFoundError: + local.remove_installed_record(ref.type, ref.id) + except Exception: + if ref.optional: + continue + raise + return removed + + +def _clear_device_template_cache_if_needed(plugin_type: PluginType) -> None: + if plugin_type not in {"tool", "device"}: + return + try: + from flocks.tool.device.plugin_index import clear_device_template_cache + + clear_device_template_cache() + except Exception: + pass async def install_plugin( @@ -156,24 +488,46 @@ async def install_plugin( plugin_id: str, *, scope: str = "global", + installed_by: str | None = None, + progress: InstallProgressCallback | None = None, ) -> InstalledPluginRecord: manifest = load_manifest(plugin_type, plugin_id) src = plugin_root(plugin_type, plugin_id) validate_package(src, manifest) dst = _resolve_install_destination(plugin_type, plugin_id, src, scope) - _copy_package(src, dst) - record = local.make_record( - plugin_type=plugin_type, - plugin_id=plugin_id, - version=manifest.version, - source=f"bundled:{manifest.source.path or ''}", - install_path=dst, - enabled=True, - scope=scope, - ) - local.save_installed_record(record) - await _refresh_runtime(plugin_type) - return record + component_key = f"component:{plugin_id}" + component_had_install = plugin_type == "component" and local.infer_local_install(plugin_type, plugin_id) is not None + component_ref_installs: list[tuple[PluginType, str]] = [] + try: + if plugin_type == "component": + component_ref_installs = await _install_component_refs(manifest, scope=scope, progress=progress) + if plugin_type == "webui": + _copy_webui_package_with_build(plugin_id, src, dst) + else: + _copy_package(src, dst) + _copy_attached_access_contracts(plugin_type, plugin_id, src, scope) + record = local.make_record( + plugin_type=plugin_type, + plugin_id=plugin_id, + version=manifest.version, + source=f"bundled:{manifest.source.path or ''}", + install_path=dst, + enabled=True, + scope=scope, + installed_by=installed_by, + ) + local.save_installed_record(record) + clear_catalog_caches() + await _refresh_runtime(plugin_type) + if plugin_type == "component": + await _emit_component_progress(progress, manifest, "complete", record=record, message="Installed") + return record + except Exception: + if plugin_type == "component": + if not component_had_install: + _rollback_install_path(plugin_type, plugin_id, dst, scope) + await _rollback_component_ref_installs(component_ref_installs, component_key) + raise async def update_plugin(plugin_type: PluginType, plugin_id: str, *, scope: str = "global") -> InstalledPluginRecord: @@ -226,11 +580,18 @@ def _cleanup_orphan_api_services(storage_keys: list[str]) -> None: async def uninstall_plugin(plugin_type: PluginType, plugin_id: str) -> bool: + manifest = load_manifest(plugin_type, plugin_id) if plugin_type == "component" else None record = local.get_record(plugin_type, plugin_id) install_path = Path(record.installPath) if record and record.installPath else local.infer_local_install(plugin_type, plugin_id) if install_path is None or not install_path.exists(): + children_removed = await _uninstall_component_refs(manifest) if manifest is not None else False + had_record = record is not None local.remove_installed_record(plugin_type, plugin_id) - return False + clear_catalog_caches() + _clear_device_template_cache_if_needed(plugin_type) + if children_removed or had_record: + await _refresh_runtime(plugin_type) + return children_removed or had_record project_root = local.install_root(plugin_type, "project").resolve() resolved_install_path = install_path.resolve() if resolved_install_path == project_root or project_root in resolved_install_path.parents: @@ -247,8 +608,15 @@ async def uninstall_plugin(plugin_type: PluginType, plugin_id: str) -> bool: if plugin_type in {"tool", "device"} else [] ) - shutil.rmtree(install_path) + if manifest is not None: + await _uninstall_component_refs(manifest) + if install_path.is_dir(): + shutil.rmtree(install_path) + else: + install_path.unlink() + _remove_attached_access_contracts(plugin_type, plugin_id, record.scope if record else "global") local.remove_installed_record(plugin_type, plugin_id) + clear_catalog_caches() _cleanup_orphan_api_services(orphan_keys) await _refresh_runtime(plugin_type) return True diff --git a/flocks/hub/local.py b/flocks/hub/local.py index 68e4ddc3a..e7f5d2580 100644 --- a/flocks/hub/local.py +++ b/flocks/hub/local.py @@ -28,6 +28,10 @@ def install_root(plugin_type: PluginType, scope: str = "global") -> Path: return root / "agents" if plugin_type == "workflow": return root / "workflows" + if plugin_type == "webui": + return root / "contracts" / "webui" + if plugin_type == "component": + return root / "components" if plugin_type == "device": # Device plugins live as a subdirectory of tools/ so the runtime # tool loader (which expects ``/tools///``) @@ -99,6 +103,15 @@ def has_install_payload(plugin_type: PluginType, path: Path) -> bool: return (path / "agent.yaml").is_file() if plugin_type == "workflow": return (path / "workflow.json").is_file() or (path / "workflow.md").is_file() + if plugin_type == "webui": + if path.is_file(): + return False + return (path / "workspace.json").is_file() or any( + candidate.name == "manifest.json" and candidate.is_file() + for candidate in path.rglob("manifest.json") + ) + if plugin_type == "component": + return path.is_dir() and (path / "component.json").is_file() if plugin_type in {"tool", "device"}: if path.is_file(): return path.suffix in {".yaml", ".yml", ".py"} @@ -122,12 +135,14 @@ def make_record( install_path: Path, enabled: bool = True, scope: str = "global", + installed_by: Optional[str] = None, ) -> InstalledPluginRecord: return InstalledPluginRecord( id=plugin_id, type=plugin_type, version=version, source=source, + installedBy=installed_by, installedAt=int(time.time() * 1000), enabled=enabled, scope="project" if scope == "project" else "global", @@ -149,12 +164,16 @@ def infer_local_install(plugin_type: PluginType, plugin_id: str) -> Optional[Pat base / "device" / plugin_id, base / "mcp" / plugin_id, base / "generated" / plugin_id, + base / "python" / plugin_id, ): if has_install_payload(plugin_type, nested): return nested - for candidate in base.rglob(f"{plugin_id}.yaml"): - if has_install_payload(plugin_type, candidate.parent): - return candidate.parent + for suffix in (".yaml", ".yml", ".py"): + for candidate in base.rglob(f"{plugin_id}{suffix}"): + if has_install_payload(plugin_type, candidate): + return candidate + if has_install_payload(plugin_type, candidate.parent): + return candidate.parent if plugin_type == "device": # Device installs live under ``/device//``. We already # checked the canonical path above via ``install_dir``; the loop @@ -172,7 +191,7 @@ def infer_local_installs() -> dict[tuple[PluginType, str], Path]: """Scan installed plugin roots once and return plugin id -> install path.""" result: dict[tuple[PluginType, str], Path] = {} - for plugin_type in ("skill", "agent", "workflow"): + for plugin_type in ("skill", "agent", "workflow", "webui", "component"): for scope in ("global", "project"): base = install_root(plugin_type, scope) if not base.is_dir(): @@ -194,7 +213,7 @@ def infer_local_installs() -> dict[tuple[PluginType, str], Path]: # device`` in ``_provider.yaml``), so we surface those entries # keyed as ``("device", id)`` instead of ``("tool", id)`` to keep # the catalog state in sync with the runtime install path. - for group in ("api", "device", "mcp", "generated"): + for group in ("api", "device", "mcp", "generated", "python"): group_dir = base / group if not group_dir.is_dir(): continue @@ -202,6 +221,12 @@ def infer_local_installs() -> dict[tuple[PluginType, str], Path]: for child in group_dir.iterdir(): if child.is_dir() and has_install_payload("tool", child): result.setdefault((entry_type, child.name), child) + elif ( + child.is_file() + and child.suffix in {".yaml", ".yml", ".py"} + and has_install_payload("tool", child) + ): + result.setdefault(("tool", child.stem), child) for candidate in base.rglob("*"): if not candidate.is_file() or candidate.name == "__init__.py": continue diff --git a/flocks/hub/models.py b/flocks/hub/models.py index 8227336ea..a9640d21a 100644 --- a/flocks/hub/models.py +++ b/flocks/hub/models.py @@ -7,7 +7,7 @@ from pydantic import BaseModel, ConfigDict, Field -PluginType = Literal["skill", "agent", "tool", "device", "workflow"] +PluginType = Literal["skill", "agent", "tool", "device", "workflow", "webui", "component"] PluginState = Literal[ "available", "installed", @@ -49,6 +49,13 @@ class HubRisk(BaseModel): reasons: list[str] = Field(default_factory=list) +class HubComponentRef(BaseModel): + type: PluginType + id: str + optional: bool = False + adoptExisting: bool = False + + class HubPluginManifest(BaseModel): model_config = ConfigDict(extra="allow") @@ -56,6 +63,7 @@ class HubPluginManifest(BaseModel): id: str type: PluginType name: str + nameCn: Optional[str] = None description: str = "" version: str = "0.0.0" author: Optional[str] = None @@ -73,6 +81,7 @@ class HubPluginManifest(BaseModel): permissions: HubPermissions = Field(default_factory=HubPermissions) risk: HubRisk = Field(default_factory=HubRisk) entrypoints: list[str] = Field(default_factory=list) + components: list[HubComponentRef] = Field(default_factory=list) checksums: dict[str, str] = Field(default_factory=dict) @@ -80,6 +89,7 @@ class HubIndexEntry(BaseModel): id: str type: PluginType name: str + nameCn: Optional[str] = None description: str = "" descriptionCn: Optional[str] = None version: str = "0.0.0" @@ -103,6 +113,7 @@ class InstalledPluginRecord(BaseModel): type: PluginType version: str = "0.0.0" source: str = "" + installedBy: Optional[str] = None installedAt: int enabled: bool = True scope: Literal["global", "project"] = "global" @@ -110,10 +121,37 @@ class InstalledPluginRecord(BaseModel): installPath: Optional[str] = None +HubInstallProgressStatus = Literal["pending", "installing", "installed", "skipped", "failed", "completed"] + + +class HubInstallProgressItem(BaseModel): + type: PluginType + id: str + name: Optional[str] = None + nameCn: Optional[str] = None + optional: bool = False + status: HubInstallProgressStatus = "pending" + message: Optional[str] = None + + +class HubInstallProgressEvent(BaseModel): + event: Literal["start", "item", "complete", "error"] + id: str + type: PluginType + name: str + nameCn: Optional[str] = None + total: int = 0 + item: Optional[HubInstallProgressItem] = None + items: list[HubInstallProgressItem] = Field(default_factory=list) + record: Optional[InstalledPluginRecord] = None + message: Optional[str] = None + + class HubCatalogEntry(BaseModel): id: str type: PluginType name: str + nameCn: Optional[str] = None description: str = "" descriptionCn: Optional[str] = None version: str = "0.0.0" diff --git a/flocks/mcp/server.py b/flocks/mcp/server.py index 70888d219..6ef4eaa63 100644 --- a/flocks/mcp/server.py +++ b/flocks/mcp/server.py @@ -9,8 +9,8 @@ from typing import Dict, Optional, Any, List from flocks.mcp.client import McpClient from flocks.mcp.types import ( - McpStatus, - McpStatusInfo, + McpStatus, + McpStatusInfo, McpServerInfo, McpToolDef, McpResource, @@ -30,17 +30,17 @@ class McpServerManager: """ MCP Server Manager - + Responsibilities: - Manage connections to multiple MCP servers - Discover and register tools - Track server status - Handle reconnection and error recovery - + Credentials are resolved at config load time via {secret:xxx} references in ~/.flocks/config/flocks.json. The resolved config is passed directly here. """ - + def __init__(self): """Initialize server manager""" self._clients: Dict[str, McpClient] = {} @@ -49,118 +49,120 @@ def __init__(self): self._resources_cache: Dict[str, List[McpResource]] = {} self._configs: Dict[str, Dict[str, Any]] = {} # saved for retry self._lock = asyncio.Lock() + self._init_lock = asyncio.Lock() self._initialized = False self._retry_task: Optional[asyncio.Task] = None - + async def init(self) -> None: """ Initialize all configured MCP servers - + Load server configurations from config file and start all enabled servers in parallel. Servers that fail to connect will be retried in the background with exponential backoff. """ - if self._initialized: - # ``MCP.init`` is invoked from both the global server lifespan and - # the per-instance bootstrap on startup. The guard above keeps the - # call idempotent, so this is informational only and should not - # surface as a warning in operational logs. - log.debug("mcp.already_initialized") - return - - log.info("mcp.initializing") - - # Load configuration - config = await Config.get() - mcp_config = getattr(config, 'mcp', None) - - if not mcp_config or not isinstance(mcp_config, dict): - log.info("mcp.no_config", {"message": "No MCP servers configured"}) - self._initialized = True - return - - # Filter enabled servers — config values may be Pydantic models (McpLocalConfig / - # McpRemoteConfig) or plain dicts depending on how Pydantic validated the Union type. - # Normalize everything to plain dicts so _connect_and_register can use dict access. - enabled_servers: Dict[str, Dict[str, Any]] = {} - for name, server_config in mcp_config.items(): - if hasattr(server_config, 'model_dump'): - cfg: Dict[str, Any] = server_config.model_dump(exclude_none=True) - elif isinstance(server_config, dict): - cfg = server_config - else: - continue - if cfg.get('enabled', True): - enabled_servers[name] = cfg - - if not enabled_servers: - log.info("mcp.no_enabled_servers") + async with self._init_lock: + if self._initialized: + # ``MCP.init`` is invoked from both the global server lifespan and + # the per-instance bootstrap on startup. The guard above keeps the + # call idempotent, so this is informational only and should not + # surface as a warning in operational logs. + log.debug("mcp.already_initialized") + return + + log.info("mcp.initializing") + + # Load configuration + config = await Config.get() + mcp_config = getattr(config, 'mcp', None) + + if not mcp_config or not isinstance(mcp_config, dict): + log.info("mcp.no_config", {"message": "No MCP servers configured"}) + self._initialized = True + return + + # Filter enabled servers — config values may be Pydantic models (McpLocalConfig / + # McpRemoteConfig) or plain dicts depending on how Pydantic validated the Union type. + # Normalize everything to plain dicts so _connect_and_register can use dict access. + enabled_servers: Dict[str, Dict[str, Any]] = {} + for name, server_config in mcp_config.items(): + if hasattr(server_config, 'model_dump'): + cfg: Dict[str, Any] = server_config.model_dump(exclude_none=True) + elif isinstance(server_config, dict): + cfg = server_config + else: + continue + if cfg.get('enabled', True): + enabled_servers[name] = cfg + + if not enabled_servers: + log.info("mcp.no_enabled_servers") + self._initialized = True + return + + # Save configs for retry + self._configs.update(enabled_servers) + + log.info("mcp.starting_servers", { + "total": len(enabled_servers), + "servers": list(enabled_servers.keys()) + }) + + # Start all servers in parallel (allow partial failures) + tasks = [ + self._connect_and_register(name, server_config) + for name, server_config in enabled_servers.items() + ] + + results = await asyncio.gather(*tasks, return_exceptions=True) + + # Count results + succeeded = sum(1 for r in results if not isinstance(r, Exception)) + failed = len(results) - succeeded + self._initialized = True - return - - # Save configs for retry - self._configs.update(enabled_servers) - - log.info("mcp.starting_servers", { - "total": len(enabled_servers), - "servers": list(enabled_servers.keys()) - }) - - # Start all servers in parallel (allow partial failures) - tasks = [ - self._connect_and_register(name, server_config) - for name, server_config in enabled_servers.items() - ] - - results = await asyncio.gather(*tasks, return_exceptions=True) - - # Count results - succeeded = sum(1 for r in results if not isinstance(r, Exception)) - failed = len(results) - succeeded - - self._initialized = True - - log.info("mcp.initialized", { - "total": len(results), - "succeeded": succeeded, - "failed": failed - }) - - # Start background retry task for any failed connections - if failed > 0: - self._retry_task = asyncio.create_task(self._retry_failed_servers()) - + + log.info("mcp.initialized", { + "total": len(results), + "succeeded": succeeded, + "failed": failed + }) + + # Start background retry task for any failed connections + if failed > 0: + self._retry_task = asyncio.create_task(self._retry_failed_servers()) + async def _retry_failed_servers(self) -> None: """ Background task: retry servers that failed to connect at startup. - + Uses a fixed retry schedule (_RETRY_DELAYS). Stops retrying a server once it connects successfully or is manually removed/disabled. """ for delay in _RETRY_DELAYS: await asyncio.sleep(delay) - + failed_servers = { name: cfg for name, cfg in self._configs.items() if self._status.get(name, McpStatusInfo(status=McpStatus.FAILED)).status == McpStatus.FAILED and name not in self._clients } - + if not failed_servers: log.info("mcp.retry.all_connected") return - + log.info("mcp.retry.attempt", { "servers": list(failed_servers.keys()), "next_delay": delay }) - + tasks = [ self._connect_and_register(name, cfg) for name, cfg in failed_servers.items() ] await asyncio.gather(*tasks, return_exceptions=True) - + # Final check after last retry still_failed = [ name for name in self._configs @@ -171,33 +173,33 @@ async def _retry_failed_servers(self) -> None: log.warn("mcp.retry.exhausted", {"servers": still_failed}) async def _connect_and_register( - self, - name: str, + self, + name: str, config: Dict[str, Any] ) -> None: """ Connect to server and register tools - + Complete workflow: 1. Create client 2. Connect to server 3. Discover tools and resources 4. Register tools to ToolRegistry 5. Update status - + Args: name: Server name config: Server configuration """ try: log.info("mcp.connecting", {"server": name}) - + # Credentials are already resolved via {secret:xxx} in config loading. # No separate injection needed. server_env = config.get("environment") if server_env is None: server_env = config.get("env") - + # 1. Create client client = McpClient( name=name, @@ -210,15 +212,15 @@ async def _connect_and_register( transport=config.get('transport', 'auto'), timeout=config.get('timeout', 30.0) ) - + # 2. Connect await client.connect() self._clients[name] = client - + # 3. Discover tools tools = await client.list_tools() self._tools_cache[name] = tools - + # 4. Discover resources (optional) try: resources = await client.list_resources() @@ -229,10 +231,10 @@ async def _connect_and_register( "error": str(e) }) self._resources_cache[name] = [] - + # 5. Register tools registered_count = await self._register_tools(name, tools, client) - + # 6. Update status self._status[name] = McpStatusInfo( status=McpStatus.CONNECTED, @@ -240,14 +242,14 @@ async def _connect_and_register( tools_count=len(tools), resources_count=len(self._resources_cache.get(name, [])) ) - + log.info("mcp.server_ready", { "server": name, "tools": len(tools), "resources": len(self._resources_cache.get(name, [])), "registered": registered_count }) - + except Exception as e: log.error("mcp.connect_failed", { "server": name, @@ -258,39 +260,39 @@ async def _connect_and_register( error=str(e) ) raise # Propagate exception for gather to capture - + async def _register_tools( - self, - server_name: str, + self, + server_name: str, tools: List[McpToolDef], client: McpClient ) -> int: """ Register tools to ToolRegistry - + Args: server_name: Server name tools: List of tools client: MCP client - + Returns: Number of successfully registered tools """ registered = 0 - + for mcp_tool in tools: try: # Convert to Flocks Tool flocks_tool = McpToolAdapter.convert_tool( - server_name, - mcp_tool, + server_name, + mcp_tool, client ) - + # Register to Flocks ToolRegistry from flocks.tool import ToolRegistry ToolRegistry.register(flocks_tool) - + # Track metadata schema_hash = McpToolAdapter.get_schema_hash(mcp_tool) McpToolRegistry.track( @@ -299,65 +301,65 @@ async def _register_tools( flocks_tool_name=flocks_tool.info.name, schema_hash=schema_hash ) - + registered += 1 - + log.debug("mcp.tool_registered", { "server": server_name, "mcp_tool": mcp_tool.name, "flocks_tool": flocks_tool.info.name }) - + except Exception as e: log.error("mcp.tool_register_failed", { "server": server_name, "tool": mcp_tool.name, "error": str(e) }) - + return registered - + async def status(self) -> Dict[str, McpStatusInfo]: """ Get status of all servers - + Returns: Dictionary mapping server name to status info """ if not self._initialized: await self.init() - + return self._status.copy() - + async def get_server_info(self, name: str) -> Optional[McpServerInfo]: """ Get detailed server information - + Args: name: Server name - + Returns: Server information, or None if not found """ status = self._status.get(name) if not status: return None - + return McpServerInfo( name=name, status=status, tools=self._tools_cache.get(name, []), resources=self._resources_cache.get(name, []) ) - + async def connect(self, name: str, config: Dict[str, Any]) -> bool: """ Connect to specified server - + Args: name: Server name config: Server configuration - + Returns: True if connection successful """ @@ -372,7 +374,7 @@ async def connect(self, name: str, config: Dict[str, Any]) -> bool: "error": str(e) }) return False - + async def disconnect(self, name: str) -> bool: """ Disconnect from server (keeps status entry as DISCONNECTED). @@ -455,63 +457,63 @@ async def remove(self, name: str) -> bool: except Exception as e: log.error("mcp.remove_error", {"server": name, "error": str(e)}) return False - + async def refresh_tools(self, name: str) -> int: """ Refresh server's tool list - + Args: name: Server name - + Returns: Number of updated tools """ if name not in self._clients: raise ValueError(f"Server not connected: {name}") - + async with self._lock: try: client = self._clients[name] - + # Fetch tool list again new_tools = await client.list_tools() old_tools = self._tools_cache.get(name, []) - + # Simple strategy: unregister all old tools, register all new tools # TODO: Implement incremental update (P2 feature) - + # Unregister old tools tool_names = McpToolRegistry.untrack_server(name) from flocks.tool import ToolRegistry for tool_name in tool_names: ToolRegistry.unregister(tool_name) - + # Register new tools registered = await self._register_tools(name, new_tools, client) - + # Update cache self._tools_cache[name] = new_tools - + log.info("mcp.tools_refreshed", { "server": name, "old_count": len(old_tools), "new_count": len(new_tools), "registered": registered }) - + return registered - + except Exception as e: log.error("mcp.refresh_error", { "server": name, "error": str(e) }) raise - + async def shutdown(self) -> None: """Shutdown all connections""" log.info("mcp.shutting_down") - + # Cancel background retry task if running if self._retry_task and not self._retry_task.done(): self._retry_task.cancel() @@ -520,7 +522,7 @@ async def shutdown(self) -> None: except asyncio.CancelledError: pass self._retry_task = None - + for name, client in list(self._clients.items()): try: await client.disconnect() @@ -529,14 +531,14 @@ async def shutdown(self) -> None: "server": name, "error": str(e) }) - + self._clients.clear() self._status.clear() self._tools_cache.clear() self._resources_cache.clear() self._configs.clear() self._initialized = False - + log.info("mcp.shutdown_complete") diff --git a/flocks/project/instance.py b/flocks/project/instance.py index 68b82d28c..40f5e6025 100644 --- a/flocks/project/instance.py +++ b/flocks/project/instance.py @@ -190,8 +190,17 @@ async def create_context(): cls._cache[directory] = asyncio.create_task(create_context()) - # Wait for context to be ready - ctx = await cls._cache[directory] + # Wait for context to be ready. If initialization fails, drop the + # failed task so a later request can retry after transient storage or + # startup recovery completes. + task = cls._cache[directory] + try: + ctx = await task + except Exception: + async with cls._lock: + if cls._cache.get(directory) is task: + cls._cache.pop(directory, None) + raise # Execute fn within context if fn: diff --git a/flocks/provider/provider.py b/flocks/provider/provider.py index 180d4e97c..89c815c71 100644 --- a/flocks/provider/provider.py +++ b/flocks/provider/provider.py @@ -389,6 +389,7 @@ class DynamicOpenAIProvider(OpenAIBaseProvider): # not enforce auth; let _get_client() fall back to a # sentinel key instead of raising. ALLOW_NO_API_KEY = True + PREFER_MAX_COMPLETION_TOKENS = True def __init__(self): super().__init__( diff --git a/flocks/provider/sdk/openai_base.py b/flocks/provider/sdk/openai_base.py index 7c8c595b6..e99d36536 100644 --- a/flocks/provider/sdk/openai_base.py +++ b/flocks/provider/sdk/openai_base.py @@ -350,6 +350,97 @@ def _supports_include_usage_fallback(exc: Exception) -> bool: ) +def _supports_max_completion_tokens_fallback(exc: Exception) -> bool: + """Return True when the provider rejects ``max_completion_tokens``.""" + message = str(exc).lower() + return "max_completion_tokens" in message and any( + marker in message + for marker in ( + "unsupported parameter", + "unknown parameter", + "unrecognized parameter", + "extra inputs are not permitted", + "extra fields not permitted", + "not permitted", + ) + ) + + +def resolve_openai_token_limit(kwargs: Dict[str, Any]) -> Optional[int]: + """Resolve output token limits from either OpenAI naming variant.""" + max_completion_tokens = kwargs.get("max_completion_tokens") + if max_completion_tokens is not None: + return max_completion_tokens + return kwargs.get("max_tokens") + + +def apply_openai_token_limit( + params: Dict[str, Any], + max_tokens: Optional[int], + *, + prefer_completion_tokens: bool = False, + completion_tokens_explicit: bool = False, +) -> None: + """Apply the preferred token-limit field to an OpenAI-style payload.""" + if max_tokens is None: + return + if completion_tokens_explicit or prefer_completion_tokens: + params["max_completion_tokens"] = max_tokens + else: + params["max_tokens"] = max_tokens + + +async def create_chat_completion_with_fallbacks( + create_call, + params: Dict[str, Any], + *, + max_tokens: Optional[int], + logger: Any, + log_prefix: str, +) -> Any: + """Execute a chat completion request with transport compatibility fallbacks.""" + current_params = dict(params) + include_usage_retried = False + max_completion_tokens_retried = False + + while True: + try: + return await create_call(**current_params) + except Exception as exc: + if ( + not max_completion_tokens_retried + and max_tokens is not None + and "max_completion_tokens" in current_params + and "max_tokens" not in current_params + and _supports_max_completion_tokens_fallback(exc) + ): + max_completion_tokens_retried = True + logger.warn(f"{log_prefix}.max_completion_tokens_unsupported", { + "model": current_params.get("model"), + "error": str(exc), + }) + current_params = dict(current_params) + current_params.pop("max_completion_tokens", None) + current_params["max_tokens"] = max_tokens + continue + + if ( + not include_usage_retried + and "stream_options" in current_params + and _supports_include_usage_fallback(exc) + ): + include_usage_retried = True + logger.warn(f"{log_prefix}.stream.include_usage_unsupported", { + "model": current_params.get("model"), + "error": str(exc), + }) + current_params = dict(current_params) + current_params.pop("stream_options", None) + continue + + raise + + class ThinkTagExtractor: """Extract reasoning content from streaming LLM output. @@ -698,6 +789,7 @@ class OpenAIBaseProvider(BaseProvider): ENV_BASE_URL: str = "" CATALOG_ID: str = "" ALLOW_NO_API_KEY: bool = False + PREFER_MAX_COMPLETION_TOKENS: bool = False NO_API_KEY_PLACEHOLDER: str = "not-needed" def __init__(self, provider_id: str, name: str): @@ -821,6 +913,8 @@ async def chat( openai_messages = self._format_messages(messages) thinking = kwargs.get("thinking") + max_tokens = resolve_openai_token_limit(kwargs) + max_completion_tokens_explicit = kwargs.get("max_completion_tokens") is not None params: Dict[str, Any] = { "model": model_id, @@ -837,8 +931,12 @@ async def chat( if extra_body: params["extra_body"] = extra_body - if kwargs.get("max_tokens"): - params["max_tokens"] = kwargs["max_tokens"] + apply_openai_token_limit( + params, + max_tokens, + prefer_completion_tokens=self.PREFER_MAX_COMPLETION_TOKENS, + completion_tokens_explicit=max_completion_tokens_explicit, + ) if kwargs.get("tools"): params["tools"] = kwargs["tools"] @@ -850,12 +948,18 @@ async def chat( "thinking_enabled": bool(thinking), "has_extra_body": "extra_body" in params, "has_tools": bool(kwargs.get("tools")), - "max_tokens": kwargs.get("max_tokens"), + "max_tokens": max_tokens, "has_temperature": "temperature" in params, "message_summary": _summarise_messages(openai_messages), }) - response = await client.chat.completions.create(**params) + response = await create_chat_completion_with_fallbacks( + client.chat.completions.create, + params, + max_tokens=max_tokens, + logger=log, + log_prefix="openai_base", + ) if not response.choices: extra = getattr(response, "model_extra", {}) or {} err_detail = extra.get("error") or extra.get("message") or str(extra) or "no choices returned" @@ -894,6 +998,8 @@ async def chat_stream( openai_messages = self._format_messages(messages) thinking = kwargs.get("thinking") + max_tokens = resolve_openai_token_limit(kwargs) + max_completion_tokens_explicit = kwargs.get("max_completion_tokens") is not None params: Dict[str, Any] = { "model": model_id, @@ -912,8 +1018,12 @@ async def chat_stream( if extra_body: params["extra_body"] = extra_body - if kwargs.get("max_tokens"): - params["max_tokens"] = kwargs["max_tokens"] + apply_openai_token_limit( + params, + max_tokens, + prefer_completion_tokens=self.PREFER_MAX_COMPLETION_TOKENS, + completion_tokens_explicit=max_completion_tokens_explicit, + ) if kwargs.get("tools"): params["tools"] = kwargs["tools"] @@ -924,24 +1034,19 @@ async def chat_stream( "thinking_enabled": bool(thinking), "has_extra_body": "extra_body" in params, "has_tools": bool(kwargs.get("tools")), - "max_tokens": kwargs.get("max_tokens"), + "max_tokens": max_tokens, "has_temperature": "temperature" in params, "include_usage": True, "message_summary": _summarise_messages(openai_messages), }) - try: - stream = await client.chat.completions.create(**params) - except Exception as exc: - if not _supports_include_usage_fallback(exc): - raise - log.warn("openai_base.stream.include_usage_unsupported", { - "model": model_id, - "error": str(exc), - }) - params_without_usage = dict(params) - params_without_usage.pop("stream_options", None) - stream = await client.chat.completions.create(**params_without_usage) + stream = await create_chat_completion_with_fallbacks( + client.chat.completions.create, + params, + max_tokens=max_tokens, + logger=log, + log_prefix="openai_base", + ) tool_calls: Dict[int, Dict[str, Any]] = {} emitted_substantive_chunk = False stream_usage: Optional[Dict[str, int]] = None diff --git a/flocks/provider/sdk/openai_compatible.py b/flocks/provider/sdk/openai_compatible.py index c38183e20..5f21424f1 100644 --- a/flocks/provider/sdk/openai_compatible.py +++ b/flocks/provider/sdk/openai_compatible.py @@ -24,14 +24,16 @@ from flocks.provider.sdk.openai_base import ( DEFAULT_HTTP_TIMEOUT, ThinkTagExtractor, + apply_openai_token_limit, build_reasoning_metadata, + create_chat_completion_with_fallbacks, _coerce_bool, _normalize_stream_usage, - _supports_include_usage_fallback, extract_reasoning_content_with_source, extract_reasoning_details, format_openai_content, format_openai_messages, + resolve_openai_token_limit, resolve_verify_ssl, ) from flocks.utils.log import Log @@ -179,7 +181,8 @@ async def chat( formatted_messages = self._format_messages(messages) # Extract parameters - max_tokens = kwargs.get("max_tokens") + max_tokens = resolve_openai_token_limit(kwargs) + max_completion_tokens_explicit = kwargs.get("max_completion_tokens") is not None tools = kwargs.get("tools") thinking = kwargs.get("thinking") @@ -201,8 +204,12 @@ async def chat( if extra_body: request_params["extra_body"] = extra_body - if max_tokens: - request_params["max_tokens"] = max_tokens + apply_openai_token_limit( + request_params, + max_tokens, + prefer_completion_tokens=True, + completion_tokens_explicit=max_completion_tokens_explicit, + ) if tools: # Some compatible APIs don't support tools try: @@ -210,7 +217,13 @@ async def chat( except Exception: self.log.warn("openai_compatible.tools.not_supported", {"model": model_id}) - response = await client.chat.completions.create(**request_params) + response = await create_chat_completion_with_fallbacks( + client.chat.completions.create, + request_params, + max_tokens=max_tokens, + logger=self.log, + log_prefix="openai_compatible", + ) # Format response choice = response.choices[0] @@ -244,7 +257,8 @@ async def chat_stream( formatted_messages = self._format_messages(messages) # Extract parameters - max_tokens = kwargs.get("max_tokens") + max_tokens = resolve_openai_token_limit(kwargs) + max_completion_tokens_explicit = kwargs.get("max_completion_tokens") is not None tools = kwargs.get("tools") thinking = kwargs.get("thinking") @@ -274,8 +288,12 @@ async def chat_stream( if extra_body: request_params["extra_body"] = extra_body - if max_tokens: - request_params["max_tokens"] = max_tokens + apply_openai_token_limit( + request_params, + max_tokens, + prefer_completion_tokens=True, + completion_tokens_explicit=max_completion_tokens_explicit, + ) if tools: # Some compatible APIs don't support tools try: @@ -291,18 +309,13 @@ async def chat_stream( "include_usage": True, }) - try: - stream = await client.chat.completions.create(**request_params) - except Exception as exc: - if not _supports_include_usage_fallback(exc): - raise - self.log.warn("openai_compatible.stream.include_usage_unsupported", { - "model": model_id, - "error": str(exc), - }) - request_params = dict(request_params) - request_params.pop("stream_options", None) - stream = await client.chat.completions.create(**request_params) + stream = await create_chat_completion_with_fallbacks( + client.chat.completions.create, + request_params, + max_tokens=max_tokens, + logger=self.log, + log_prefix="openai_compatible", + ) # Stateful extractor to separate ... from content. think_extractor = ThinkTagExtractor() diff --git a/flocks/server/app.py b/flocks/server/app.py index 8c7cb1b24..f557c79e8 100644 --- a/flocks/server/app.py +++ b/flocks/server/app.py @@ -13,7 +13,7 @@ from pathlib import Path from typing import Any, Callable, Optional from contextlib import asynccontextmanager -from fastapi import FastAPI, Request, Response, status +from fastapi import FastAPI, Request, status from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from fastapi.exceptions import RequestValidationError @@ -26,6 +26,7 @@ from flocks.auth.service import AuthService from flocks.extensions import ExtensionOptions, handler_name, normalize_fail_policy, normalize_timeout from flocks.server.auth import apply_auth_for_request, clear_auth_context +from flocks.server.static_webui import maybe_serve_static_webui # Load .env file at startup try: @@ -196,6 +197,17 @@ async def _recover_orphan_tool_parts() -> None: _recover_orphan_tool_parts, ) + try: + from flocks.console.scheduler import ConsoleSyncScheduler + + async def _start_console_sync_phase() -> None: + await ConsoleSyncScheduler.send_startup_heartbeat() + await ConsoleSyncScheduler.start() + + _schedule_startup_phase(app, log, "console.sync.start", _start_console_sync_phase) + except Exception as e: + log.warning("console.sync.start_failed", {"error": str(e)}) + # Ensure default device room exists, then migrate legacy device API # configs from flocks.json → device_integrations table. try: @@ -388,17 +400,25 @@ def _start_agent_watcher() -> None: except Exception as e: log.warning("agent.watcher.init_failed", {"error": str(e)}) - # Start Tool file watcher (auto-reload plugin tools on file changes) + # Warm ToolRegistry in the background so the first Tool page load usually + # hits a hot in-memory registry instead of doing plugin discovery. try: from flocks.tool.registry import ToolRegistry + def _init_tool_registry() -> None: + ToolRegistry.init() + log.info("tool.registry.initialized") + + _schedule_startup_phase(app, log, "tool.registry.init", lambda: asyncio.to_thread(_init_tool_registry)) + + # Start Tool file watcher (auto-reload plugin tools on file changes) def _start_tool_watcher() -> None: ToolRegistry.start_watcher() log.info("tool.watcher.initialized") _schedule_startup_phase(app, log, "tool.watcher.start", _start_tool_watcher) except Exception as e: - log.warning("tool.watcher.init_failed", {"error": str(e)}) + log.warning("tool.registry.init_failed", {"error": str(e)}) # Start WebUI page watcher (auto-build user custom pages) try: @@ -477,6 +497,13 @@ async def _delayed_trigger_runtime_start() -> None: if background_tasks: await asyncio.gather(*background_tasks, return_exceptions=True) + try: + from flocks.console.scheduler import ConsoleSyncScheduler + + await ConsoleSyncScheduler.stop() + except Exception as exc: + log.warning("console.sync.stop_failed", {"error": str(exc)}) + # Notify SSE clients before stopping sessions, MCP transports, and other # long-lived runtime services so browser listeners see the shutdown event. try: @@ -659,6 +686,13 @@ async def _run_http_middleware_hooks(request: Request, context: dict[str, Any]) "/api/session/status", }) +_SECURITY_HEADERS = { + "X-Content-Type-Options": "nosniff", + "Referrer-Policy": "no-referrer", + "Content-Security-Policy": "frame-ancestors 'self'", + "Permissions-Policy": "camera=(), microphone=(), geolocation=()", +} + def _is_noisy_request_path(path: str) -> bool: """Return True for high-frequency polling endpoints that are noisy on success.""" @@ -681,7 +715,7 @@ def _should_log_request(path: str, status_code: int) -> bool: # CORS Configuration # # Priority order: -# 1. Runtime env vars exported by ``start_backend()`` → add the concrete +# 1. Runtime env vars exported by the supervised backend launcher → add the concrete # ``_FLOCKS_WEBUI_*`` origin inferred from the current CLI launch. # 2. Explicit ``server.cors`` in flocks.json → append user-configured # origins without discarding the runtime ones. @@ -780,7 +814,15 @@ async def __call__(self, scope, receive, send): await self._inner(scope, receive, send) -# Instance Context Middleware +@app.middleware("http") +async def security_headers_middleware(request: Request, call_next): + """Attach baseline browser security headers to every HTTP response.""" + response = await call_next(request) + for name, value in _SECURITY_HEADERS.items(): + response.headers.setdefault(name, value) + return response + + @app.middleware("http") async def instance_context_middleware(request: Request, call_next): """ @@ -796,7 +838,7 @@ async def instance_context_middleware(request: Request, call_next): from urllib.parse import unquote from flocks.project.instance import Instance from flocks.project.bootstrap import instance_bootstrap - + # Skip instance context for global routes, static files, and simple endpoints skip_prefixes = { "/global", "/docs", "/redoc", "/openapi.json", "/health", @@ -891,6 +933,15 @@ async def auth_guard_middleware(request: Request, call_next): clear_auth_context(token) +@app.middleware("http") +async def static_webui_middleware(request: Request, call_next): + """Serve the SPA shell before auth for browser navigations.""" + static_response = await maybe_serve_static_webui(request) + if static_response is not None: + return static_response + return await call_next(request) + + # Error Handlers @app.exception_handler(RequestValidationError) async def validation_exception_handler(request: Request, exc: RequestValidationError): diff --git a/flocks/server/auth.py b/flocks/server/auth.py index 654f7d381..593508eeb 100644 --- a/flocks/server/auth.py +++ b/flocks/server/auth.py @@ -23,11 +23,10 @@ PUBLIC_PATHS = frozenset({ "/", "/health", - "/docs", - "/redoc", - "/openapi.json", "/favicon.ico", "/api/health", + "/api/config/ui-display", + "/api/config/ui-favicon", "/api/auth/login", "/api/auth/bootstrap-status", "/api/auth/bootstrap-admin", @@ -65,6 +64,11 @@ # downstream handler is fully responsible for its own authentication # (signature checks, IP allowlists, replay protection, …). Do NOT add # entries that touch user data without a per-request integrity check. +# +# Workflow webhook paths also need to be reachable by external systems that +# cannot present a browser session. They are safe to exempt here only because +# _authorize_webhook_trigger() fails closed unless the trigger config supplies +# api_key or hmac authentication. See: https://github.com/AgentFlocks/flocks/issues/454 PUBLIC_PATH_REGEXES = ( re.compile(r"^/(?:api/)?channel/[^/]+/webhook/?$"), re.compile(r"^/webhook/workflows/[^/]+/[^/]+/?$"), diff --git a/flocks/server/routes/agent.py b/flocks/server/routes/agent.py index e36583253..5df1fa9d4 100644 --- a/flocks/server/routes/agent.py +++ b/flocks/server/routes/agent.py @@ -231,6 +231,14 @@ def _get_all_tool_names() -> List[str]: return [t.name for t in ToolRegistry.list_tools()] +async def _get_all_tool_names_async() -> List[str]: + """Return all registered tool names without blocking the event loop.""" + from flocks.tool.registry import ToolRegistry + + await ToolRegistry.init_async() + return [t.name for t in ToolRegistry.list_tools()] + + def _compute_native_agent_tools(agent: AgentInfoModel, all_tool_names: List[str]) -> List[str]: """Return the concrete tools explicitly declared for the agent.""" tools = list(agent.tools or []) @@ -290,7 +298,7 @@ async def list_agents(): agents = await Agent.list() overrides = await _load_model_overrides() delegatable_overrides = _load_delegatable_overrides() - all_tool_names = _get_all_tool_names() + all_tool_names = await _get_all_tool_names_async() result = [] for agent in agents: if agent.hidden: @@ -311,7 +319,7 @@ async def get_agent(name: str): raise HTTPException(status_code=404, detail=f"Agent {name} not found") overrides = await _load_model_overrides() delegatable_overrides = _load_delegatable_overrides() - all_tool_names = _get_all_tool_names() + all_tool_names = await _get_all_tool_names_async() return await _build_single_agent_response(agent, overrides, delegatable_overrides, all_tool_names) except HTTPException: raise @@ -532,7 +540,7 @@ async def update_agent(name: str, req: AgentUpdateRequest): agent.delegatable = req.delegatable overrides = await _load_model_overrides() delegatable_overrides = _load_delegatable_overrides() - all_tool_names = _get_all_tool_names() + all_tool_names = await _get_all_tool_names_async() return await _build_single_agent_response(agent, overrides, delegatable_overrides, all_tool_names) yaml_data = read_yaml_agent(name) or {} return _custom_agent_data_to_response(yaml_data) @@ -583,7 +591,7 @@ async def update_agent_delegatable(name: str, req: AgentDelegatableUpdateRequest overrides = await _load_model_overrides() delegatable_overrides = _load_delegatable_overrides() - all_tool_names = _get_all_tool_names() + all_tool_names = await _get_all_tool_names_async() log.info("agent.delegatable.updated", {"name": name, "source": "override", "delegatable": req.delegatable}) return await _build_single_agent_response(agent, overrides, delegatable_overrides, all_tool_names) @@ -728,7 +736,7 @@ async def update_agent_model(name: str, req: AgentModelUpdateRequest): log.info("agent.model.updated", {"name": name, "source": "yaml"}) overrides = await _load_model_overrides() delegatable_overrides = _load_delegatable_overrides() - all_tool_names = _get_all_tool_names() + all_tool_names = await _get_all_tool_names_async() return await _build_single_agent_response(agent, overrides, delegatable_overrides, all_tool_names) raise HTTPException(status_code=404, detail=f"Custom agent {name} not found") @@ -818,7 +826,7 @@ async def test_agent(name: str, req: AgentTestRequest = AgentTestRequest()): # --- 4. init provider / tools --- Provider._ensure_initialized() - ToolRegistry.init() + await ToolRegistry.init_async() # --- 5. run agent loop in background (publishes to global SSE bus) --- from flocks.session.session_loop import SessionLoop, LoopCallbacks diff --git a/flocks/server/routes/auth.py b/flocks/server/routes/auth.py index 83ec3df9d..5cfcb30e2 100644 --- a/flocks/server/routes/auth.py +++ b/flocks/server/routes/auth.py @@ -4,6 +4,8 @@ from __future__ import annotations +import threading +import time from typing import Any from fastapi import APIRouter, HTTPException, Request, Response, status @@ -21,6 +23,166 @@ router = APIRouter() +_LOGIN_FAILURE_WINDOW_SECONDS = 5 * 60 +_LOGIN_LOCKOUT_SECONDS = 15 * 60 +_LOGIN_MAX_FAILURES_PER_USER_AND_IP = 5 +_LOGIN_MAX_FAILURES_PER_IP = 20 +_LOGIN_PRUNE_INTERVAL_SECONDS = 60 +_LOGIN_MAX_TRACKED_BUCKETS = 2048 + + +class _LoginRateLimiter: + """In-process failed-login limiter for local account authentication.""" + + def __init__(self) -> None: + self._lock = threading.Lock() + self._failures: dict[tuple[str, str], list[float]] = {} + self._locked_until: dict[tuple[str, str], float] = {} + self._last_pruned_at = 0.0 + + def check(self, *, username: str, ip: str | None) -> int | None: + """Return retry-after seconds when the login attempt is currently blocked.""" + now = time.monotonic() + with self._lock: + retry_after = self._retry_after(("user_ip", self._user_ip_key(username, ip)), now) + if retry_after is not None: + return retry_after + return self._retry_after(("ip", self._ip_key(ip)), now) + + def record_failure(self, *, username: str, ip: str | None) -> int | None: + """Record a failed login attempt and return retry-after when it locks out.""" + now = time.monotonic() + with self._lock: + self._prune(now) + user_key = ("user_ip", self._user_ip_key(username, ip)) + ip_key = ("ip", self._ip_key(ip)) + user_retry = self._record_failure( + user_key, + limit=_LOGIN_MAX_FAILURES_PER_USER_AND_IP, + now=now, + ) + ip_retry = self._record_failure( + ip_key, + limit=_LOGIN_MAX_FAILURES_PER_IP, + now=now, + ) + self._enforce_capacity(now, preserve={user_key, ip_key}) + if user_retry is not None and ip_retry is not None: + return max(user_retry, ip_retry) + return user_retry if user_retry is not None else ip_retry + + def record_success(self, *, username: str, ip: str | None) -> None: + """Clear the exact user/IP failure bucket after a successful login.""" + with self._lock: + key = ("user_ip", self._user_ip_key(username, ip)) + self._failures.pop(key, None) + self._locked_until.pop(key, None) + + def reset(self) -> None: + """Clear limiter state for tests and process lifecycle resets.""" + with self._lock: + self._failures.clear() + self._locked_until.clear() + self._last_pruned_at = 0.0 + + def _retry_after(self, key: tuple[str, str], now: float) -> int | None: + locked_until = self._locked_until.get(key) + if locked_until is None: + return None + if locked_until <= now: + self._locked_until.pop(key, None) + self._failures.pop(key, None) + return None + return max(1, int(locked_until - now)) + + def _record_failure(self, key: tuple[str, str], *, limit: int, now: float) -> int | None: + if retry_after := self._retry_after(key, now): + return retry_after + cutoff = now - _LOGIN_FAILURE_WINDOW_SECONDS + failures = [timestamp for timestamp in self._failures.get(key, []) if timestamp >= cutoff] + failures.append(now) + self._failures[key] = failures + if len(failures) <= limit: + return None + locked_until = now + _LOGIN_LOCKOUT_SECONDS + self._locked_until[key] = locked_until + return _LOGIN_LOCKOUT_SECONDS + + def _prune(self, now: float, *, force: bool = False) -> None: + if not force and ( + now - self._last_pruned_at < _LOGIN_PRUNE_INTERVAL_SECONDS + and self._tracked_bucket_count() <= _LOGIN_MAX_TRACKED_BUCKETS + ): + return + cutoff = now - _LOGIN_FAILURE_WINDOW_SECONDS + for key, locked_until in list(self._locked_until.items()): + if locked_until <= now: + self._locked_until.pop(key, None) + for key, failures in list(self._failures.items()): + if self._locked_until.get(key, 0) > now: + continue + active_failures = [timestamp for timestamp in failures if timestamp >= cutoff] + if active_failures: + self._failures[key] = active_failures + else: + self._failures.pop(key, None) + self._last_pruned_at = now + + def _enforce_capacity(self, now: float, *, preserve: set[tuple[str, str]]) -> None: + if self._tracked_bucket_count() <= _LOGIN_MAX_TRACKED_BUCKETS: + return + self._prune(now, force=True) + overflow = self._tracked_bucket_count() - _LOGIN_MAX_TRACKED_BUCKETS + if overflow <= 0: + return + candidates = [ + (max(failures, default=0.0), key) + for key, failures in self._failures.items() + if key not in preserve and self._locked_until.get(key, 0) <= now + ] + candidates.sort() + for _latest_failure, key in candidates[:overflow]: + self._failures.pop(key, None) + self._locked_until.pop(key, None) + overflow = self._tracked_bucket_count() - _LOGIN_MAX_TRACKED_BUCKETS + if overflow <= 0: + return + locked_candidates = [ + (locked_until, key) + for key, locked_until in self._locked_until.items() + if key not in preserve + ] + locked_candidates.sort() + for _locked_until, key in locked_candidates[:overflow]: + self._locked_until.pop(key, None) + self._failures.pop(key, None) + + def _tracked_bucket_count(self) -> int: + return len(set(self._failures) | set(self._locked_until)) + + @staticmethod + def _user_ip_key(username: str, ip: str | None) -> str: + return f"{(username or '').strip().casefold()}@{ip or 'unknown'}" + + @staticmethod + def _ip_key(ip: str | None) -> str: + return ip or "unknown" + + +_login_rate_limiter = _LoginRateLimiter() + + +def _request_ip(request: Request) -> str | None: + return getattr(getattr(request, "client", None), "host", None) + + +def _raise_login_rate_limited(retry_after: int) -> None: + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail="登录失败次数过多,请稍后再试", + headers={"Retry-After": str(retry_after)}, + ) + def _parse_event_type(event_type: str) -> tuple[str, str]: if "." in event_type: @@ -181,22 +343,39 @@ async def bootstrap_admin(payload: BootstrapAdminRequest, response: Response, re @router.post("/login", response_model=MeResponse, summary="登录本地账号") async def login(payload: LoginRequest, response: Response, request: Request) -> MeResponse: + ip = _request_ip(request) + retry_after = _login_rate_limiter.check(username=payload.username, ip=ip) + if retry_after is not None: + await _emit_auth_audit( + "account.login_rate_limited", + { + "username": payload.username, + "ip": ip, + "retry_after": retry_after, + }, + ) + _raise_login_rate_limited(retry_after) + try: user, session_id = await AuthService.login( payload.username, payload.password, ) except ValueError as exc: + retry_after = _login_rate_limiter.record_failure(username=payload.username, ip=ip) await _emit_auth_audit( "account.login_failed", { "username": payload.username, "reason": str(exc), - "ip": getattr(getattr(request, "client", None), "host", None), + "ip": ip, }, ) + if retry_after is not None: + _raise_login_rate_limited(retry_after) raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + _login_rate_limiter.record_success(username=payload.username, ip=ip) set_session_cookie(response, session_id, secure=should_use_secure_cookie(request)) await _emit_auth_audit( "account.login", @@ -208,7 +387,7 @@ async def login(payload: LoginRequest, response: Response, request: Request) -> "username": user.username, "role": user.role, "session_id": session_id, - "ip": getattr(getattr(request, "client", None), "host", None), + "ip": ip, }, ) return _to_me_response(user) diff --git a/flocks/server/routes/channel.py b/flocks/server/routes/channel.py index aed3dc343..518e3fcbd 100644 --- a/flocks/server/routes/channel.py +++ b/flocks/server/routes/channel.py @@ -71,11 +71,26 @@ async def channel_session_send(req: SessionSendRequest): svc = SessionBindingService() all_bindings = await svc.list_bindings() matched = [b for b in all_bindings if b.session_id == req.session_id] + resolved_session_id = req.session_id + + if not matched and req.channel_type: + latest = await svc.latest_active_user_binding( + channel_id=req.channel_type, + account_id=req.account_id, + chat_id=req.chat_id, + ) + if latest: + matched = [latest] + resolved_session_id = latest.session_id if not matched: raise HTTPException( status_code=404, - detail=f"未找到 session '{req.session_id}' 的渠道绑定", + detail=( + f"未找到 session '{req.session_id}' 的渠道绑定;" + "请使用 im_send_message(resolve_only=true) 重新解析当前 IM 目标," + "或让用户确认目标 IM 会话。" + ), ) if req.channel_type: @@ -109,7 +124,10 @@ async def channel_session_send(req: SessionSendRequest): text=req.text, media_url=req.media_url, ) - results = await OutboundDelivery.deliver(out_ctx, session_id=req.session_id) + results = await OutboundDelivery.deliver( + out_ctx, + session_id=resolved_session_id, + ) all_results.extend(results) for r in results: if not r.success: @@ -120,6 +138,7 @@ async def channel_session_send(req: SessionSendRequest): return { "ok": True, + "session_id": resolved_session_id, "message_ids": [r.message_id for r in all_results if r.message_id], "channels": list({b.channel_id for b in matched}), } diff --git a/flocks/server/routes/config.py b/flocks/server/routes/config.py index 4c59c9ce2..7f403f92c 100644 --- a/flocks/server/routes/config.py +++ b/flocks/server/routes/config.py @@ -16,11 +16,18 @@ } """ +import re +import xml.etree.ElementTree as ET +from pathlib import Path from typing import Dict, Any, Optional -from fastapi import APIRouter, HTTPException -from pydantic import BaseModel - -from flocks.config.config import Config, GlobalConfig, ConfigInfo as ConfigInfoModel +from fastapi import APIRouter, File, HTTPException, UploadFile, status +from fastapi.responses import FileResponse +from pydantic import BaseModel, Field +from defusedxml import ElementTree as DefusedET +from defusedxml.common import DefusedXmlException + +from flocks.config.config import Config, GlobalConfig, ConfigInfo as ConfigInfoModel, UIConfig +from flocks.config.config_writer import ConfigWriter from flocks.provider.provider import Provider from flocks.utils.log import Log @@ -118,6 +125,402 @@ class ProviderDefaultsResponse(BaseModel): default: Dict[str, str] +class UIDisplayResponse(BaseModel): + """Public WebUI display-name response.""" + + display_name: str = Field(alias="displayName") + configured_display_name: Optional[str] = Field(None, alias="configuredDisplayName") + favicon_url: Optional[str] = Field(None, alias="faviconUrl") + + +class UIConfigUpdateRequest(BaseModel): + """Update request for visible WebUI display preferences.""" + + model_config = {"populate_by_name": True} + + display_name: Optional[str] = Field(None, alias="displayName") + + +DEFAULT_UI_DISPLAY_NAME = "Flocks" +DEFAULT_UI_PRO_DISPLAY_NAME = "Flocks Pro" +FAVICON_MAX_BYTES = 512 * 1024 +FAVICON_RELATIVE_DIR = "assets" +FAVICON_BASENAME = "favicon" +FAVICON_MEDIA_TYPES = { + ".ico": "image/x-icon", + ".png": "image/png", + ".svg": "image/svg+xml", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".webp": "image/webp", +} +SVG_NAMESPACE = "http://www.w3.org/2000/svg" +XLINK_NAMESPACE = "http://www.w3.org/1999/xlink" +SVG_ALLOWED_TAGS = { + "circle", + "clippath", + "defs", + "desc", + "ellipse", + "g", + "line", + "lineargradient", + "mask", + "path", + "pattern", + "polygon", + "polyline", + "radialgradient", + "rect", + "stop", + "svg", + "symbol", + "text", + "textpath", + "title", + "tspan", + "use", +} +SVG_ALLOWED_ATTRIBUTES = { + "aria-label", + "baseprofile", + "class", + "clip-path", + "clip-rule", + "color", + "cx", + "cy", + "d", + "direction", + "display", + "dominant-baseline", + "dx", + "dy", + "fill", + "fill-opacity", + "fill-rule", + "font-family", + "font-size", + "font-style", + "font-weight", + "fr", + "fx", + "fy", + "gradienttransform", + "gradientunits", + "height", + "href", + "id", + "letter-spacing", + "mask", + "offset", + "opacity", + "points", + "preserveaspectratio", + "r", + "role", + "rotate", + "rx", + "ry", + "spreadmethod", + "stop-color", + "stop-opacity", + "stroke", + "stroke-dasharray", + "stroke-dashoffset", + "stroke-linecap", + "stroke-linejoin", + "stroke-miterlimit", + "stroke-opacity", + "stroke-width", + "text-anchor", + "transform", + "version", + "viewbox", + "visibility", + "width", + "x", + "x1", + "x2", + "y", + "y1", + "y2", +} +SVG_UNSAFE_VALUE_PATTERN = re.compile( + r"(?:javascript|vbscript|data|file|https?):|//|@import|expression\s*\(", + re.IGNORECASE, +) +SVG_URL_PATTERN = re.compile(r"url\(\s*(['\"]?)(.*?)\1\s*\)", re.IGNORECASE) + + +def _is_flockspro_enabled() -> bool: + try: + from flocks.server.routes.console_upgrade import _get_pro_capability_status + + status_data = _get_pro_capability_status() + except Exception: + return False + return any(status_data.get(key) is True for key in ("pro_enabled", "active", "activated")) + + +def _default_display_name() -> str: + return DEFAULT_UI_PRO_DISPLAY_NAME if _is_flockspro_enabled() else DEFAULT_UI_DISPLAY_NAME + + +def _effective_display_name(config: ConfigInfoModel) -> tuple[str, Optional[str]]: + configured = config.ui.display_name if config.ui else None + return configured or _default_display_name(), configured + + +def _ui_assets_dir() -> Path: + return Config.get_config_path() / FAVICON_RELATIVE_DIR + + +def _favicon_relative_path(ext: str) -> str: + return f"{FAVICON_RELATIVE_DIR}/{FAVICON_BASENAME}{ext}" + + +def _safe_config_relative_path(relative_path: str) -> Optional[Path]: + try: + config_dir = Config.get_config_path().resolve() + target = (config_dir / relative_path).resolve() + if target == config_dir or config_dir not in target.parents: + return None + return target + except Exception: + return None + + +def _configured_favicon_path(config: ConfigInfoModel) -> Optional[Path]: + relative_path = config.ui.favicon_path if config.ui else None + if not relative_path: + return None + target = _safe_config_relative_path(relative_path) + if not target or not target.is_file(): + return None + return target + + +def _favicon_media_type(path: Path) -> str: + return FAVICON_MEDIA_TYPES.get(path.suffix.lower(), "application/octet-stream") + + +def _favicon_url(config: ConfigInfoModel) -> Optional[str]: + path = _configured_favicon_path(config) + if not path: + return None + try: + version = int(path.stat().st_mtime) + except OSError: + version = 0 + return f"/api/config/ui-favicon?v={version}" + + +def _xml_local_name(name: str) -> str: + if name.startswith("{") and "}" in name: + return name.rsplit("}", 1)[1] + if ":" in name: + return name.rsplit(":", 1)[1] + return name + + +def _xml_namespace(name: str) -> Optional[str]: + if name.startswith("{") and "}" in name: + return name[1:].split("}", 1)[0] + return None + + +def _validate_svg_value(attribute: str, value: str) -> None: + stripped = value.strip() + if SVG_UNSAFE_VALUE_PATTERN.search(stripped): + raise HTTPException(status_code=400, detail=f"Unsafe SVG attribute value: {attribute}") + + if attribute == "href" and stripped and not stripped.startswith("#"): + raise HTTPException(status_code=400, detail="SVG href values must reference local fragments only") + + for match in SVG_URL_PATTERN.finditer(stripped): + target = match.group(2).strip() + if not target.startswith("#"): + raise HTTPException(status_code=400, detail="SVG url() values must reference local fragments only") + + +def _validate_svg_favicon(content: bytes) -> bytes: + try: + text = content.decode("utf-8-sig") + except UnicodeDecodeError as e: + raise HTTPException(status_code=400, detail="SVG favicon must be UTF-8 encoded") from e + + lowered = text.lower() + if "") + + for element in root.iter(): + tag_name = _xml_local_name(element.tag).lower() + if _xml_namespace(element.tag) not in (None, SVG_NAMESPACE): + raise HTTPException(status_code=400, detail=f"Unsupported SVG namespace for <{tag_name}>") + if tag_name not in SVG_ALLOWED_TAGS: + raise HTTPException(status_code=400, detail=f"Unsupported SVG element: <{tag_name}>") + + for raw_attribute, value in element.attrib.items(): + attribute_namespace = _xml_namespace(raw_attribute) + attribute_name = _xml_local_name(raw_attribute).lower() + if attribute_namespace not in (None, XLINK_NAMESPACE): + raise HTTPException(status_code=400, detail=f"Unsupported SVG attribute: {attribute_name}") + if attribute_namespace == XLINK_NAMESPACE and attribute_name != "href": + raise HTTPException(status_code=400, detail=f"Unsupported SVG attribute: {attribute_name}") + if attribute_name.startswith("on") or attribute_name in {"style", "src"}: + raise HTTPException(status_code=400, detail=f"Unsupported SVG attribute: {attribute_name}") + if attribute_name not in SVG_ALLOWED_ATTRIBUTES: + raise HTTPException(status_code=400, detail=f"Unsupported SVG attribute: {attribute_name}") + _validate_svg_value(attribute_name, value) + + return content + + +def _get_or_create_ui_section(data: Dict[str, Any]) -> Dict[str, Any]: + ui_section = data.get("ui") + if not isinstance(ui_section, dict): + ui_section = {} + return ui_section + + +def _persist_ui_section(data: Dict[str, Any], ui_section: Dict[str, Any]) -> None: + if ui_section: + data["ui"] = ui_section + else: + data.pop("ui", None) + ConfigWriter._write_raw(data) + + +@router.get("/ui-display", response_model=UIDisplayResponse, summary="Get public UI display name") +async def get_ui_display() -> UIDisplayResponse: + """Return only the effective WebUI display name for public screens.""" + try: + complete_config = await Config.get() + display_name, configured_display_name = _effective_display_name(complete_config) + return UIDisplayResponse( + displayName=display_name, + configuredDisplayName=configured_display_name, + faviconUrl=_favicon_url(complete_config), + ) + except Exception as e: + log.error("config.ui_display.get.error", {"error": str(e)}) + raise HTTPException(status_code=500, detail=str(e)) + + +@router.patch("/ui", response_model=UIDisplayResponse, summary="Update UI display preferences") +async def update_ui_config(request: UIConfigUpdateRequest) -> UIDisplayResponse: + """Update visible WebUI display preferences.""" + try: + ui_config = UIConfig.model_validate({"displayName": request.display_name}) + data = ConfigWriter._read_raw() + ui_section = _get_or_create_ui_section(data) + + if ui_config.display_name: + ui_section["displayName"] = ui_config.display_name + else: + ui_section.pop("displayName", None) + + _persist_ui_section(data, ui_section) + return await get_ui_display() + except Exception as e: + log.error("config.ui.update.error", {"error": str(e)}) + raise HTTPException(status_code=400, detail=str(e)) + + +@router.get("/ui-favicon", summary="Get custom UI favicon") +async def get_ui_favicon() -> FileResponse: + """Return the uploaded favicon, if one is configured.""" + try: + complete_config = await Config.get() + path = _configured_favicon_path(complete_config) + if not path: + raise HTTPException(status_code=404, detail="custom favicon is not configured") + return FileResponse(path, media_type=_favicon_media_type(path)) + except HTTPException: + raise + except Exception as e: + log.error("config.ui_favicon.get.error", {"error": str(e)}) + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/ui/favicon", response_model=UIDisplayResponse, summary="Upload UI favicon") +async def upload_ui_favicon(file: UploadFile = File(...)) -> UIDisplayResponse: + """Upload a custom favicon for visible WebUI branding.""" + filename = Path(file.filename or "").name + ext = Path(filename).suffix.lower() + if ext not in FAVICON_MEDIA_TYPES: + raise HTTPException(status_code=400, detail="Unsupported favicon type. Use .ico, .png, .svg, .jpg, or .webp") + + chunks: list[bytes] = [] + total = 0 + while True: + chunk = await file.read(65536) + if not chunk: + break + total += len(chunk) + if total > FAVICON_MAX_BYTES: + raise HTTPException( + status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, + detail="Favicon file is too large. Maximum size is 512 KB.", + ) + chunks.append(chunk) + + if total == 0: + raise HTTPException(status_code=400, detail="Favicon file is empty") + + content = b"".join(chunks) + if ext == ".svg": + content = _validate_svg_favicon(content) + + assets_dir = _ui_assets_dir() + assets_dir.mkdir(parents=True, exist_ok=True) + for old in assets_dir.glob(f"{FAVICON_BASENAME}.*"): + if old.is_file(): + try: + old.unlink() + except OSError: + pass + + target = assets_dir / f"{FAVICON_BASENAME}{ext}" + target.write_bytes(content) + + data = ConfigWriter._read_raw() + ui_section = _get_or_create_ui_section(data) + ui_section["faviconPath"] = _favicon_relative_path(ext) + _persist_ui_section(data, ui_section) + + log.info("config.ui_favicon.uploaded", {"path": ui_section["faviconPath"], "size": total}) + return await get_ui_display() + + +@router.delete("/ui/favicon", response_model=UIDisplayResponse, summary="Reset UI favicon") +async def reset_ui_favicon() -> UIDisplayResponse: + """Remove the uploaded favicon and fall back to the default bundled favicon.""" + assets_dir = _ui_assets_dir() + if assets_dir.exists(): + for old in assets_dir.glob(f"{FAVICON_BASENAME}.*"): + if old.is_file(): + try: + old.unlink() + except OSError: + pass + + data = ConfigWriter._read_raw() + ui_section = _get_or_create_ui_section(data) + ui_section.pop("faviconPath", None) + _persist_ui_section(data, ui_section) + return await get_ui_display() + + @router.get("", summary="Get configuration") async def get_config() -> Dict[str, Any]: """ diff --git a/flocks/server/routes/console_upgrade.py b/flocks/server/routes/console_upgrade.py index 9539bf71a..107140f35 100644 --- a/flocks/server/routes/console_upgrade.py +++ b/flocks/server/routes/console_upgrade.py @@ -22,7 +22,7 @@ from flocks.console.login import ConsoleLoginService from flocks.server.auth import require_admin, require_user from flocks.storage.storage import Storage -from flocks.updater import perform_pro_bundle_install +from flocks.updater import perform_pro_bundle_downgrade, perform_pro_bundle_install router = APIRouter() _AUTO_UPGRADE_TASKS: set[asyncio.Task[None]] = set() @@ -93,6 +93,10 @@ class UpgradeRequestStatus(BaseModel): updated_at: str +class ProPackageDowngradeRequest(BaseModel): + reason: Optional[str] = Field(default="user_requested", max_length=500) + + def _request_key(request_id: str) -> str: return f"console:upgrade_request:{request_id}" @@ -317,8 +321,8 @@ def _enrich_record_from_install_marker(record: dict[str, Any]) -> dict[str, Any] marker = _read_pro_bundle_install_marker() if marker: details.setdefault("auto_install_release_id", marker.get("release_id") or marker.get("bundle_release_id")) - details.setdefault("auto_install_version", marker.get("installed_version")) - details.setdefault("auto_install_pro_version", marker.get("flockspro_component_version")) + details.setdefault("auto_install_bundle_version", marker.get("bundle_version")) + details.setdefault("auto_install_pro_component_version", marker.get("flockspro_component_version")) details.setdefault("flockspro_component_version", marker.get("flockspro_component_version")) details.setdefault("auto_install_build_id", marker.get("build_id")) @@ -442,19 +446,16 @@ def _record_target_bundle(record: dict[str, Any]) -> dict[str, str]: "release_id": release_id, "bundle_release_id": _clean_bundle_value(details.get("bundle_release_id") or release_id), "build_id": _clean_bundle_value(details.get("target_build_id") or latest_bundle.get("build_id")), - "display_version": _clean_bundle_value( - details.get("target_display_version") - or details.get("auto_install_target") - or latest_bundle.get("display_version") + "bundle_version_update_to": _clean_bundle_value( + details.get("bundle_version_update_to") + or latest_bundle.get("bundle_version") ), - "core_version": _clean_bundle_value( - details.get("target_core_version") - or details.get("target_oss_version") + "core_version_update_to": _clean_bundle_value( + details.get("core_version_update_to") or latest_bundle.get("core_version") - or latest_bundle.get("oss_version") ), - "flockspro_component_version": _clean_bundle_value( - details.get("target_flockspro_component_version") + "flockspro_component_version_update_to": _clean_bundle_value( + details.get("flockspro_component_version_update_to") or latest_bundle.get("flockspro_component_version") ), } @@ -467,18 +468,18 @@ def _target_bundle_fingerprint_matches(target: dict[str, str], marker: dict[str, if build_id and marker_build_id: return marker_build_id == build_id - pro_version = target.get("flockspro_component_version") + pro_version = target.get("flockspro_component_version_update_to") marker_pro_version = _clean_bundle_value(marker.get("flockspro_component_version")) if pro_version and marker_pro_version: return marker_pro_version == pro_version - display_version = target.get("display_version") - marker_display_version = _clean_bundle_value(marker.get("installed_version") or marker.get("display_version")) - if display_version and marker_display_version: - return _clean_version_value(marker_display_version) == _clean_version_value(display_version) + bundle_version = target.get("bundle_version_update_to") + marker_bundle_version = _clean_bundle_value(marker.get("bundle_version")) + if bundle_version and marker_bundle_version: + return _clean_version_value(marker_bundle_version) == _clean_version_value(bundle_version) - core_version = target.get("core_version") or target.get("oss_version") - marker_core_version = _clean_bundle_value(marker.get("core_version") or marker.get("oss_version")) + core_version = target.get("core_version_update_to") + marker_core_version = _clean_bundle_value(marker.get("core_version")) if core_version and marker_core_version: return _clean_version_value(marker_core_version) == _clean_version_value(core_version) @@ -510,7 +511,7 @@ async def _run_auto_upgrade_install(record: dict[str, Any]) -> dict[str, Any]: marker = _read_pro_bundle_install_marker() if _is_pro_component_installed() and _marker_matches_target_bundle(marker, record): details["auto_install_release_id"] = marker.get("release_id") or marker.get("bundle_release_id") - details["auto_install_version"] = marker.get("installed_version") + details["auto_install_bundle_version"] = marker.get("bundle_version") await _maybe_activate_pro_license(record, allow_fallback=False) await _maybe_refresh_pro_license(record) capability = _record_pro_capability(details) @@ -538,8 +539,8 @@ async def _run_auto_upgrade_install(record: dict[str, Any]) -> dict[str, Any]: "done" if final_stage == "done" and capability.get("pro_enabled") else "license_inactive" ) details["auto_install_release_id"] = marker.get("release_id") or marker.get("bundle_release_id") - details["auto_install_version"] = marker.get("installed_version") - details["auto_install_pro_version"] = marker.get("flockspro_component_version") + details["auto_install_bundle_version"] = marker.get("bundle_version") + details["auto_install_pro_component_version"] = marker.get("flockspro_component_version") details["auto_install_completed_at"] = datetime.now(UTC).isoformat() details["auto_install_message"] = final_message _enrich_record_from_install_marker(record) @@ -560,12 +561,16 @@ def _read_pro_bundle_install_marker() -> dict[str, Any]: return payload if isinstance(payload, dict) else {} +def _pending_pro_bundle_downgrade_receipt_path() -> Path: + return Path(os.getenv("FLOCKS_ROOT", str(Path.home() / ".flocks"))) / "run" / "pro-bundle-downgrade-receipt-pending.json" + + def _marker_indicates_pro_bundle_installed(marker: dict[str, Any]) -> bool: if not marker: return False return any( str(marker.get(key) or "").strip() - for key in ("installed_at", "installed_version", "bundle_version", "flockspro_component_version", "build_id") + for key in ("installed_at", "bundle_version", "flockspro_component_version", "build_id") ) @@ -600,15 +605,12 @@ async def _report_pro_bundle_installation( "license_id": _record_license_id(record), "fingerprint": console_session.get("fingerprint"), "install_id": console_session.get("install_id"), - "installed_version": source.get("installed_version") - or source.get("display_version") - or target.get("display_version") - or details.get("auto_install_target") - or details.get("auto_install_version") - or "", - "core_version": source.get("core_version") or source.get("oss_version") or target.get("core_version"), - "oss_version": source.get("core_version") or source.get("oss_version") or target.get("core_version"), - "flockspro_component_version": source.get("flockspro_component_version") or target.get("flockspro_component_version"), + "bundle_version": source.get("bundle_version") or target.get("bundle_version_update_to") or "", + "core_version": source.get("core_version") or target.get("core_version_update_to"), + "flockspro_component_version": ( + source.get("flockspro_component_version") + or target.get("flockspro_component_version_update_to") + ), "build_id": source.get("build_id") or target.get("build_id"), "install_result": install_result, "error_message": error_message, @@ -632,6 +634,149 @@ async def _report_pro_bundle_installation( details["install_receipt_error"] = str(exc) +async def _report_pro_bundle_downgrade( + record: dict[str, Any] | None, + *, + reason: str | None = None, + console_session: dict[str, Any] | None = None, +) -> dict[str, Any]: + target_record = record or {} + details = target_record.setdefault("details", {}) + if console_session is None: + try: + console_session = await ConsoleLoginService.require_console_session() + except Exception as exc: + details["local_downgrade_report_error"] = str(exc) + raise + + console_base = _console_base_url() + if not console_base: + message = "FLOCKS_CONSOLE_BASE_URL 未配置,无法同步降级状态" + details["local_downgrade_report_error"] = message + raise ValueError(message) + + payload = _build_pro_bundle_downgrade_payload(target_record, reason=reason, console_session=console_session) + async with httpx.AsyncClient(timeout=10) as client: + resp = await client.post( + f"{console_base}/v1/pro-bundles/installations", + json=payload, + headers={"Authorization": f"Bearer {console_session['console_session_token']}"}, + ) + resp.raise_for_status() + data = resp.json() + + reported_at = str(payload.get("reported_at") or datetime.now(UTC).isoformat()) + details["local_downgrade_reported_at"] = reported_at + details["local_downgrade_reason"] = reason or "user_requested" + details["local_downgrade_previous_version"] = payload.get("bundle_version") + details["local_downgrade_previous_core_version"] = payload.get("core_version") + details["local_downgrade_previous_pro_version"] = payload.get("flockspro_component_version") + details["local_downgrade_installation_id"] = data.get("id") + details["local_downgrade_report_result"] = "reported" + details.pop("local_downgrade_report_error", None) + _pending_pro_bundle_downgrade_receipt_path().unlink(missing_ok=True) + target_record["updated_at"] = reported_at + return data if isinstance(data, dict) else {} + + +def _build_pro_bundle_downgrade_payload( + record: dict[str, Any] | None, + *, + reason: str | None = None, + console_session: dict[str, Any] | None = None, +) -> dict[str, Any]: + target_record = record or {} + details = target_record.get("details") if isinstance(target_record.get("details"), dict) else {} + marker = _read_pro_bundle_install_marker() + target = _record_target_bundle(target_record) if target_record else {} + capability = _get_pro_capability_status() + release_id = _clean_bundle_value( + marker.get("release_id") + or marker.get("bundle_release_id") + or target.get("release_id") + or target.get("bundle_release_id") + ) + bundle_release_id = _clean_bundle_value( + marker.get("bundle_release_id") + or marker.get("release_id") + or target.get("bundle_release_id") + or target.get("release_id") + or release_id + ) + license_id = _record_license_id(target_record) or _clean_bundle_value(capability.get("license_id")) + installed_version = _clean_bundle_value( + marker.get("installed_version") + or marker.get("display_version") + or marker.get("bundle_version") + or details.get("auto_install_bundle_version") + or target.get("bundle_version_update_to") + or details.get("auto_install_version") + ) + core_version = _clean_bundle_value( + marker.get("core_version") + or marker.get("oss_version") + or target.get("core_version_update_to") + ) + pro_version = _clean_bundle_value( + marker.get("flockspro_component_version") + or target.get("flockspro_component_version_update_to") + or details.get("auto_install_pro_version") + or details.get("flockspro_component_version") + ) + reported_at = datetime.now(UTC).isoformat() + payload = { + "request_id": _clean_bundle_value(target_record.get("request_id")) or None, + "release_id": release_id or None, + "bundle_release_id": bundle_release_id or None, + "license_id": license_id or None, + "fingerprint": (console_session or {}).get("fingerprint"), + "install_id": (console_session or {}).get("install_id"), + "bundle_version": installed_version, + "core_version": core_version, + "flockspro_component_version": pro_version, + "build_id": marker.get("build_id") or target.get("build_id"), + "install_result": "downgraded", + "runtime_edition": "oss", + "reason": reason or "user_requested", + "reported_at": reported_at, + } + return payload + + +def _write_pending_pro_bundle_downgrade_receipt( + record: dict[str, Any] | None, + *, + reason: str | None = None, + console_session: dict[str, Any] | None = None, + error_message: str | None = None, +) -> None: + path = _pending_pro_bundle_downgrade_receipt_path() + path.parent.mkdir(parents=True, exist_ok=True) + payload = _build_pro_bundle_downgrade_payload(record, reason=reason, console_session=console_session) + payload["pending_report_created_at"] = datetime.now(UTC).isoformat() + if error_message: + payload["last_report_error"] = error_message + console_base = _console_base_url() + if console_base: + payload["console_base_url"] = console_base + path.write_text(json.dumps(payload, ensure_ascii=True, sort_keys=True), encoding="utf-8") + try: + os.chmod(path, 0o600) + except OSError: + pass + + +def _downgrade_report_error_message(exc: Exception) -> str: + if isinstance(exc, httpx.HTTPStatusError): + try: + data = exc.response.json() + if isinstance(data, dict): + return str(data.get("detail") or data.get("message") or "console 降级状态同步失败,请稍后重试") + except Exception: + pass + return str(exc) or "console 降级状态同步失败,请稍后重试" + + async def _mark_console_upgrade_activated(record: dict[str, Any]) -> None: request_id = str(record.get("request_id") or "").strip() if not request_id: @@ -688,8 +833,8 @@ async def _finalize_restarting_upgrade_if_installed(record: dict[str, Any]) -> d await _maybe_refresh_pro_license(record) capability = _record_pro_capability(details) details["auto_install_result"] = "done" if capability.get("pro_enabled") else "license_inactive" - details["auto_install_version"] = marker.get("installed_version") or marker.get("display_version") - details["auto_install_pro_version"] = marker.get("flockspro_component_version") + details["auto_install_bundle_version"] = marker.get("bundle_version") + details["auto_install_pro_component_version"] = marker.get("flockspro_component_version") details["auto_install_completed_at"] = datetime.now(UTC).isoformat() details["auto_install_message"] = "Upgrade completed after service restart" _enrich_record_from_install_marker(record) @@ -877,7 +1022,8 @@ async def get_pro_package_status(request: Request) -> dict[str, Any]: "installed": installed, "runtime_importable": runtime_importable, "install_marker_present": install_marker_present, - "installed_version": marker.get("installed_version"), + "bundle_version": marker.get("bundle_version"), + "core_version": marker.get("core_version"), "flockspro_component_version": marker.get("flockspro_component_version"), "build_id": marker.get("build_id"), "installed_at": marker.get("installed_at"), @@ -887,6 +1033,107 @@ async def get_pro_package_status(request: Request) -> dict[str, Any]: } +@router.post("/pro-package/downgrade") +async def downgrade_pro_package(payload: ProPackageDowngradeRequest, request: Request) -> StreamingResponse: + require_admin(request) + + async def _stream(): + marker = _read_pro_bundle_install_marker() + installed = _is_pro_component_installed() or _marker_indicates_pro_bundle_installed(marker) + if not installed: + yield f"data: {json.dumps({'stage': 'done', 'message': 'Already running the OSS edition.', 'success': True})}\n\n" + return + + reason = (payload.reason or "user_requested").strip() or "user_requested" + record: dict[str, Any] | None = None + console_session: dict[str, Any] | None = None + + async def _store_local_downgrade_failure(message: str) -> None: + if not record or not record.get("request_id"): + return + details = record.setdefault("details", {}) + details["local_downgrade_result"] = "failed" + details["local_downgrade_error"] = message + record["updated_at"] = datetime.now(UTC).isoformat() + await Storage.set(_request_key(str(record["request_id"])), record, "json") + + async def _store_pending_downgrade_report(message: str) -> None: + _write_pending_pro_bundle_downgrade_receipt( + record, + reason=reason, + console_session=console_session, + error_message=message, + ) + if not record or not record.get("request_id"): + return + details = record.setdefault("details", {}) + details["local_downgrade_report_result"] = "pending" + details["local_downgrade_report_error"] = message + record["updated_at"] = datetime.now(UTC).isoformat() + await Storage.set(_request_key(str(record["request_id"])), record, "json") + + try: + yield f"data: {json.dumps({'stage': 'checking', 'message': 'Checking local Pro installation.', 'success': None})}\n\n" + console_session_error: str | None = None + try: + console_session = await ConsoleLoginService.require_console_session() + except Exception as exc: + console_session_error = str(exc) + + account_key = _console_session_account_key(console_session) if console_session else "" + record = await _latest_usable_issued_record(set(), account_key=account_key) + + async def _report_after_local_downgrade() -> None: + if console_session is None: + await _store_pending_downgrade_report(console_session_error or "云账号未登录,降级状态将在下次登录后同步") + return + try: + await _report_pro_bundle_downgrade(record, reason=reason, console_session=console_session) + except Exception as exc: + await _store_pending_downgrade_report(_downgrade_report_error_message(exc)) + return + if record and record.get("request_id"): + details = record.setdefault("details", {}) + details["local_downgrade_report_result"] = "reported" + await Storage.set(_request_key(str(record["request_id"])), record, "json") + + async for progress in perform_pro_bundle_downgrade( + restart=True, + reason=reason, + after_uninstall=_report_after_local_downgrade, + ): + if progress.stage == "error" and record and record.get("request_id"): + details = record.setdefault("details", {}) + details["local_downgrade_result"] = "failed" + details["local_downgrade_error"] = progress.message + record["updated_at"] = datetime.now(UTC).isoformat() + await Storage.set(_request_key(str(record["request_id"])), record, "json") + elif progress.stage == "done" and record and record.get("request_id"): + details = record.setdefault("details", {}) + details["local_downgrade_result"] = "done" + details["local_downgraded_at"] = datetime.now(UTC).isoformat() + record["updated_at"] = details["local_downgraded_at"] + await Storage.set(_request_key(str(record["request_id"])), record, "json") + yield f"data: {progress.model_dump_json()}\n\n" + await asyncio.sleep(0) + if progress.stage == "error": + return + except Exception as exc: + detail = str(exc) + await _store_local_downgrade_failure(detail) + yield f"data: {json.dumps({'stage': 'error', 'message': detail, 'success': False})}\n\n" + + return StreamingResponse( + _stream(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) + + @router.get("/upgrade-requests/{request_id}", response_model=UpgradeRequestStatus) async def get_upgrade_request(request_id: str, request: Request) -> UpgradeRequestStatus: require_admin(request) @@ -996,8 +1243,8 @@ async def _stream(): details["auto_install_result"] = "done" else: details["auto_install_result"] = "license_inactive" - details["auto_install_version"] = marker.get("installed_version") - details["auto_install_pro_version"] = marker.get("flockspro_component_version") + details["auto_install_bundle_version"] = marker.get("bundle_version") + details["auto_install_pro_component_version"] = marker.get("flockspro_component_version") details["auto_install_completed_at"] = datetime.now(UTC).isoformat() details["auto_install_message"] = progress.message _enrich_record_from_install_marker(raw) diff --git a/flocks/server/routes/device.py b/flocks/server/routes/device.py index 650a13b72..11bf877e8 100644 --- a/flocks/server/routes/device.py +++ b/flocks/server/routes/device.py @@ -5,6 +5,7 @@ """ from __future__ import annotations +import asyncio import json from typing import Any, List, Optional @@ -181,7 +182,7 @@ async def route_list_devices(group_id: Optional[str] = None, refresh: bool = Fal @router.get("/templates", response_model=List[DeviceTemplate]) async def route_list_device_templates(refresh: bool = False): - return list_device_templates(refresh=refresh) + return await asyncio.to_thread(list_device_templates, refresh=refresh) @router.post("/sync") @@ -337,7 +338,7 @@ async def route_list_device_tools(device_id: str): from flocks.tool.registry import ToolRegistry storage_key: str = row["storage_key"] - ToolRegistry.init() + await ToolRegistry.init_async() # Collect tools that belong to this device's plugin (matching provider). device_tools = [ @@ -388,7 +389,7 @@ async def route_update_device_tool( from flocks.tool.registry import ToolRegistry - ToolRegistry.init() + await ToolRegistry.init_async() storage_key: str = row["storage_key"] tool = ToolRegistry.get(tool_name) if tool is None or tool.info.provider != storage_key: diff --git a/flocks/server/routes/flockspro_license.py b/flocks/server/routes/flockspro_license.py index 10b132273..b7f041b89 100644 --- a/flocks/server/routes/flockspro_license.py +++ b/flocks/server/routes/flockspro_license.py @@ -11,6 +11,7 @@ from fastapi import APIRouter, Request +from flocks.console.login import ConsoleLoginService from flocks.server.auth import require_user from flocks.server.routes.console_upgrade import _get_pro_capability_status, _is_pro_component_installed @@ -50,6 +51,8 @@ async def refresh_flockspro_license_status(request: Request) -> dict[str, Any]: return _inactive_status("flockspro_not_installed") try: + await ConsoleLoginService.send_heartbeat() + from flockspro.license.runtime import get_license_checker # type: ignore[import-not-found] checker = get_license_checker() diff --git a/flocks/server/routes/health.py b/flocks/server/routes/health.py index f2539d038..84669c577 100644 --- a/flocks/server/routes/health.py +++ b/flocks/server/routes/health.py @@ -6,8 +6,6 @@ from pydantic import BaseModel from datetime import datetime -from flocks.config.config import Config - router = APIRouter() @@ -17,8 +15,6 @@ class HealthResponse(BaseModel): status: str version: str timestamp: str - config_dir: str - data_dir: str @router.get( @@ -35,15 +31,12 @@ async def health_check() -> HealthResponse: Returns server status and basic information """ from datetime import UTC - config = Config.get_global() from flocks.updater import get_current_version return HealthResponse( status="healthy", version=get_current_version(), timestamp=datetime.now(UTC).isoformat(), - config_dir=str(config.config_dir), - data_dir=str(config.data_dir), ) diff --git a/flocks/server/routes/hub.py b/flocks/server/routes/hub.py index e2bc03d47..ba37eef83 100644 --- a/flocks/server/routes/hub.py +++ b/flocks/server/routes/hub.py @@ -2,18 +2,21 @@ from __future__ import annotations +import asyncio from typing import Optional from fastapi import APIRouter, HTTPException, Query +from fastapi.responses import StreamingResponse from pydantic import BaseModel, Field -from flocks.hub.catalog import category_counts, legacy_removed_plugin_message, list_catalog, load_manifest +from flocks.hub.catalog import category_counts, clear_catalog_caches, legacy_removed_plugin_message, list_catalog, load_manifest from flocks.hub.files import file_tree, read_file_content from flocks.hub.installer import install_plugin, uninstall_plugin, update_plugin from flocks.hub.models import ( HubCatalogEntry, HubFileContent, HubFileNode, + HubInstallProgressEvent, HubPluginManifest, InstalledPluginRecord, PluginType, @@ -46,6 +49,16 @@ def _guard_legacy_removed_plugin(plugin_type: PluginType, plugin_id: str) -> Non raise HTTPException(status_code=410, detail=detail) +def _clear_hub_runtime_caches() -> None: + clear_catalog_caches() + try: + from flocks.tool.device.plugin_index import clear_device_template_cache + + clear_device_template_cache() + except Exception: + pass + + @router.get("/hub/catalog", response_model=list[HubCatalogEntry]) async def hub_catalog( type: Optional[PluginType] = Query(default=None), # noqa: A002 - API field name @@ -57,7 +70,8 @@ async def hub_catalog( risk: Optional[str] = None, q: Optional[str] = None, ): - return list_catalog( + return await asyncio.to_thread( + list_catalog, plugin_type=type, category=_split_csv(category), tags=_split_csv(tags), @@ -71,7 +85,7 @@ async def hub_catalog( @router.get("/hub/categories") async def hub_categories(): - return category_counts() + return await asyncio.to_thread(category_counts) @router.get("/hub/plugins/{plugin_type}/{plugin_id}", response_model=HubPluginManifest) @@ -115,6 +129,55 @@ async def hub_install_plugin(plugin_type: PluginType, plugin_id: str, req: HubIn raise HTTPException(status_code=422, detail=str(exc)) from exc +@router.post("/hub/plugins/{plugin_type}/{plugin_id}/install/stream") +async def hub_install_plugin_stream(plugin_type: PluginType, plugin_id: str, req: HubInstallRequest = HubInstallRequest()): + _guard_legacy_removed_plugin(plugin_type, plugin_id) + if plugin_type != "component": + raise HTTPException(status_code=400, detail="Streaming install progress is only supported for components.") + try: + manifest = load_manifest(plugin_type, plugin_id) + except Exception as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + async def generate(): + queue: asyncio.Queue[HubInstallProgressEvent | None] = asyncio.Queue() + + async def emit(event: HubInstallProgressEvent) -> None: + await queue.put(event) + + async def run_install() -> None: + try: + await install_plugin(plugin_type, plugin_id, scope=req.scope, progress=emit) + except Exception as exc: + log.error("hub.install_stream.failed", {"type": plugin_type, "id": plugin_id, "error": str(exc)}) + await queue.put( + HubInstallProgressEvent( + event="error", + id=manifest.id, + type=manifest.type, + name=manifest.name, + nameCn=manifest.nameCn, + total=len(manifest.components), + message=str(exc), + ) + ) + finally: + await queue.put(None) + + task = asyncio.create_task(run_install()) + try: + while True: + event = await queue.get() + if event is None: + break + yield f"data: {event.model_dump_json()}\n\n" + finally: + if not task.done(): + task.cancel() + + return StreamingResponse(generate(), media_type="text/event-stream") + + @router.post("/hub/plugins/{plugin_type}/{plugin_id}/update", response_model=InstalledPluginRecord) async def hub_update_plugin(plugin_type: PluginType, plugin_id: str, req: HubInstallRequest = HubInstallRequest()): _guard_legacy_removed_plugin(plugin_type, plugin_id) @@ -137,5 +200,5 @@ async def hub_uninstall_plugin(plugin_type: PluginType, plugin_id: str): @router.post("/hub/refresh") async def hub_refresh(): - # The bundled catalog is filesystem-backed, so refresh just returns the current count. - return {"count": len(list_catalog())} + _clear_hub_runtime_caches() + return {"count": len(await asyncio.to_thread(list_catalog))} diff --git a/flocks/server/routes/provider.py b/flocks/server/routes/provider.py index 34976bb36..19c8e8397 100644 --- a/flocks/server/routes/provider.py +++ b/flocks/server/routes/provider.py @@ -1139,7 +1139,7 @@ async def list_api_services() -> List[APIServiceSummary]: from flocks.tool.registry import ToolRegistry from flocks.config.api_versioning import shadowed_legacy_ids - ToolRegistry.init() + await ToolRegistry.init_async() configured_services = set(ConfigWriter.list_api_services_raw().keys()) discovered_services = ToolRegistry.get_api_service_ids() @@ -1168,6 +1168,10 @@ async def list_api_services() -> List[APIServiceSummary]: async def update_api_service(provider_id: str, request: APIServiceUpdateRequest) -> APIServiceSummary: try: + from flocks.tool.registry import ToolRegistry + + await ToolRegistry.init_async() + existing = ConfigWriter.get_api_service_raw(provider_id) or {} existing["enabled"] = request.enabled if request.verify_ssl is not None: @@ -2124,7 +2128,7 @@ async def test_provider_credentials(provider_id: str, body: Optional[TestCredent from flocks.tool.registry import ToolRegistry, ToolCategory, ToolInfo from flocks.server.routes.tool import _get_tool_source - ToolRegistry.init() + await ToolRegistry.init_async() _set_api_service_tools_enabled(provider_id, True) @@ -2595,7 +2599,7 @@ async def refresh_api_services_status(): # Also discover services from registered tools (covers YAML API tools) from flocks.tool.registry import ToolRegistry - ToolRegistry.init() + await ToolRegistry.init_async() service_ids: set = set(api_services.keys()) | ToolRegistry.get_api_service_ids() refreshed_at = int(time.time()) diff --git a/flocks/server/routes/skill.py b/flocks/server/routes/skill.py index f270b15c8..28d0e3d7f 100644 --- a/flocks/server/routes/skill.py +++ b/flocks/server/routes/skill.py @@ -107,6 +107,8 @@ class SkillInstallRequest(BaseModel): "Install source. Supported formats:\n" " clawhub: – clawhub.com registry\n" " github:/ – GitHub repo\n" + " safeskill://... – SafeSkill package URI\n" + " safeskill: – SafeSkill source alias\n" " https://... – direct URL to SKILL.md\n" " /local/path – local file or directory\n" " / – shorthand for GitHub" @@ -329,9 +331,9 @@ async def install_skill(req: SkillInstallRequest, _user=Depends(require_user)): Supported sources: - `clawhub:` — clawhub.com registry (OpenClaw ecosystem) - `github:/` or `/` — GitHub repository + - `safeskill://...` or `safeskill:` — SafeSkill package URI/source - `https://...` — direct URL to a SKILL.md file - `/local/path` — local filesystem path - - `safeskill:` — SafeSkill registry (reserved, future) """ try: result = await SkillInstaller.install_from_source(req.source, scope=req.scope) diff --git a/flocks/server/routes/tool.py b/flocks/server/routes/tool.py index 765a1f3e7..658bc0897 100644 --- a/flocks/server/routes/tool.py +++ b/flocks/server/routes/tool.py @@ -4,7 +4,7 @@ import asyncio import time -from typing import List, Optional, Dict, Any +from typing import Annotated, List, Optional, Dict, Any from fastapi import APIRouter, Depends, HTTPException, Query, status from pydantic import BaseModel, Field @@ -477,7 +477,7 @@ async def list_tools( """ # Initialize registry if needed started_at = time.perf_counter() - ToolRegistry.init() + await ToolRegistry.init_async() # Parse category filter cat_filter = None @@ -540,13 +540,15 @@ async def get_tool(tool_name: str): async def update_tool( tool_name: str, request: ToolUpdateRequest, - device_id: Optional[str] = Query( - None, - description=( - "设备实例 UUID。提供时仅修改该设备的工具开关(per-device 覆盖)," - "不影响其他同版本设备;省略时修改全局 tool_settings(影响所有设备)。" + device_id: Annotated[ + Optional[str], + Query( + description=( + "设备实例 UUID。提供时仅修改该设备的工具开关(per-device 覆盖)," + "不影响其他同版本设备;省略时修改全局 tool_settings(影响所有设备)。" + ), ), - ), + ] = None, _admin: object = Depends(require_admin), ): """ @@ -577,7 +579,7 @@ async def update_tool( ``info.enabled`` flag, mirroring the gate in :meth:`ToolRegistry._apply_tool_settings`. """ - ToolRegistry.init() + await ToolRegistry.init_async() tool = ToolRegistry.get(tool_name) if not tool: diff --git a/flocks/server/routes/workflow.py b/flocks/server/routes/workflow.py index c480e0b1f..1bf338498 100644 --- a/flocks/server/routes/workflow.py +++ b/flocks/server/routes/workflow.py @@ -99,6 +99,8 @@ _PROGRESS_FLUSH_EVERY_STEPS = 5 _LEGACY_SINGLETON_TRIGGER_TYPES = frozenset({"schedule", "kafka", "syslog"}) +_WEBHOOK_TRIGGER_TYPES = frozenset({"webhook", "custom_webhook"}) +_WEBHOOK_AUTH_TYPES = frozenset({"api_key", "hmac"}) _WORKFLOW_INTEGRATION_CONFIG_VERSION = 1 _WORKFLOW_INTEGRATION_CONFIG_KIND = "workflow.integration-config" _WORKFLOW_INTEGRATION_CONFIG_PREFIX = "workflow_integration_config/" @@ -2733,6 +2735,39 @@ def _validate_trigger_type_constraints(triggers: List[TriggerDefinition]) -> Non raise HTTPException(status_code=409, detail=detail) +def _webhook_auth_type(trigger: TriggerDefinition) -> str: + auth = trigger.auth + return str(getattr(auth, "type", "") or "").strip().lower() + + +def _validate_webhook_trigger_auth(trigger: TriggerDefinition) -> None: + if trigger.type not in _WEBHOOK_TRIGGER_TYPES: + return + + auth = trigger.auth + auth_type = _webhook_auth_type(trigger) + if auth is None or auth_type in {"", "none"}: + raise HTTPException( + status_code=400, + detail="Webhook triggers must configure authentication with auth.type 'api_key' or 'hmac'.", + ) + if auth_type not in _WEBHOOK_AUTH_TYPES: + raise HTTPException(status_code=400, detail=f"Unsupported webhook auth type: {auth.type}") + if auth_type == "api_key" and not (auth.apiKey or auth.secretRef): + raise HTTPException( + status_code=400, + detail="Webhook api_key auth requires either apiKey or secretRef.", + ) + if auth_type == "hmac" and not auth.secretRef: + raise HTTPException(status_code=400, detail="Webhook hmac auth requires secretRef.") + + +def _validate_trigger_definitions(triggers: List[TriggerDefinition]) -> None: + _validate_trigger_type_constraints(triggers) + for trigger in triggers: + _validate_webhook_trigger_auth(trigger) + + @router.get("/workflow/{workflow_id}/triggers") async def list_workflow_triggers(workflow_id: str): """List unified triggers for a workflow with runtime status.""" @@ -2764,7 +2799,7 @@ async def create_workflow_trigger(workflow_id: str, trigger: TriggerDefinition): raise HTTPException(status_code=404, detail=f"Workflow not found: {workflow_id}") existing = await _get_workflow_trigger_defs(workflow_id, data) updated = _replace_or_append_trigger(existing, trigger) - _validate_trigger_type_constraints(updated) + _validate_trigger_definitions(updated) persisted = await _persist_workflow_triggers(workflow_id, data, updated) await default_trigger_runtime.restart_workflow(workflow_id, persisted.get("workflowJson") or {}) status = await default_trigger_runtime.get_trigger_status(workflow_id, trigger) @@ -2781,7 +2816,7 @@ async def update_workflow_trigger(workflow_id: str, trigger_id: str, trigger: Tr _find_trigger_or_404(existing, trigger_id) updated_trigger = trigger.model_copy(update={"id": trigger_id}) updated = _replace_or_append_trigger(existing, updated_trigger) - _validate_trigger_type_constraints(updated) + _validate_trigger_definitions(updated) persisted = await _persist_workflow_triggers(workflow_id, data, updated) await default_trigger_runtime.restart_workflow(workflow_id, persisted.get("workflowJson") or {}) status = await default_trigger_runtime.get_trigger_status(workflow_id, updated_trigger) @@ -2908,9 +2943,13 @@ def _authorize_webhook_trigger( raw_body: bytes, ) -> None: auth = trigger.auth - if auth is None or auth.type in {"none", ""}: - return - if auth.type == "api_key": + auth_type = _webhook_auth_type(trigger) + if auth is None or auth_type in {"none", ""}: + raise HTTPException( + status_code=401, + detail="Webhook trigger must configure authentication with auth.type 'api_key' or 'hmac'.", + ) + if auth_type == "api_key": expected = auth.apiKey or _resolve_trigger_secret(auth.secretRef) if not expected: raise HTTPException(status_code=401, detail="Webhook trigger API key is not configured") @@ -2919,7 +2958,7 @@ def _authorize_webhook_trigger( if actual != expected: raise HTTPException(status_code=401, detail="Invalid webhook API key") return - if auth.type == "hmac": + if auth_type == "hmac": expected = _resolve_trigger_secret(auth.secretRef) if not expected: raise HTTPException(status_code=401, detail="Webhook trigger secret is not configured") diff --git a/flocks/server/static_webui.py b/flocks/server/static_webui.py new file mode 100644 index 000000000..dede61320 --- /dev/null +++ b/flocks/server/static_webui.py @@ -0,0 +1,129 @@ +"""Static WebUI hosting helpers for the FastAPI server.""" + +from __future__ import annotations + +import os +import re +from pathlib import Path +from urllib.parse import unquote + +from fastapi import Request, Response +from fastapi.responses import FileResponse, PlainTextResponse + +_INDEX_CACHE_CONTROL = "no-store" +_ASSET_CACHE_CONTROL = "public, max-age=31536000, immutable" +_STATIC_CACHE_CONTROL = "no-cache" +_FINGERPRINT_RE = re.compile(r"(?:^|[.-])[0-9a-f]{8,}(?:[.-]|$)", re.IGNORECASE) +_PROTECTED_PREFIXES = ( + "/api", + "/event", + "/global", + "/docs", + "/redoc", + "/openapi.json", + "/health", +) + + +class WebUIDistMissingError(RuntimeError): + """Raised when the production WebUI build output is unavailable.""" + + +def source_webui_dist_dir() -> Path: + """Return the source-tree WebUI dist directory.""" + return Path(__file__).resolve().parents[2] / "webui" / "dist" + + +def packaged_webui_dist_dir() -> Path: + """Return the packaged WebUI static directory.""" + return Path(__file__).resolve().parents[1] / "webui_static" + + +def resolve_webui_dist_dir() -> Path | None: + """Return the first usable WebUI dist directory.""" + candidates: list[Path] = [] + override = os.getenv("FLOCKS_WEBUI_DIST_DIR") + if override: + candidates.append(Path(override).expanduser()) + candidates.extend([source_webui_dist_dir(), packaged_webui_dist_dir()]) + for candidate in candidates: + if (candidate / "index.html").is_file(): + return candidate.resolve() + return None + + +def ensure_webui_dist_dir() -> Path: + """Return the WebUI dist directory or raise a clear startup error.""" + dist_dir = resolve_webui_dist_dir() + if dist_dir is None: + raise WebUIDistMissingError( + "WebUI build output is missing. Run `cd webui && npm run build`, " + "or start without `--skip-webui-build` so Flocks can build it." + ) + return dist_dir + + +async def maybe_serve_static_webui(request: Request) -> Response | None: + """Serve SPA static files for browser navigations. + + API and TUI-compatible requests continue through the existing routers. Only + real static files and browser HTML navigation requests are handled here. + """ + if request.method not in {"GET", "HEAD"}: + return None + + path = request.url.path or "/" + dist_dir = resolve_webui_dist_dir() + if dist_dir is None: + return None + + file_path = _resolve_existing_static_file(dist_dir, path) + if file_path is not None: + return _file_response(file_path, cache_control=_cache_control_for_file(path, file_path)) + + if path.startswith("/assets/"): + return PlainTextResponse("Not found", status_code=404) + if _is_protected_backend_path(path): + return None + if not _accepts_html(request): + return None + + return _file_response(dist_dir / "index.html", cache_control=_INDEX_CACHE_CONTROL) + + +def _resolve_existing_static_file(dist_dir: Path, path: str) -> Path | None: + if path == "/": + return None + relative = unquote(path.lstrip("/")) + candidate = (dist_dir / relative).resolve() + try: + candidate.relative_to(dist_dir) + except ValueError: + return None + if candidate.is_file(): + return candidate + return None + + +def _file_response(path: Path, *, cache_control: str) -> FileResponse: + headers = {"Cache-Control": cache_control} + return FileResponse(path, headers=headers) + + +def _cache_control_for_file(path: str, file_path: Path) -> str: + if file_path.name == "index.html": + return _INDEX_CACHE_CONTROL + if path.startswith("/assets/") or _FINGERPRINT_RE.search(file_path.name): + return _ASSET_CACHE_CONTROL + return _STATIC_CACHE_CONTROL + + +def _is_protected_backend_path(path: str) -> bool: + return any(path == prefix or path.startswith(prefix + "/") for prefix in _PROTECTED_PREFIXES) + + +def _accepts_html(request: Request) -> bool: + accept = request.headers.get("accept", "") + if not accept or accept == "*/*": + return False + return "text/html" in accept or "application/xhtml+xml" in accept diff --git a/flocks/session/lifecycle/retry.py b/flocks/session/lifecycle/retry.py index fe6f79c99..c220aff1a 100644 --- a/flocks/session/lifecycle/retry.py +++ b/flocks/session/lifecycle/retry.py @@ -29,6 +29,9 @@ "could not connect", "failed to connect", "api connection", + "remote protocol error", + "peer closed", + "incomplete chunked read", "model unavailable", "model is unavailable", "model not available", diff --git a/flocks/skill/installer.py b/flocks/skill/installer.py index 98583903f..15a9aeac6 100644 --- a/flocks/skill/installer.py +++ b/flocks/skill/installer.py @@ -24,6 +24,8 @@ import platform import re import shutil +import signal +import subprocess import sys import tempfile import zipfile @@ -115,12 +117,12 @@ def _resolve_source(source: str) -> dict: prefix = "skills-sh:" if source.startswith("skills-sh:") else "skills.sh:" return {"kind": "skills_sh", "value": source[len(prefix):]} - if source.startswith("safeskill:"): - return {"kind": "safeskill", "value": source[len("safeskill:"):]} - if source.startswith("safeskill://"): return {"kind": "safeskill", "value": source} + if source.startswith("safeskill:"): + return {"kind": "safeskill", "value": source[len("safeskill:"):]} + if source.startswith("clawhub:"): return {"kind": "clawhub", "value": source[len("clawhub:"):]} @@ -167,6 +169,81 @@ def _resolve_source(source: str) -> dict: class SkillInstaller: """Install skills from external sources and manage skill dependencies.""" + @staticmethod + def _subprocess_stdio_kwargs( + *, + cwd: Optional[str] = None, + env: Optional[dict[str, str]] = None, + ) -> dict: + kwargs = { + "cwd": cwd, + "env": env, + "stdin": asyncio.subprocess.DEVNULL, + "stdout": asyncio.subprocess.PIPE, + "stderr": asyncio.subprocess.PIPE, + } + if os.name == "nt": + kwargs["creationflags"] = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) + else: + kwargs["start_new_session"] = True + return kwargs + + @staticmethod + def _signal_process_tree(proc, sig: signal.Signals, *, force: bool = False) -> None: + pid = getattr(proc, "pid", None) + if os.name == "nt": + if force and isinstance(pid, int) and pid > 0: + try: + completed = subprocess.run( + ["taskkill", "/PID", str(pid), "/T", "/F"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + if completed.returncode == 0: + return + except Exception: + pass + try: + if force: + proc.kill() + else: + proc.terminate() + except ProcessLookupError: + pass + return + + if os.name != "nt" and isinstance(pid, int) and pid > 0: + try: + os.killpg(pid, sig) + return + except ProcessLookupError: + return + except Exception: + pass + try: + if force: + proc.kill() + else: + proc.terminate() + except ProcessLookupError: + pass + + @staticmethod + def _safeskill_staging_env(staging: Path) -> dict[str, str]: + env = os.environ.copy() + appdata = staging / "AppData" + env.update({ + "HOME": str(staging), + "USERPROFILE": str(staging), + "APPDATA": str(appdata / "Roaming"), + "LOCALAPPDATA": str(appdata / "Local"), + "XDG_CONFIG_HOME": str(staging / ".config"), + "XDG_CACHE_HOME": str(staging / ".cache"), + "NPM_CONFIG_CACHE": str(staging / ".npm"), + }) + return env + @staticmethod async def _run_subprocess( cmd: list[str], @@ -177,10 +254,7 @@ async def _run_subprocess( ) -> tuple[int, str, str]: proc = await asyncio.create_subprocess_exec( *cmd, - cwd=cwd, - env=env, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, + **SkillInstaller._subprocess_stdio_kwargs(cwd=cwd, env=env), ) try: stdout_b, stderr_b = await asyncio.wait_for( @@ -188,14 +262,19 @@ async def _run_subprocess( timeout=timeout_sec, ) except asyncio.TimeoutError: - try: - proc.kill() - except ProcessLookupError: - pass + SkillInstaller._signal_process_tree(proc, signal.SIGTERM) try: await asyncio.wait_for(proc.communicate(), timeout=5) except Exception: - pass + SkillInstaller._signal_process_tree( + proc, + getattr(signal, "SIGKILL", signal.SIGTERM), + force=True, + ) + try: + await asyncio.wait_for(proc.communicate(), timeout=5) + except Exception: + pass raise TimeoutError(f"Command timed out after {timeout_sec:g}s: {' '.join(cmd)}") return ( proc.returncode if proc.returncode is not None else 0, @@ -359,7 +438,7 @@ async def _install_from_skills_sh_cli( @classmethod async def _install_from_safeskill(cls, source: str, scope: str) -> SkillInstallResult: - """Run SafeSkill CLI in a staging directory and import its agent output.""" + """Run SafeSkill CLI in an isolated home and import its Flocks output.""" npx = shutil.which("npx") if not npx: return SkillInstallResult( @@ -373,27 +452,30 @@ async def _install_from_safeskill(cls, source: str, scope: str) -> SkillInstallR success=False, error=( "safeskill source is required, e.g. " - "safeskill:safeskill://official/acme/code-review" + "safeskill://official/acme/code-review" ), ) with tempfile.TemporaryDirectory(prefix="flocks-safeskill-") as tmp: staging = Path(tmp) + env = cls._safeskill_staging_env(staging) cmd = [ npx, "-y", "@safeskill/cli", + "--region", + "cn", "add", source, - "--copy", - "-y", - "-a", - "universal", + "--agent", + "flocks", + "--yes", ] try: returncode, stdout, stderr = await cls._run_subprocess( cmd, cwd=str(staging), + env=env, timeout_sec=_SKILLS_SH_CLI_TIMEOUT_SEC, ) except TimeoutError as exc: @@ -416,7 +498,7 @@ async def _install_from_safeskill(cls, source: str, scope: str) -> SkillInstallR success=False, error=( "SafeSkill CLI completed but no SKILL.md files were found " - "in the staging agent directories." + "in the staged Flocks/agent skill directories." ), ) @@ -499,11 +581,12 @@ def _github_repo_slug(value: str) -> Optional[str]: @classmethod def _import_staged_skill_dirs(cls, staging: Path, scope: str) -> List[tuple[str, Path]]: - """Copy staged SafeSkill agent directories into Flocks skill storage.""" + """Copy staged agent skill directories into Flocks skill storage.""" install_root = _resolve_install_root(scope) imported: List[tuple[str, Path]] = [] seen: set[Path] = set() candidate_roots = [ + staging / ".flocks" / "plugins" / "skills", staging / ".agents" / "skills", staging / ".claude" / "skills", staging / ".cursor" / "skills", @@ -1208,16 +1291,26 @@ async def _execute_install_spec( try: proc = await asyncio.create_subprocess_exec( *cmd, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, + **cls._subprocess_stdio_kwargs(), ) try: stdout_b, stderr_b = await asyncio.wait_for( proc.communicate(), timeout=timeout_sec ) except asyncio.TimeoutError: - proc.kill() - await proc.communicate() + cls._signal_process_tree(proc, signal.SIGTERM) + try: + await asyncio.wait_for(proc.communicate(), timeout=5) + except Exception: + cls._signal_process_tree( + proc, + getattr(signal, "SIGKILL", signal.SIGTERM), + force=True, + ) + try: + await asyncio.wait_for(proc.communicate(), timeout=5) + except Exception: + pass return DepInstallResult( success=False, spec_id=spec.id, diff --git a/flocks/storage/storage.py b/flocks/storage/storage.py index bad02354f..4a1b1ed41 100644 --- a/flocks/storage/storage.py +++ b/flocks/storage/storage.py @@ -6,6 +6,8 @@ import asyncio import os +import shutil +import subprocess from contextlib import asynccontextmanager from pathlib import Path @@ -21,6 +23,8 @@ T = TypeVar("T", bound=BaseModel) +DDLScript = str | Callable[[aiosqlite.Connection], Awaitable[None]] +R = TypeVar("R") class NotFoundError(Exception): @@ -73,7 +77,7 @@ class Storage: # descriptors and ``_initialized=True`` flag are never silently inherited # — a known SQLite corruption vector. _init_pid: Optional[int] = None - _extension_ddls: List[str] = [] + _extension_ddls: List[DDLScript] = [] _sqlite_timeout_s = 5.0 _sqlite_busy_timeout_ms = 5000 _sqlite_journal_mode = "WAL" @@ -94,6 +98,9 @@ class Storage: _sqlite_write_retry_base_delay_s = 0.05 _multi_db_migration_marker_key = "storage.migration.multi_db.v1" _multi_db_migration_batch_size = 500 + _corruption_recovery_lock = asyncio.Lock() + _corruption_recovery_generation = 0 + _sqlite_recover_timeout_s = 30.0 # Substrings that mark an SQLite file as unrecoverably damaged at open # time. We deliberately keep this list short and English-only because @@ -311,14 +318,259 @@ def _quarantine_corrupt_db(cls, db_path: Path) -> Optional[Path]: "original_path": str(db_path), "quarantined_path": str(new_main), "hint": ( - "Server is starting with a fresh empty database. " - "Run scripts/recover_raw_flocks_db.py against the " - "quarantined file to attempt data recovery." + "Server will attempt sqlite3 .recover against the " + "quarantined file before falling back to a fresh " + "empty database." ), }, ) return new_main + @classmethod + def _integrity_check_sync(cls, db_path: Path) -> tuple[bool, str]: + """Return whether SQLite can read *db_path* and reports integrity OK.""" + try: + conn = sqlite3.connect(db_path, timeout=cls._sqlite_timeout_s) + try: + row = conn.execute("PRAGMA integrity_check").fetchone() + finally: + conn.close() + except Exception as exc: + return False, str(exc) + if row is None: + return False, "integrity_check returned no rows" + result = str(row[0]) + return result.lower() == "ok", result + + @classmethod + async def _assert_integrity_check_ok(cls, db_path: Path) -> None: + """Raise a corruption-looking SQLite error when integrity check fails.""" + ok, detail = await asyncio.to_thread(cls._integrity_check_sync, db_path) + if ok: + return + raise sqlite3.DatabaseError( + "database disk image is malformed: " + f"PRAGMA integrity_check failed for {db_path}: {detail}" + ) + + @classmethod + def _try_sqlite_recover_sync(cls, quarantined_path: Path, target_path: Path) -> Optional[Path]: + """Try SQLite's lightweight `.recover` and install the recovered DB. + + This intentionally avoids the heavier raw-page/WAL reconstruction script. + It handles the common case where SQLite can still scan a malformed DB + enough to emit recoverable SQL. Failure is non-fatal; callers fall back + to bootstrapping an empty database. + """ + sqlite_bin = shutil.which("sqlite3") + if sqlite_bin is None: + cls._log.warn( + "storage.corruption.recovery.skipped", + { + "db_path": str(target_path), + "quarantined_path": str(quarantined_path), + "reason": "sqlite3 CLI not found", + }, + ) + return None + + recovered_path = target_path.with_name(target_path.name + ".recovered") + sql_path = target_path.with_name(target_path.name + ".recover.sql") + for path in (recovered_path, sql_path): + try: + path.unlink() + except FileNotFoundError: + pass + + try: + completed = subprocess.run( + [sqlite_bin, str(quarantined_path), ".recover"], + check=False, + capture_output=True, + text=True, + encoding="utf-8", + timeout=cls._sqlite_recover_timeout_s, + ) + except Exception as exc: + cls._log.warn( + "storage.corruption.recovery.failed", + { + "db_path": str(target_path), + "quarantined_path": str(quarantined_path), + "stage": "recover", + "error": str(exc), + }, + ) + return None + + recover_sql = completed.stdout or "" + sql_path.write_text(recover_sql, encoding="utf-8") + if completed.returncode != 0 and not recover_sql.strip(): + cls._log.warn( + "storage.corruption.recovery.failed", + { + "db_path": str(target_path), + "quarantined_path": str(quarantined_path), + "stage": "recover", + "error": completed.stderr.strip() or str(completed.returncode), + }, + ) + return None + + try: + materialized = subprocess.run( + [sqlite_bin, str(recovered_path)], + input=recover_sql, + check=False, + capture_output=True, + text=True, + encoding="utf-8", + timeout=cls._sqlite_recover_timeout_s, + ) + except Exception as exc: + cls._log.warn( + "storage.corruption.recovery.failed", + { + "db_path": str(target_path), + "quarantined_path": str(quarantined_path), + "stage": "materialize", + "error": str(exc), + }, + ) + return None + + if materialized.returncode != 0: + cls._log.warn( + "storage.corruption.recovery.failed", + { + "db_path": str(target_path), + "quarantined_path": str(quarantined_path), + "stage": "materialize", + "error": materialized.stderr.strip() or str(materialized.returncode), + "recover_sql": str(sql_path), + }, + ) + return None + + ok, detail = cls._integrity_check_sync(recovered_path) + if not ok: + cls._log.warn( + "storage.corruption.recovery.failed", + { + "db_path": str(target_path), + "quarantined_path": str(quarantined_path), + "stage": "integrity_check", + "error": detail, + "recovered_path": str(recovered_path), + "recover_sql": str(sql_path), + }, + ) + return None + + try: + recovered_path.replace(target_path) + except OSError as exc: + cls._log.warn( + "storage.corruption.recovery.failed", + { + "db_path": str(target_path), + "quarantined_path": str(quarantined_path), + "stage": "install", + "error": str(exc), + "recovered_path": str(recovered_path), + "recover_sql": str(sql_path), + }, + ) + return None + + cls._log.warn( + "storage.corruption.recovery.succeeded", + { + "db_path": str(target_path), + "quarantined_path": str(quarantined_path), + "recover_sql": str(sql_path), + }, + ) + return target_path + + @classmethod + async def recover_corrupt_db( + cls, + db_path: Path, + *, + action: str, + exc: BaseException, + generation: Optional[int] = None, + reinitialize: Optional[Callable[[], Awaitable[Any]]] = None, + ) -> bool: + """Quarantine a corrupt SQLite DB and rebuild it once. + + Returns ``True`` when recovery completed and callers should retry the + failed operation. Returns ``False`` when another task already completed + recovery while this caller was waiting for the lock, in which case a + retry is still appropriate. + """ + db_path = Path(db_path) + async with cls._corruption_recovery_lock: + if generation is not None and generation != cls._corruption_recovery_generation: + return False + + cls._log.error( + "storage.corruption.detected", + { + "db_path": str(db_path), + "action": action, + "error": str(exc), + "error_type": type(exc).__name__, + }, + ) + quarantined = cls._quarantine_corrupt_db(db_path) + if quarantined is None: + raise exc + await asyncio.to_thread(cls._try_sqlite_recover_sync, quarantined, db_path) + + if cls._db_path == db_path: + cls._initialized = False + cls._init_pid = None + + if reinitialize is not None: + await reinitialize() + elif cls._db_path == db_path or db_path.name == "flocks.db": + await cls.init(db_path) + + cls._corruption_recovery_generation += 1 + cls._log.warn( + "storage.corruption.recovered", + { + "db_path": str(db_path), + "quarantined_path": str(quarantined), + "action": action, + }, + ) + return True + + @classmethod + async def _run_with_corruption_recovery( + cls, + operation: Callable[[], Awaitable[R]], + *, + db_path: Path, + action: str, + ) -> R: + generation = cls._corruption_recovery_generation + try: + return await operation() + except Exception as exc: + if not cls._is_db_corruption_error(exc): + raise + await cls.recover_corrupt_db( + db_path, + action=action, + exc=exc, + generation=generation, + ) + return await operation() + @classmethod def _is_sqlite_busy_error(cls, exc: Exception) -> bool: """Return whether *exc* is a retryable SQLite busy/locked write error.""" @@ -432,7 +684,7 @@ def connect_sync(cls, db_path: Optional[Path] = None) -> sqlite3.Connection: return cls.configure_sync_connection(conn) @classmethod - def register_ddl(cls, ddl: str) -> None: + def register_ddl(cls, ddl: DDLScript) -> None: """Register an extension DDL script to be executed during ``init()``. If init() has already completed the DDL is executed immediately @@ -627,6 +879,7 @@ async def init(cls, db_path: Optional[Path] = None) -> None: quarantined = cls._quarantine_corrupt_db(cls._db_path) if quarantined is None: raise + await asyncio.to_thread(cls._try_sqlite_recover_sync, quarantined, cls._db_path) await cls._bootstrap_schema() # Drain any residual WAL frames left by the previous process so the @@ -823,7 +1076,10 @@ async def _bootstrap_schema(cls) -> None: async def _run_extension_ddl() -> None: async with cls.connect(cls._db_path) as db: - await db.executescript(ddl) + if isinstance(ddl, str): + await db.executescript(ddl) + else: + await ddl(db) await db.commit() await cls._run_write_with_retry( @@ -834,6 +1090,8 @@ async def _run_extension_ddl() -> None: except Exception as e: cls._log.warn("storage.extension_ddl.failed", {"error": str(e)}) + await cls._assert_integrity_check_ok(cls._db_path) + @classmethod async def _create_model_management_tables(cls) -> None: """Create dynamic data tables (idempotent). @@ -978,7 +1236,11 @@ async def _write() -> None: ) await db.commit() - await cls._run_write_with_retry(_write, action="set", target=key) + await cls._run_with_corruption_recovery( + lambda: cls._run_write_with_retry(_write, action="set", target=key), + db_path=db_path, + action=f"set:{key}", + ) cls._log.debug("storage.set", {"key": key, "type": value_type}) @@ -997,9 +1259,16 @@ async def get(cls, key: str, model: Optional[Type[T]] = None) -> Optional[T | An await cls._ensure_init() db_path = cls.route_db_path_for_key(key) - async with cls.connect(db_path) as db: - async with db.execute("SELECT value, type FROM storage WHERE key = ?", (key,)) as cursor: - row = await cursor.fetchone() + async def _read() -> Optional[Tuple[str, str]]: + async with cls.connect(db_path) as db: + async with db.execute("SELECT value, type FROM storage WHERE key = ?", (key,)) as cursor: + return await cursor.fetchone() + + row = await cls._run_with_corruption_recovery( + _read, + db_path=db_path, + action=f"get:{key}", + ) if row is None: return None @@ -1033,7 +1302,11 @@ async def _delete() -> bool: await db.commit() return cursor.rowcount > 0 - deleted = await cls._run_write_with_retry(_delete, action="delete", target=key) + deleted = await cls._run_with_corruption_recovery( + lambda: cls._run_write_with_retry(_delete, action="delete", target=key), + db_path=db_path, + action=f"delete:{key}", + ) if deleted: cls._log.debug("storage.delete", {"key": key}) @@ -1059,16 +1332,23 @@ async def list_keys(cls, prefix: Optional[str] = None) -> List[str]: keys: set[str] = set() for db_path in db_paths: - async with cls.connect(db_path) as db: - if prefix: - query = f"SELECT key FROM storage WHERE {cls._like_prefix_clause()}" - params = (cls._like_prefix_pattern(prefix),) - else: - query = "SELECT key FROM storage" - params = () - - async with db.execute(query, params) as cursor: - rows = await cursor.fetchall() + async def _read_keys(db_path: Path = db_path): + async with cls.connect(db_path) as db: + if prefix: + query = f"SELECT key FROM storage WHERE {cls._like_prefix_clause()}" + params = (cls._like_prefix_pattern(prefix),) + else: + query = "SELECT key FROM storage" + params = () + + async with db.execute(query, params) as cursor: + return await cursor.fetchall() + + rows = await cls._run_with_corruption_recovery( + _read_keys, + db_path=db_path, + action=f"list_keys:{prefix or ''}", + ) keys.update(row[0] for row in rows) return sorted(keys) @@ -1086,16 +1366,23 @@ async def _list_entry_rows( rows_by_key: dict[str, str] = {} for db_path in db_paths: - async with cls.connect(db_path) as db: - if prefix: - query = f"SELECT key, value FROM storage WHERE {cls._like_prefix_clause()}" - params = (cls._like_prefix_pattern(prefix),) - else: - query = "SELECT key, value FROM storage" - params = () - - async with db.execute(query, params) as cursor: - rows = await cursor.fetchall() + async def _read_rows(db_path: Path = db_path): + async with cls.connect(db_path) as db: + if prefix: + query = f"SELECT key, value FROM storage WHERE {cls._like_prefix_clause()}" + params = (cls._like_prefix_pattern(prefix),) + else: + query = "SELECT key, value FROM storage" + params = () + + async with db.execute(query, params) as cursor: + return await cursor.fetchall() + + rows = await cls._run_with_corruption_recovery( + _read_rows, + db_path=db_path, + action=f"list_entries:{prefix or ''}", + ) for key, value in rows: rows_by_key[key] = value @@ -1149,27 +1436,35 @@ async def list_entries_page( safe_limit = max(int(limit), 0) params = (cls._like_prefix_pattern(prefix),) - async with cls.connect(db_path) as db: - async with db.execute( - f"SELECT COUNT(*) FROM storage WHERE {cls._like_prefix_clause()}", - params, - ) as cursor: - row = await cursor.fetchone() - total = int(row[0]) if row else 0 - - if safe_limit == 0: - return [], total - - async with db.execute( - f""" - SELECT key, value FROM storage - WHERE {cls._like_prefix_clause()} - ORDER BY key - LIMIT ? OFFSET ? - """, - (cls._like_prefix_pattern(prefix), safe_limit, safe_offset), - ) as cursor: - rows = await cursor.fetchall() + async def _read_page(): + async with cls.connect(db_path) as db: + async with db.execute( + f"SELECT COUNT(*) FROM storage WHERE {cls._like_prefix_clause()}", + params, + ) as cursor: + row = await cursor.fetchone() + total = int(row[0]) if row else 0 + + if safe_limit == 0: + return [], total + + async with db.execute( + f""" + SELECT key, value FROM storage + WHERE {cls._like_prefix_clause()} + ORDER BY key + LIMIT ? OFFSET ? + """, + (cls._like_prefix_pattern(prefix), safe_limit, safe_offset), + ) as cursor: + rows = await cursor.fetchall() + return rows, total + + rows, total = await cls._run_with_corruption_recovery( + _read_page, + db_path=db_path, + action=f"list_entries_page:{prefix}", + ) entries: List[Tuple[str, T | Any]] = [] for key, value_str in rows: @@ -1210,9 +1505,16 @@ async def exists(cls, key: str) -> bool: await cls._ensure_init() db_path = cls.route_db_path_for_key(key) - async with cls.connect(db_path) as db: - async with db.execute("SELECT 1 FROM storage WHERE key = ?", (key,)) as cursor: - row = await cursor.fetchone() + async def _exists(): + async with cls.connect(db_path) as db: + async with db.execute("SELECT 1 FROM storage WHERE key = ?", (key,)) as cursor: + return await cursor.fetchone() + + row = await cls._run_with_corruption_recovery( + _exists, + db_path=db_path, + action=f"exists:{key}", + ) return row is not None @@ -1253,10 +1555,14 @@ async def _clear_db(db_path: Path) -> int: deleted = 0 for db_path in db_paths: - deleted += await cls._run_write_with_retry( - lambda db_path=db_path: _clear_db(db_path), - action="clear", - target=f"{prefix or ''}@{db_path}", + deleted += await cls._run_with_corruption_recovery( + lambda db_path=db_path: cls._run_write_with_retry( + lambda db_path=db_path: _clear_db(db_path), + action="clear", + target=f"{prefix or ''}@{db_path}", + ), + db_path=db_path, + action=f"clear:{prefix or ''}", ) cls._log.info("storage.clear", {"prefix": prefix, "deleted": deleted}) diff --git a/flocks/task/store.py b/flocks/task/store.py index cbc5aa1b8..d611a357d 100644 --- a/flocks/task/store.py +++ b/flocks/task/store.py @@ -61,7 +61,8 @@ async def init(cls) -> None: db_path = cls.get_db_path() db_existed_before_init = db_path.exists() db_path.parent.mkdir(parents=True, exist_ok=True) - try: + + async def _open_and_migrate() -> None: cls._conn = await aiosqlite.connect( db_path, timeout=Storage._sqlite_timeout_s, @@ -78,13 +79,25 @@ async def init(cls) -> None: cls._initialized = True cls._init_pid = current_pid await cls._normalize_legacy_paused_executions() + + try: + await _open_and_migrate() log.info("task.store.initialized") - except Exception: + except Exception as exc: if cls._conn: await cls._conn.close() cls._conn = None cls._initialized = False cls._init_pid = None + if Storage._is_db_corruption_error(exc): + await Storage.recover_corrupt_db( + db_path, + action="task.store.init", + exc=exc, + reinitialize=_open_and_migrate, + ) + log.info("task.store.initialized") + return raise @classmethod diff --git a/flocks/tool/channel/channel_message.py b/flocks/tool/channel/channel_message.py index bbe7ba19f..acc5e1c20 100644 --- a/flocks/tool/channel/channel_message.py +++ b/flocks/tool/channel/channel_message.py @@ -98,10 +98,11 @@ async def _http_session_send( ) body = resp.json() if resp.status_code == 200: + resolved_session_id = body.get("session_id") or session_id return ToolResult( success=True, output=( - f"Message sent to session '{session_id}' " + f"Message sent to session '{resolved_session_id}' " f"via channels {body.get('channels', [])}, " f"ids: {body.get('message_ids', [])}" ), @@ -217,13 +218,26 @@ async def channel_message(ctx: ToolContext, **kwargs) -> ToolResult: svc = SessionBindingService() all_bindings = await svc.list_bindings() matched = [b for b in all_bindings if b.session_id == session_id] + resolved_session_id = session_id + + if not matched and channel_type: + latest = await svc.latest_active_user_binding( + channel_id=channel_type, + account_id=account_id, + chat_id=chat_id, + ) + if latest: + matched = [latest] + resolved_session_id = latest.session_id if not matched: return ToolResult( success=False, error=( f"No channel binding found for session_id='{session_id}'. " - "Make sure the session was initiated via an IM channel." + "Resolve the current IM target again with " + "im_send_message(resolve_only=true), or ask the user to confirm " + "the target IM session." ), ) @@ -266,7 +280,7 @@ async def channel_message(ctx: ToolContext, **kwargs) -> ToolResult: text=message, media_url=media, ) - results = await OutboundDelivery.deliver(out_ctx, session_id=session_id) + results = await OutboundDelivery.deliver(out_ctx, session_id=resolved_session_id) all_results.extend(results) failed = [r for r in results if not r.success] @@ -284,7 +298,7 @@ async def channel_message(ctx: ToolContext, **kwargs) -> ToolResult: return ToolResult( success=True, output=( - f"Message sent to session '{session_id}' " + f"Message sent to session '{resolved_session_id}' " f"via channels {channels_sent}, " f"{len(all_results)} chunk(s), ids: {msg_ids}" ), diff --git a/flocks/tool/device/intake.py b/flocks/tool/device/intake.py index b4589afc0..04f5d2211 100644 --- a/flocks/tool/device/intake.py +++ b/flocks/tool/device/intake.py @@ -130,14 +130,16 @@ async def _ensure_user_device_instances_unlocked(*, refresh_templates: bool = Fa await ensure_default_group() existing_storage_keys = {device.storage_key for device in await list_devices()} ignored_storage_keys = await _load_auto_instance_ignored_storage_keys() - user_template_storage_keys = _user_device_template_storage_keys( + user_template_storage_keys = await asyncio.to_thread( + _user_device_template_storage_keys, refresh_templates=refresh_templates, ) created = 0 from flocks.tool.device.plugin_index import list_device_templates - for template in list_device_templates(refresh=False): + templates = await asyncio.to_thread(list_device_templates, refresh=False) + for template in templates: if template.source != "global" or not template.installed: continue if template.storage_key in existing_storage_keys: diff --git a/flocks/tool/device/models.py b/flocks/tool/device/models.py index a3b5d15d1..e9bc1c27d 100644 --- a/flocks/tool/device/models.py +++ b/flocks/tool/device/models.py @@ -54,15 +54,24 @@ updated_at INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS idx_device_storage_key ON device_integrations(storage_key); -CREATE INDEX IF NOT EXISTS idx_device_group ON device_integrations(group_id); """) + # Upgrade hook for installations created before group_id was added. -# Storage wraps each DDL in try/except so the duplicate-column error on fresh -# installs is silently ignored. -Storage.register_ddl( - "ALTER TABLE device_integrations ADD COLUMN group_id TEXT NOT NULL DEFAULT '';" -) +async def _ensure_device_integrations_group_id(db: Any) -> None: + cursor = await db.execute("PRAGMA table_info(device_integrations)") + columns = {str(row[1]) for row in await cursor.fetchall()} + if "group_id" in columns: + return + await db.execute("ALTER TABLE device_integrations ADD COLUMN group_id TEXT NOT NULL DEFAULT '';") + + +Storage.register_ddl(_ensure_device_integrations_group_id) + +Storage.register_ddl(""" +CREATE INDEX IF NOT EXISTS idx_device_group ON device_integrations(group_id); +""") + # Per-device tool enabled/disabled overrides. # diff --git a/flocks/tool/registry.py b/flocks/tool/registry.py index 5492f3459..0f11995e7 100644 --- a/flocks/tool/registry.py +++ b/flocks/tool/registry.py @@ -601,6 +601,8 @@ class ToolRegistry: _revision: int = 0 _failure_state: Dict[str, Dict[str, Any]] = {} _failure_disable_threshold: int = 3 + _init_lock = threading.Lock() + _initializing_thread_id: Optional[int] = None # Snapshot of every tool's factory-default ``enabled`` flag — captured # in :meth:`register` at the moment the tool object is handed to the @@ -734,7 +736,7 @@ def unregister(cls, name: str) -> bool: @classmethod def _ensure_initialized(cls) -> None: """Initialize the registry on first public access.""" - if not cls._initialized: + if not cls._initialized and cls._initializing_thread_id != threading.get_ident(): cls.init() @classmethod @@ -1015,13 +1017,26 @@ def init(cls) -> None: if cls._initialized: return - # Import and register built-in tools - cls._register_builtin_tools() - cls._register_dynamic_tools() - cls._register_plugin_extension_point() - cls._load_plugin_tools() - cls._initialized = True - log.debug("tool_registry.initialized", {"count": len(cls._tools)}) + with cls._init_lock: + if cls._initialized: + return + + cls._initializing_thread_id = threading.get_ident() + try: + # Import and register built-in tools + cls._register_builtin_tools() + cls._register_dynamic_tools() + cls._register_plugin_extension_point() + cls._load_plugin_tools() + cls._initialized = True + log.debug("tool_registry.initialized", {"count": len(cls._tools)}) + finally: + cls._initializing_thread_id = None + + @classmethod + async def init_async(cls) -> None: + """Initialize the registry without blocking the event loop.""" + await asyncio.to_thread(cls.init) @classmethod def _load_plugin_tools(cls) -> None: @@ -1456,7 +1471,13 @@ def _register_builtin_tools(cls) -> None: # 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"]), + ("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 diff --git a/flocks/tool/skill/flocks_skills.py b/flocks/tool/skill/flocks_skills.py index 8b577cc60..ca7a68689 100644 --- a/flocks/tool/skill/flocks_skills.py +++ b/flocks/tool/skill/flocks_skills.py @@ -56,7 +56,8 @@ github:// e.g. github:octocat/skills/find-ioc clawhub: e.g. clawhub:ndr-alert-analysis skills-sh:// e.g. skills-sh:owner/repo/code-review - safeskill: e.g. safeskill:safeskill://official/acme/code-review + safeskill://... e.g. safeskill://official/acme/code-review@1.2.0 + safeskill: SafeSkill source alias https://... direct SKILL.md URL The tool auto-adds --yes so non-interactive agent calls do not hang on downstream CLI confirmation prompts (e.g. `skills add`). @@ -170,7 +171,7 @@ async def flocks_skills( if not source: return ToolResult( success=False, - error="install requires a source, e.g. github:owner/repo/skill-name", + error="install requires a source, e.g. github:owner/repo/skill-name or safeskill://...", ) if scope not in {"global", "project"}: return ToolResult( diff --git a/flocks/tool/task/workflow_config_manage.py b/flocks/tool/task/workflow_config_manage.py new file mode 100644 index 000000000..90aa078ed --- /dev/null +++ b/flocks/tool/task/workflow_config_manage.py @@ -0,0 +1,647 @@ +"""Built-in workflow config management tool. + +This tool gives Rex a first-class path to workflow config stores from inside the +Flocks backend process. It intentionally reuses the existing workflow route +helpers so the tool and WebUI keep the same config shape, validation rules, and +runtime side effects. +""" + +from __future__ import annotations + +import difflib +import json +from typing import Any, Dict + +from fastapi import HTTPException + +from flocks.tool.registry import ( + ParameterType, + ToolCategory, + ToolContext, + ToolParameter, + ToolRegistry, + ToolResult, +) +from flocks.utils.log import Log + + +log = Log.create(service="tool.workflow_config_manage") + +_ACTIONS = {"get", "status", "sync", "diff", "put"} +_CONFIG_TYPES = {"integration", "kafka", "poller", "syslog"} +_RUNTIME_CONFIG_KINDS = { + "kafka": "workflow_kafka_config", + "poller": "workflow_poller_config", + "syslog": "workflow_syslog_config", +} +_RUNTIME_TRIGGER_TYPES = { + "kafka": "kafka", + "poller": "schedule", + "syslog": "syslog", +} + +DESCRIPTION = """Read, compare, sync, or update workflow configs from the Flocks backend store. + +Use this instead of reading server_api_token/service_api_token or curling local backend endpoints when a workflow guide asks to inspect or update workflow publish, trigger, or runtime config. + +Actions: +- get: read the effective workflow integration config and runtime summary. +- status: read a compact status summary without exposing the full config. +- diff: compare the current effective config with a proposed config; does not write. +- sync: ensure the config exists in WorkflowStore, migrating config.json fallback when needed. +- put: normalize and save the full proposed config into WorkflowStore. + +Config types: +- integration: publish/trigger template config. This is the default for backward compatibility. +- poller: background schedule/poller runtime config. +- syslog: Syslog listener runtime config. +- kafka: Kafka consumer runtime config. + +Important: +- This tool manages config reads/writes only. Non-config runtime commands such as API service publishing/unpublishing remain separate runtime operations. +- For sync and put, show the plan/diff to the user first and get confirmation before invoking the tool. +- Do not ask the user for backend API tokens or expose secrets in chat.""" + +DESCRIPTION_CN = """读取、对比、同步或写入工作流配置库。 + +当工作流 guide 要求查看或更新发布、触发或运行态配置时,优先使用本工具,不要读取 server_api_token/service_api_token,也不要手工 curl 本机后端接口。 + +动作: +- get:读取当前生效的集成配置和运行态摘要。 +- status:读取简要状态,不返回完整配置。 +- diff:将当前配置和候选配置做差异对比,不写入。 +- sync:确保配置库存在模板,必要时从 config.json 兜底迁移。 +- put:规范化后把完整候选配置写入 WorkflowStore。 + +配置类型: +- integration:发布/触发模板配置;默认值,用于兼容旧调用。 +- poller:后台定时/poller 运行态配置。 +- syslog:Syslog listener 运行态配置。 +- kafka:Kafka consumer 运行态配置。 + +注意:本工具只管理配置读写;发布/停止 API 服务等非配置运行态动作仍使用对应运行态接口。""" + + +def _workflow_routes(): + """Import workflow routes lazily to avoid loading server modules at registry import time.""" + from flocks.server.routes import workflow as workflow_routes + + return workflow_routes + + +def _json_lines(payload: Any) -> list[str]: + return json.dumps( + payload, + ensure_ascii=False, + sort_keys=True, + indent=2, + ).splitlines(keepends=True) + + +def _config_diff(current: Dict[str, Any], proposed: Dict[str, Any]) -> str: + return "".join( + difflib.unified_diff( + _json_lines(current), + _json_lines(proposed), + fromfile="current_config", + tofile="proposed_config", + lineterm="", + ) + ) + + +def _error_message(exc: Exception) -> str: + if isinstance(exc, HTTPException): + detail = exc.detail + if isinstance(detail, str): + return detail + return json.dumps(detail, ensure_ascii=False, default=str) + return str(exc) + + +async def _read_effective_config(workflow_id: str) -> Dict[str, Any]: + routes = _workflow_routes() + data = routes._read_workflow_from_fs(workflow_id) + if not data: + raise HTTPException(status_code=404, detail=f"Workflow not found: {workflow_id}") + + config_path = routes._workflow_config_dir(workflow_id, data) / "config.json" + runtime = await routes._build_workflow_integration_runtime(workflow_id, data) + + config = await routes._read_stored_workflow_integration_config(workflow_id) + if config is not None: + return { + "exists": True, + "path": str(config_path), + "storageKey": routes._workflow_integration_config_key(workflow_id), + "source": "storage", + "stored": True, + "config": config, + "runtime": runtime, + } + + config = await routes._read_file_workflow_integration_config(workflow_id, data, config_path) + if config is not None: + return { + "exists": True, + "path": str(config_path), + "storageKey": routes._workflow_integration_config_key(workflow_id), + "source": "file_fallback", + "stored": False, + "config": config, + "runtime": runtime, + } + + return { + "exists": False, + "path": str(config_path), + "storageKey": routes._workflow_integration_config_key(workflow_id), + "source": "generated", + "stored": False, + "config": await routes._build_workflow_integration_config(workflow_id, data), + "runtime": runtime, + } + + +def _runtime_storage_key(config_type: str, workflow_id: str) -> str: + return f"{_RUNTIME_CONFIG_KINDS[config_type]}/{workflow_id}" + + +async def _runtime_status(workflow_id: str, config_type: str) -> Dict[str, Any] | None: + try: + if config_type == "kafka": + from flocks.ingest.kafka.manager import default_manager as kafka_manager + + return kafka_manager.get_consumer_status(workflow_id) + if config_type == "poller": + from flocks.workflow.poller_manager import default_manager as poller_manager + + return poller_manager.get_status(workflow_id) + if config_type == "syslog": + from flocks.ingest.syslog.manager import default_manager as syslog_manager + + return syslog_manager.get_listener_status(workflow_id) + except Exception as exc: + return {"error": str(exc)} + return None + + +def _legacy_runtime_config_from_trigger(routes: Any, workflow_id: str, config_type: str, trigger: Any) -> Dict[str, Any]: + if config_type == "kafka": + return routes.kafka_trigger_to_legacy_config(workflow_id, trigger) + if config_type == "poller": + return routes.schedule_trigger_to_legacy_config(workflow_id, trigger) + if config_type == "syslog": + return routes.syslog_trigger_to_legacy_config(workflow_id, trigger) + raise HTTPException(status_code=422, detail=f"Unsupported runtime config type: {config_type}") + + +async def _read_runtime_config(workflow_id: str, config_type: str) -> Dict[str, Any]: + routes = _workflow_routes() + data = routes._read_workflow_from_fs(workflow_id) + if not data: + raise HTTPException(status_code=404, detail=f"Workflow not found: {workflow_id}") + + kind = _RUNTIME_CONFIG_KINDS[config_type] + config = await routes.WorkflowStore.get_config(workflow_id, kind=kind) + if config is not None: + return { + "exists": True, + "path": None, + "storageKey": _runtime_storage_key(config_type, workflow_id), + "source": "storage", + "stored": True, + "config": config, + "runtime": await _runtime_status(workflow_id, config_type), + } + + trigger_type = _RUNTIME_TRIGGER_TYPES[config_type] + triggers = await routes._get_workflow_trigger_defs(workflow_id, data) + trigger = next((item for item in triggers if item.type == trigger_type), None) + if trigger is not None: + return { + "exists": True, + "path": None, + "storageKey": _runtime_storage_key(config_type, workflow_id), + "source": "trigger_fallback", + "stored": False, + "config": _legacy_runtime_config_from_trigger(routes, workflow_id, config_type, trigger), + "runtime": await _runtime_status(workflow_id, config_type), + } + + return { + "exists": False, + "path": None, + "storageKey": _runtime_storage_key(config_type, workflow_id), + "source": "missing", + "stored": False, + "config": None, + "runtime": await _runtime_status(workflow_id, config_type), + } + + +async def _read_config(workflow_id: str, config_type: str) -> Dict[str, Any]: + if config_type == "integration": + return await _read_effective_config(workflow_id) + return await _read_runtime_config(workflow_id, config_type) + + +def _ensure_runtime_workflow_id(workflow_id: str, config: Dict[str, Any]) -> None: + candidate = config.get("workflowId") + if candidate not in (None, workflow_id): + raise HTTPException(status_code=409, detail="config.workflowId does not match workflow_id") + + +def _with_existing_updated_at(config: Dict[str, Any], current: Dict[str, Any] | None) -> Dict[str, Any]: + current_config = (current or {}).get("config") + if isinstance(current_config, dict) and current_config.get("updatedAt") is not None: + config["updatedAt"] = current_config["updatedAt"] + return config + + +def _without_updated_at(config: Dict[str, Any]) -> Dict[str, Any]: + payload = dict(config) + payload.pop("updatedAt", None) + return payload + + +def _runtime_config_matches_proposed(config: Any, proposed: Dict[str, Any]) -> bool: + if not isinstance(config, dict): + return False + return _without_updated_at(config) == _without_updated_at(proposed) + + +def _normalize_runtime_config( + workflow_id: str, + config_type: str, + config: Dict[str, Any], + current: Dict[str, Any] | None, +) -> Dict[str, Any]: + routes = _workflow_routes() + _ensure_runtime_workflow_id(workflow_id, config) + + if config_type == "kafka": + req = routes.KafkaConfigRequest.model_validate(config) + return _with_existing_updated_at( + { + "workflowId": workflow_id, + "enabled": req.enabled, + "inputBroker": req.inputBroker, + "inputTopic": req.inputTopic, + "inputGroupId": req.inputGroupId, + "inputKey": req.inputKey, + "autoOffsetReset": req.autoOffsetReset, + "inputs": routes._strip_execution_only_comments(req.inputs), + }, + current, + ) + + if config_type == "poller": + req = routes.WorkflowPollerConfigRequest.model_validate(config) + cron_expression = (req.cronExpression or "").strip() + return _with_existing_updated_at( + { + "workflowId": workflow_id, + "enabled": req.enabled, + "intervalSeconds": req.intervalSeconds, + "cronExpression": cron_expression or None, + "timeoutSeconds": req.timeoutSeconds, + "noOverlap": req.noOverlap, + "inputs": req.inputs, + }, + current, + ) + + if config_type == "syslog": + req = routes.SyslogConfigRequest.model_validate(config) + return _with_existing_updated_at( + { + "workflowId": workflow_id, + "enabled": req.enabled, + "protocol": req.protocol, + "host": req.host, + "port": req.port, + "format": req.msg_format, + "inputKey": req.input_key, + }, + current, + ) + + raise HTTPException(status_code=422, detail=f"Unsupported runtime config type: {config_type}") + + +async def _normalize_proposed_config( + workflow_id: str, + config_type: str, + config: Any, + current: Dict[str, Any] | None = None, +) -> Dict[str, Any]: + if config is None: + raise HTTPException(status_code=422, detail="config is required for action='diff' or action='put'") + if not isinstance(config, dict): + raise HTTPException(status_code=422, detail="config must be a JSON object") + + if config_type != "integration": + return _normalize_runtime_config(workflow_id, config_type, config, current) + + routes = _workflow_routes() + data = routes._read_workflow_from_fs(workflow_id) + if not data: + raise HTTPException(status_code=404, detail=f"Workflow not found: {workflow_id}") + return routes._normalize_workflow_integration_config_template(workflow_id, data, config) + + +def _status_payload(config_type: str, response: Dict[str, Any]) -> Dict[str, Any]: + config = response.get("config") + config = config if isinstance(config, dict) else {} + if config_type != "integration": + return { + "exists": response.get("exists"), + "source": response.get("source"), + "stored": response.get("stored"), + "storageKey": response.get("storageKey"), + "workflowId": config.get("workflowId"), + "configType": config_type, + "enabled": config.get("enabled"), + "runtime": response.get("runtime"), + } + + triggers = config.get("triggers") if isinstance(config.get("triggers"), list) else [] + publish = config.get("publish") if isinstance(config.get("publish"), dict) else {} + runtime = response.get("runtime") if isinstance(response.get("runtime"), dict) else {} + runtime_triggers = runtime.get("triggers") if isinstance(runtime.get("triggers"), list) else [] + + return { + "exists": response.get("exists"), + "source": response.get("source"), + "stored": response.get("stored"), + "path": response.get("path"), + "storageKey": response.get("storageKey"), + "workflow": config.get("workflow"), + "publish": publish, + "triggerCount": len(triggers), + "triggerTypes": [item.get("type") for item in triggers if isinstance(item, dict)], + "runtime": { + "publish": runtime.get("publish"), + "triggerCount": len(runtime_triggers), + }, + } + + +async def _confirm_write(ctx: ToolContext, *, action: str, workflow_id: str, metadata: Dict[str, Any]) -> None: + await ctx.ask( + permission="workflow_config", + patterns=[workflow_id, action], + always=["*"], + metadata={ + "workflow_id": workflow_id, + "action": action, + **metadata, + }, + ) + + +async def _sync_runtime_config(workflow_id: str, config_type: str, current: Dict[str, Any]) -> Dict[str, Any]: + config = current.get("config") + if current.get("stored"): + return current + if not isinstance(config, dict): + raise HTTPException(status_code=404, detail=f"No {config_type} config exists for workflow: {workflow_id}") + + routes = _workflow_routes() + await routes.WorkflowStore.put_config(workflow_id, config, kind=_RUNTIME_CONFIG_KINDS[config_type]) + return await _read_runtime_config(workflow_id, config_type) + + +async def _save_runtime_config(workflow_id: str, config_type: str, config: Dict[str, Any]) -> Dict[str, Any]: + routes = _workflow_routes() + if config_type == "kafka": + req = routes.KafkaConfigRequest.model_validate(config) + return await routes.save_kafka_config(workflow_id, req) + if config_type == "poller": + req = routes.WorkflowPollerConfigRequest.model_validate(config) + return await routes.save_workflow_poller_config(workflow_id, req) + if config_type == "syslog": + req = routes.SyslogConfigRequest.model_validate(config) + return await routes.save_syslog_config(workflow_id, req) + raise HTTPException(status_code=422, detail=f"Unsupported runtime config type: {config_type}") + + +@ToolRegistry.register_function( + name="workflow_config_manage", + description=DESCRIPTION, + description_cn=DESCRIPTION_CN, + category=ToolCategory.SYSTEM, + parameters=[ + ToolParameter( + name="action", + type=ParameterType.STRING, + description="Operation to run: get, status, sync, diff, or put.", + required=True, + enum=sorted(_ACTIONS), + ), + ToolParameter( + name="workflow_id", + type=ParameterType.STRING, + description="Workflow id, for example stream_alert_denoise or stream_alert_triage.", + required=True, + ), + ToolParameter( + name="config_type", + type=ParameterType.STRING, + description="Config type to manage: integration, poller, syslog, or kafka. Defaults to integration.", + required=False, + enum=sorted(_CONFIG_TYPES), + ), + ToolParameter( + name="config", + type=ParameterType.OBJECT, + description="Full proposed workflow config. Required for action='diff' and action='put'.", + required=False, + json_schema={ + "type": "object", + "additionalProperties": True, + }, + ), + ], + tags=["workflow", "config", "integration", "trigger", "syslog", "kafka", "poller", "publish"], +) +async def workflow_config_manage( + ctx: ToolContext, + action: str, + workflow_id: str, + config_type: str = "integration", + config: Dict[str, Any] | None = None, +) -> ToolResult: + normalized_action = str(action or "").strip().lower() + normalized_workflow_id = str(workflow_id or "").strip() + normalized_config_type = str(config_type or "integration").strip().lower() + title = f"Workflow config: {normalized_workflow_id or workflow_id} ({normalized_config_type})" + + if normalized_action not in _ACTIONS: + return ToolResult( + success=False, + error=f"Unsupported action: {action!r}. Expected one of: {', '.join(sorted(_ACTIONS))}.", + title=title, + ) + if not normalized_workflow_id: + return ToolResult(success=False, error="workflow_id is required", title=title) + if normalized_config_type not in _CONFIG_TYPES: + return ToolResult( + success=False, + error=f"Unsupported config_type: {config_type!r}. Expected one of: {', '.join(sorted(_CONFIG_TYPES))}.", + title=title, + ) + + try: + if normalized_action == "get": + response = await _read_config(normalized_workflow_id, normalized_config_type) + response["configType"] = normalized_config_type + return ToolResult(success=True, output=response, title=title) + + if normalized_action == "status": + response = await _read_config(normalized_workflow_id, normalized_config_type) + return ToolResult(success=True, output=_status_payload(normalized_config_type, response), title=title) + + if normalized_action == "diff": + current = await _read_config(normalized_workflow_id, normalized_config_type) + proposed = await _normalize_proposed_config( + normalized_workflow_id, + normalized_config_type, + config, + current, + ) + current_config = current.get("config") if isinstance(current.get("config"), dict) else {} + diff = _config_diff(current_config, proposed) + return ToolResult( + success=True, + title=title, + output={ + "workflowId": normalized_workflow_id, + "configType": normalized_config_type, + "changed": bool(diff), + "source": current.get("source"), + "storageKey": current.get("storageKey"), + "diff": diff, + "current": current_config, + "proposed": proposed, + }, + ) + + routes = _workflow_routes() + + if normalized_action == "sync": + current = await _read_config(normalized_workflow_id, normalized_config_type) + await _confirm_write( + ctx, + action=normalized_action, + workflow_id=normalized_workflow_id, + metadata={ + "config_type": normalized_config_type, + "storage_key": current.get("storageKey"), + "note": f"ensure {normalized_config_type} config exists in WorkflowStore", + }, + ) + if normalized_config_type == "integration": + response = await routes.sync_workflow_config(normalized_workflow_id) + else: + response = await _sync_runtime_config(normalized_workflow_id, normalized_config_type, current) + response["configType"] = normalized_config_type + return ToolResult(success=True, output=response, title=title) + + if normalized_action == "put": + current = await _read_config(normalized_workflow_id, normalized_config_type) + proposed = await _normalize_proposed_config( + normalized_workflow_id, + normalized_config_type, + config, + current, + ) + current_config = current.get("config") if isinstance(current.get("config"), dict) else {} + diff = _config_diff(current_config, proposed) + if normalized_config_type != "integration" and not diff: + output = { + **current, + "workflowId": normalized_workflow_id, + "configType": normalized_config_type, + "changed": False, + "applied": False, + "runtimeFailed": False, + "saveResult": { + "ok": True, + "skipped": True, + "reason": "no_changes", + }, + "diff": diff, + } + return ToolResult(success=True, output=output, title=title) + + await _confirm_write( + ctx, + action=normalized_action, + workflow_id=normalized_workflow_id, + metadata={ + "config_type": normalized_config_type, + "storage_key": current.get("storageKey"), + "changed": bool(diff), + "diff": diff, + }, + ) + if normalized_config_type == "integration": + response = await routes.update_workflow_config(normalized_workflow_id, proposed) + output = {**response, "configType": normalized_config_type, "diff": diff} + else: + try: + save_response = await _save_runtime_config(normalized_workflow_id, normalized_config_type, proposed) + except HTTPException as exc: + response = await _read_runtime_config(normalized_workflow_id, normalized_config_type) + if _runtime_config_matches_proposed(response.get("config"), proposed): + output = { + **response, + "workflowId": normalized_workflow_id, + "configType": normalized_config_type, + "changed": bool(diff), + "applied": True, + "runtimeFailed": True, + "runtimeError": _error_message(exc), + "saveResult": { + "ok": False, + "error": _error_message(exc), + "status_code": exc.status_code, + }, + "diff": diff, + } + return ToolResult(success=True, output=output, title=title) + raise + response = await _read_runtime_config(normalized_workflow_id, normalized_config_type) + output = { + **response, + "workflowId": normalized_workflow_id, + "configType": normalized_config_type, + "changed": bool(diff), + "applied": True, + "runtimeFailed": False, + "saveResult": save_response, + "diff": diff, + } + return ToolResult(success=True, output=output, title=title) + + return ToolResult(success=False, error=f"Unsupported action: {action!r}", title=title) + except HTTPException as exc: + return ToolResult( + success=False, + error=_error_message(exc), + title=title, + metadata={"status_code": exc.status_code}, + ) + except Exception as exc: + log.error( + "workflow_config_manage.failed", + { + "action": normalized_action, + "workflow_id": normalized_workflow_id, + "config_type": normalized_config_type, + "error": str(exc), + }, + ) + return ToolResult(success=False, error=_error_message(exc), title=title) diff --git a/flocks/updater/__init__.py b/flocks/updater/__init__.py index ddf14df7a..0ba8cc329 100644 --- a/flocks/updater/__init__.py +++ b/flocks/updater/__init__.py @@ -13,6 +13,7 @@ check_update, get_current_version, get_latest_release, + perform_pro_bundle_downgrade, perform_update, perform_pro_bundle_install, ) @@ -29,4 +30,5 @@ "get_latest_release", "perform_update", "perform_pro_bundle_install", + "perform_pro_bundle_downgrade", ] diff --git a/flocks/updater/models.py b/flocks/updater/models.py index 7f9336632..67f8d669f 100644 --- a/flocks/updater/models.py +++ b/flocks/updater/models.py @@ -8,10 +8,13 @@ from flocks.updater.deploy import DeployMode UpdateStage = Literal[ + "checking", + "reporting", "fetching", "backing_up", "applying", "syncing", + "downgrading", "restarting", "done", "error", diff --git a/flocks/updater/restart_handoff.py b/flocks/updater/restart_handoff.py index f6350b400..0d429a4db 100644 --- a/flocks/updater/restart_handoff.py +++ b/flocks/updater/restart_handoff.py @@ -23,14 +23,10 @@ DEFAULT_PARENT_TIMEOUT_SECONDS = 20.0 DEFAULT_PORT_TIMEOUT_SECONDS = 10.0 POST_STOP_PORT_TIMEOUT_SECONDS = 20.0 +SUPERVISOR_STOP_TIMEOUT_SECONDS = 20.0 DEFAULT_POLL_INTERVAL_SECONDS = 0.25 -class _NullConsole: - def print(self, *args, **kwargs) -> None: - return None - - def _record_handoff_log(message: str) -> None: append_upgrade_text_log(f"restart_handoff {message}") @@ -68,50 +64,37 @@ def _wait_for_backend_port_free( return not _backend_port_in_use(port) -def _ensure_backend_port_free(backend_port: int, backend_pid_file: Path) -> bool: +def _ensure_backend_port_free(backend_port: int) -> bool: if _wait_for_backend_port_free(backend_port): return True - _record_handoff_log(f"backend_port_still_in_use port={backend_port}; stopping backend") - try: - service_manager.stop_one(backend_port, backend_pid_file, "backend", _NullConsole()) - except Exception as exc: - _record_handoff_log(f"backend_stop_failed port={backend_port} error={exc}") - return False - + _record_handoff_log(f"backend_port_still_in_use port={backend_port}") return _wait_for_backend_port_free(backend_port, timeout_seconds=POST_STOP_PORT_TIMEOUT_SECONDS) -def _cli_subcommand(argv: Sequence[str]) -> str | None: - for index, value in enumerate(argv[:-2]): - if value == "-m" and argv[index + 1] == "flocks.cli.main": - return argv[index + 2] - return None - - -def _record_backend_runtime_if_direct_serve( - process: subprocess.Popen, - restart_argv: Sequence[str], +def _stop_supervisor_before_restart( *, - backend_host: str, - backend_port: int, - backend_pid_file: Path, -) -> None: - if _cli_subcommand(restart_argv) != "serve": - return + timeout_seconds: float = SUPERVISOR_STOP_TIMEOUT_SECONDS, + poll_interval_seconds: float = DEFAULT_POLL_INTERVAL_SECONDS, +) -> bool: + from flocks.cli import service_control + + paths = service_manager.runtime_paths() + if not service_control.supervisor_is_running(paths): + return True try: - service_manager.write_runtime_record( - backend_pid_file, - service_manager.process_runtime_record( - process, - host=backend_host, - port=backend_port, - command=restart_argv, - ), - ) + service_control.request_stop(paths=paths, timeout=timeout_seconds) except Exception as exc: - _record_handoff_log(f"backend_runtime_record_failed error={exc}") + _record_handoff_log(f"supervisor_stop_request_failed error={exc}") + return False + + deadline = time.monotonic() + timeout_seconds + while time.monotonic() < deadline: + if not service_control.supervisor_is_running(paths): + return True + time.sleep(poll_interval_seconds) + return not service_control.supervisor_is_running(paths) def _parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: @@ -121,7 +104,7 @@ def _parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: parser.add_argument("--backend-port", type=int, required=True) parser.add_argument("--frontend-host", required=True) parser.add_argument("--frontend-port", type=int, required=True) - parser.add_argument("--backend-pid-file", required=True) + parser.add_argument("--backend-pid-file") parser.add_argument("--install-root", required=True) parser.add_argument("--uv-path", required=True) parser.add_argument("--sync-timeout", type=int, required=True) @@ -134,6 +117,7 @@ def _parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: parser.add_argument("--pro-bundle-manifest-path") parser.add_argument("--bundle-sha256") parser.add_argument("--cleanup-dir") + parser.add_argument("--prepare-handover", action="store_true") parser.add_argument("restart_argv", nargs=argparse.REMAINDER) args = parser.parse_args(argv) if args.restart_argv and args.restart_argv[0] == "--": @@ -161,6 +145,22 @@ def _run_upgrade_tasks(args: argparse.Namespace) -> str | None: ) +def _report_pending_pro_bundle_install_receipt(args: argparse.Namespace) -> None: + if not args.pro_bundle_manifest_path: + return + try: + from flocks.console.login import ConsoleLoginService + + reported = asyncio.run(ConsoleLoginService.report_pending_pro_bundle_install_receipt()) + except Exception as exc: + _record_handoff_log(f"install_receipt_report_failed error={exc}") + return + if reported: + _record_handoff_log("install_receipt_reported") + else: + _record_handoff_log("install_receipt_report_skipped") + + def _rollback_failed_upgrade(args: argparse.Namespace, error: str) -> None: from flocks.updater import updater @@ -176,15 +176,67 @@ def _rollback_failed_upgrade(args: argparse.Namespace, error: str) -> None: _record_handoff_log(f"rollback_failed error={exc}") +def _prepare_upgrade_handover(args: argparse.Namespace) -> bool: + from flocks.updater import updater + + try: + updater._prepare_upgrade_handover(args.version) + except Exception as exc: + _record_handoff_log(f"prepare_handover_failed error={exc}") + return False + return True + + +def _rollback_upgrade_handover() -> None: + from flocks.updater import updater + + try: + updater.rollback_upgrade_handover() + except Exception as exc: + _record_handoff_log(f"handover_rollback_failed error={exc}") + + def _cleanup_dir(path_value: str | None) -> None: if not path_value: return shutil.rmtree(Path(path_value), ignore_errors=True) +def _cli_subcommand(argv: Sequence[str]) -> str | None: + """Return the flocks.cli.main subcommand embedded in a Python argv.""" + for index, value in enumerate(argv[:-2]): + if value == "-m" and argv[index + 1] == "flocks.cli.main": + return argv[index + 2] + return None + + +def _restart_argv_for_current_runtime(args: argparse.Namespace, restart_argv: Sequence[str]) -> list[str]: + if _cli_subcommand(restart_argv) != "serve": + return list(restart_argv) + + argv = [ + restart_argv[0], + "-m", + "flocks.cli.main", + "start", + "--no-browser", + "--skip-webui-build", + "--host", + str(args.frontend_host), + "--port", + str(args.frontend_port), + "--server-host", + str(args.backend_host), + "--server-port", + str(args.backend_port), + ] + _record_handoff_log(f"legacy_serve_restart_migrated argv={argv}") + return argv + + def run(argv: Sequence[str] | None = None) -> int: args = _parse_args(argv) - restart_argv = list(args.restart_argv) + restart_argv = _restart_argv_for_current_runtime(args, args.restart_argv) if not restart_argv: _record_handoff_log("missing_restart_argv") return 2 @@ -200,8 +252,11 @@ def run(argv: Sequence[str] | None = None) -> int: _cleanup_dir(args.cleanup_dir) return 1 - backend_pid_file = Path(args.backend_pid_file) - if not _ensure_backend_port_free(args.backend_port, backend_pid_file): + if args.prepare_handover: + if not _prepare_upgrade_handover(args): + _cleanup_dir(args.cleanup_dir) + return 1 + elif not _ensure_backend_port_free(args.backend_port): _record_handoff_log(f"backend_port_unavailable port={args.backend_port}") _cleanup_dir(args.cleanup_dir) return 1 @@ -214,6 +269,14 @@ def run(argv: Sequence[str] | None = None) -> int: _rollback_failed_upgrade(args, task_error) _cleanup_dir(args.cleanup_dir) return 1 + _report_pending_pro_bundle_install_receipt(args) + + if not _stop_supervisor_before_restart(): + _record_handoff_log("supervisor_stop_timeout") + if args.prepare_handover: + _rollback_upgrade_handover() + _cleanup_dir(args.cleanup_dir) + return 1 try: process = subprocess.Popen( @@ -223,16 +286,11 @@ def run(argv: Sequence[str] | None = None) -> int: ) except OSError as exc: _record_handoff_log(f"restart_spawn_failed error={exc}") + if args.prepare_handover: + _rollback_upgrade_handover() _cleanup_dir(args.cleanup_dir) return 1 - _record_backend_runtime_if_direct_serve( - process, - restart_argv, - backend_host=args.backend_host, - backend_port=args.backend_port, - backend_pid_file=backend_pid_file, - ) _record_handoff_log(f"restart_spawned pid={process.pid}") _cleanup_dir(args.cleanup_dir) return 0 diff --git a/flocks/updater/updater.py b/flocks/updater/updater.py index d076af229..3ebdf7791 100644 --- a/flocks/updater/updater.py +++ b/flocks/updater/updater.py @@ -31,7 +31,7 @@ from datetime import datetime, timezone from pathlib import Path, PureWindowsPath from typing import Any, AsyncGenerator, Awaitable, Callable -from urllib.parse import quote, urlparse +from urllib.parse import quote, urlencode, urlparse import httpx @@ -199,17 +199,6 @@ def _looks_like_windows_python_launcher(entry: str) -> bool: return _windows_path_stem(entry) in {"python", "pythonw", "py"} -def _is_windows_file_in_use_error(exc: BaseException) -> bool: - """Return True when *exc* looks like a Windows file-lock failure.""" - if sys.platform != "win32": - return False - if isinstance(exc, OSError) and getattr(exc, "winerror", None) == 32: - return True - - text = str(exc).lower() - return "winerror 32" in text or "used by another process" in text - - def _is_uv_managed_python_runtime_error(text: str) -> bool: """Return True when uv reports a broken managed Python runtime cache.""" if not text: @@ -1082,31 +1071,15 @@ def _archive_format_for_url(url: str, manifest_format: str | None = None) -> str return "tar.gz" -def _console_manifest_display_version(data: dict[str, Any]) -> str: - display_version = str(data.get("display_version") or data.get("version") or data.get("latest_version") or "").strip() - if display_version: - return display_version - core_version = str(data.get("core_version") or "").strip() - if core_version: - return core_version - oss_version = str(data.get("oss_version") or "").strip() - if oss_version: - return oss_version - compare_version = str(data.get("compare_version") or "").strip() - return f"v{compare_version}" if compare_version and not compare_version.startswith(("v", "V")) else compare_version +def _console_manifest_bundle_version(data: dict[str, Any]) -> str: + return str(data.get("bundle_version") or "").strip() def _pro_bundle_core_version(data: dict[str, Any]) -> str: - core_version = str(data.get("core_version") or "").strip() - if core_version: - return core_version - oss_version = str(data.get("oss_version") or "").strip() - if oss_version: - return oss_version - return _console_manifest_display_version(data) + return str(data.get("core_version") or "").strip() -def _pro_bundle_oss_version(data: dict[str, Any]) -> str: +def _pro_bundle_core_version_for_compare(data: dict[str, Any]) -> str: return _pro_bundle_core_version(data) @@ -1117,25 +1090,44 @@ def _version_label(version: str | None) -> str: return normalized if normalized.startswith(("v", "V")) else f"v{normalized}" -def _is_pro_bundle_oss_older_than_local(manifest: dict[str, Any], current_version: str | None = None) -> bool: - bundle_oss_version = _pro_bundle_oss_version(manifest) - if not bundle_oss_version: +def _is_pro_bundle_core_older_than_local(manifest: dict[str, Any], current_version: str | None = None) -> bool: + bundle_core_version = _pro_bundle_core_version_for_compare(manifest) + if not bundle_core_version: return False local_version = str(current_version or get_current_version() or "").strip() if not local_version: return False - return _parse_version(bundle_oss_version) < _parse_version(local_version) + return _parse_version(bundle_core_version) < _parse_version(local_version) -def _effective_pro_bundle_manifest(manifest: dict[str, Any], effective_oss_version: str) -> dict[str, Any]: +def _effective_pro_bundle_manifest(manifest: dict[str, Any], effective_core_version: str) -> dict[str, Any]: payload = dict(manifest) - effective_label = _version_label(effective_oss_version) + effective_label = _version_label(effective_core_version) if effective_label: payload["core_version"] = effective_label - payload["oss_version"] = effective_label return payload +def _required_pro_bundle_marker_value(manifest: dict[str, Any], key: str) -> str: + value = str(manifest.get(key) or "").strip() + if not value: + raise ValueError(f"Pro bundle manifest missing required {key}") + return value + + +def _validate_pro_bundle_marker_manifest(manifest: dict[str, Any]) -> None: + for key in ("bundle_version", "core_version", "flockspro_component_version"): + _required_pro_bundle_marker_value(manifest, key) + + +def _pro_bundle_core_version_for_marker(manifest: dict[str, Any]) -> str: + return _required_pro_bundle_marker_value(manifest, "core_version") + + +def _pro_bundle_core_version_or_empty(manifest: dict[str, Any]) -> str: + return str(manifest.get("core_version") or "").strip() + + def _archive_filename_for_format(latest_tag: str, fmt: str) -> str: return f"flocks-{latest_tag}.{'zip' if fmt == 'zip' else 'tar.gz'}" @@ -1222,6 +1214,17 @@ async def _fetch_gitlab_release( ) +def _read_local_pro_license_id() -> str: + license_path = _flocks_root() / "flockspro" / "license.json" + try: + payload = json.loads(license_path.read_text(encoding="utf-8")) + except Exception: + return "" + if not isinstance(payload, dict): + return "" + return str(payload.get("license_id") or "").strip() + + async def _load_console_session_token() -> str | None: def _token_from_payload(payload: Any) -> str | None: if not isinstance(payload, dict): @@ -1273,8 +1276,14 @@ async def _fetch_console_manifest_release_info(console_session_token: str | None raise ValueError("FLOCKS_CONSOLE_BASE_URL 未配置,无法使用 console-manifest 源") channel = (os.getenv("FLOCKS_UPDATE_CHANNEL") or "flockspro").strip() or "flockspro" - url = f"{manifest_base}/v1/manifest/latest?channel={channel}" + license_id = (os.getenv("FLOCKSPRO_LICENSE_ID") or _read_local_pro_license_id()).strip() + query = {"channel": channel} + if license_id: + query["license_id"] = license_id + url = f"{manifest_base}/v1/manifest/latest?{urlencode(query)}" headers: dict[str, str] = {} + if license_id: + headers["x-license-id"] = license_id token = str(console_session_token or "").strip() or await _load_console_session_token() if token: headers["Authorization"] = f"Bearer {token}" @@ -1297,9 +1306,9 @@ async def _fetch_console_manifest_release_info(console_session_token: str | None if datetime.now(timezone.utc) < frozen_until: raise ValueError("console manifest channel frozen_until not reached") - latest = _console_manifest_display_version(data) + latest = _console_manifest_bundle_version(data) if not latest: - raise ValueError("manifest 响应缺少 compare_version/display_version") + raise ValueError("manifest 响应缺少 bundle_version") bundle_url = str( data.get("bundle_url") or data.get("url") @@ -1848,13 +1857,12 @@ def _merge_console_manifest_release_identity( merged["release_id"] = release_id if bundle_release_id and not merged.get("bundle_release_id"): merged["bundle_release_id"] = bundle_release_id - for key in ("display_version", "version", "latest_version", "compare_version", "flockspro_component_version", "build_id"): + for key in ("bundle_version", "compare_version", "flockspro_component_version", "build_id"): if console_manifest.get(key): merged[key] = console_manifest.get(key) - core_version = console_manifest.get("core_version") or console_manifest.get("oss_version") or merged.get("core_version") or merged.get("oss_version") + core_version = console_manifest.get("core_version") or merged.get("core_version") if core_version: merged["core_version"] = core_version - merged["oss_version"] = core_version return merged @@ -1863,22 +1871,104 @@ def _write_pro_bundle_install_marker(manifest: dict[str, Any], *, bundle_sha256: marker.parent.mkdir(parents=True, exist_ok=True) release_id = manifest.get("release_id") or manifest.get("bundle_release_id") bundle_release_id = manifest.get("bundle_release_id") or manifest.get("release_id") - display_version = _console_manifest_display_version(manifest) - core_version = manifest.get("core_version") or manifest.get("oss_version") + bundle_version = _required_pro_bundle_marker_value(manifest, "bundle_version") + core_version = _required_pro_bundle_marker_value(manifest, "core_version") + pro_component_version = _required_pro_bundle_marker_value(manifest, "flockspro_component_version") payload = { "release_id": release_id, "bundle_release_id": bundle_release_id, - "bundle_version": display_version, - "display_version": display_version, - "installed_version": display_version, + "bundle_version": bundle_version, "core_version": core_version, - "oss_version": core_version, - "flockspro_component_version": manifest.get("flockspro_component_version"), + "flockspro_component_version": pro_component_version, "build_id": manifest.get("build_id"), "bundle_sha256": bundle_sha256 or manifest.get("bundle_sha256"), "installed_at": datetime.now(timezone.utc).isoformat(), } marker.write_text(json.dumps(payload, ensure_ascii=True, sort_keys=True), encoding="utf-8") + _write_pending_pro_bundle_install_receipt(payload) + + +def _write_pending_pro_bundle_install_receipt(marker_payload: dict[str, Any]) -> None: + receipt_path = _pending_pro_bundle_install_receipt_path() + receipt_path.parent.mkdir(parents=True, exist_ok=True) + bundle_version = str( + marker_payload.get("bundle_version") + or "" + ).strip() + core_version = str(marker_payload.get("core_version") or "").strip() + pro_component_version = str(marker_payload.get("flockspro_component_version") or "").strip() + receipt = { + "release_id": marker_payload.get("release_id") or marker_payload.get("bundle_release_id"), + "bundle_release_id": marker_payload.get("bundle_release_id") or marker_payload.get("release_id"), + "license_id": _read_local_pro_license_id() or None, + "bundle_version": bundle_version, + "core_version": core_version, + "flockspro_component_version": pro_component_version, + "build_id": marker_payload.get("build_id"), + "install_result": "success", + "reported_at": datetime.now(timezone.utc).isoformat(), + } + receipt_path.write_text(json.dumps(receipt, ensure_ascii=True, sort_keys=True), encoding="utf-8") + try: + os.chmod(receipt_path, 0o600) + except OSError: + pass + + +def _pending_pro_bundle_install_receipt_path() -> Path: + return _flocks_root() / "run" / "pro-bundle-install-receipt-pending.json" + + +def _pro_bundle_install_marker_path() -> Path: + return _flocks_root() / "run" / "pro-bundle-installed.json" + + +def _archive_json_marker(path: Path, archive_name: str, reason: str | None = None) -> None: + if not path.exists(): + return + archive_dir = path.parent / "archive" + archive_dir.mkdir(parents=True, exist_ok=True) + suffix = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S") + archived = archive_dir / f"{archive_name}-{suffix}.json" + try: + payload = json.loads(path.read_text(encoding="utf-8")) + if isinstance(payload, dict): + payload["archived_at"] = datetime.now(timezone.utc).isoformat() + payload["archive_reason"] = reason or "downgraded_to_oss" + archived.write_text(json.dumps(payload, ensure_ascii=True, sort_keys=True), encoding="utf-8") + else: + shutil.copy2(path, archived) + except Exception: + shutil.copy2(path, archived) + path.unlink(missing_ok=True) + + +def _archive_pro_bundle_install_marker(reason: str | None = None) -> None: + _archive_json_marker(_pro_bundle_install_marker_path(), "pro-bundle-installed", reason) + + +def _archive_pending_pro_bundle_install_receipt(reason: str | None = None) -> None: + _archive_json_marker( + _pending_pro_bundle_install_receipt_path(), + "pro-bundle-install-receipt-pending", + reason, + ) + + +async def _uninstall_pro_component( + *, + uv_path: str, + install_root: Path, + env: dict[str, str] | None = None, +) -> str | None: + python_path = _venv_python_path(install_root) + if not python_path.exists(): + return f"Python environment may need manual repair: missing {python_path}" + cmd = [uv_path, "pip", "uninstall", "--python", str(python_path), "flockspro"] + code, _, err = await _run_async(cmd, cwd=install_root, timeout=180, env=env) + if code != 0: + return f"Flocks Pro component uninstall failed: {err}" + return None class _NullConsole: @@ -1888,13 +1978,15 @@ def print(self, *args, **kwargs) -> None: def _current_service_config(): from flocks.cli import service_manager + from flocks.cli.service_config import service_config_from_status_payload + from flocks.cli.service_control import read_supervisor_status - paths = service_manager.ensure_runtime_dirs() - return service_manager.ServiceConfig( - backend_host=service_manager._recorded_host(paths.backend_pid, service_manager.ServiceConfig.backend_host), - backend_port=service_manager._recorded_port(paths.backend_pid, service_manager.ServiceConfig.backend_port), - frontend_host=service_manager._recorded_host(paths.frontend_pid, service_manager.ServiceConfig.frontend_host), - frontend_port=service_manager._recorded_port(paths.frontend_pid, service_manager.ServiceConfig.frontend_port), + try: + status = read_supervisor_status(paths=service_manager.runtime_paths(), timeout=1.0) + except Exception as exc: + raise RuntimeError("Supervisor control API is unavailable; cannot perform managed upgrade restart.") from exc + return service_config_from_status_payload( + status.raw, no_browser=True, skip_frontend_build=True, ) @@ -2035,7 +2127,11 @@ def _stop_upgrade_page_server(*, frontend_port: int | None = None) -> None: from flocks.cli import service_manager - remaining = service_manager.port_owner_pids(frontend_port) + remaining = [ + pid + for pid in service_manager.port_owner_pids(frontend_port) + if _looks_like_upgrade_page_process(pid) + ] if remaining: log.info( "updater.upgrade_page.port_fallback_kill", @@ -2057,7 +2153,7 @@ def _stop_upgrade_page_server(*, frontend_port: int | None = None) -> None: wait_attempts = 40 wait_interval = 0.25 for _ in range(wait_attempts): - if not service_manager.port_owner_pids(frontend_port): + if not any(_looks_like_upgrade_page_process(pid) for pid in service_manager.port_owner_pids(frontend_port)): return time.sleep(wait_interval) return @@ -2066,8 +2162,23 @@ def _stop_upgrade_page_server(*, frontend_port: int | None = None) -> None: time.sleep(0.3) +def _looks_like_upgrade_page_process(pid: int) -> bool: + """Return True only for the temporary upgrade-page http.server process.""" + try: + from flocks.cli import service_manager + + command_line = service_manager._process_command_line(pid).lower() + except Exception: + return False + if not command_line: + return False + page_dir = str(_upgrade_page_dir()).lower() + return "http.server" in command_line and "upgrade-page" in command_line and page_dir in command_line + + def _prepare_upgrade_handover(version: str) -> dict[str, Any]: from flocks.cli import service_manager + from flocks.cli.service_control import request_prepare_upgrade config = _current_service_config() payload: dict[str, Any] = { @@ -2082,9 +2193,8 @@ def _prepare_upgrade_handover(version: str) -> dict[str, Any]: _persist_upgrade_state(payload, last_error=None) console = _NullConsole() - paths = service_manager.ensure_runtime_dirs() - frontend_port = service_manager._recorded_port(paths.frontend_pid, config.frontend_port) - service_manager.stop_one(frontend_port, paths.frontend_pid, "WebUI", console) + paths = service_manager.runtime_paths() + request_prepare_upgrade(paths=paths, timeout=30.0) try: payload.update(_start_upgrade_page_server(config, version)) @@ -2097,7 +2207,7 @@ def _prepare_upgrade_handover(version: str) -> dict[str, Any]: _stop_upgrade_page_server(frontend_port=config.frontend_port) _clear_upgrade_state() try: - service_manager.start_frontend(config, console) + _start_frontend_with_fallback(config, console, allow_build_fallback=False) except Exception as restart_error: log.error("updater.frontend.restore_failed", {"error": str(restart_error)}) raise @@ -2105,26 +2215,56 @@ def _prepare_upgrade_handover(version: str) -> dict[str, Any]: return payload +def _spawn_restart_handoff(command: list[str], *, cwd: Path) -> subprocess.Popen: + creationflags = 0 + kwargs: dict[str, object] = {} + if sys.platform == "win32": + creationflags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) | getattr(subprocess, "CREATE_NO_WINDOW", 0) + startupinfo_cls = getattr(subprocess, "STARTUPINFO", None) + if startupinfo_cls is not None: + startupinfo = startupinfo_cls() + startupinfo.dwFlags |= getattr(subprocess, "STARTF_USESHOWWINDOW", 0) + startupinfo.wShowWindow = getattr(subprocess, "SW_HIDE", 0) + kwargs["startupinfo"] = startupinfo + else: + kwargs["start_new_session"] = True + return subprocess.Popen(command, cwd=cwd, close_fds=True, creationflags=creationflags, **kwargs) + + def _service_config_from_payload( payload: dict[str, Any], *, skip_frontend_build: bool | None = None, ): - from flocks.cli import service_manager + from flocks.cli.service_config import ServiceConfig, service_config_from_payload resolved_skip_frontend_build = ( bool(payload.get("skip_frontend_build", True)) if skip_frontend_build is None else skip_frontend_build ) - return service_manager.ServiceConfig( - backend_host=str(payload.get("backend_host") or service_manager.ServiceConfig.backend_host), - backend_port=int(payload.get("backend_port") or service_manager.ServiceConfig.backend_port), - frontend_host=str(payload.get("frontend_host") or service_manager.ServiceConfig.frontend_host), - frontend_port=int(payload.get("frontend_port") or service_manager.ServiceConfig.frontend_port), + migrated_payload = dict(payload) + backend_port = migrated_payload.get("backend_port") + frontend_port = migrated_payload.get("frontend_port") + if isinstance(backend_port, int) and isinstance(frontend_port, int) and backend_port != frontend_port: + migrated_payload["legacy_backend_host"] = migrated_payload.get("backend_host") + migrated_payload["legacy_backend_port"] = backend_port + migrated_payload["backend_host"] = migrated_payload.get("frontend_host") or migrated_payload.get("backend_host") + migrated_payload["backend_port"] = frontend_port + migrated_payload["server_port_migration_hint"] = True + return service_config_from_payload( + migrated_payload, + default=ServiceConfig(), no_browser=True, skip_frontend_build=resolved_skip_frontend_build, ) +def _handoff_service_config(): + payload = _read_upgrade_state() + if payload is not None: + return _service_config_from_payload(payload, skip_frontend_build=True) + return _current_service_config() + + def _read_upgrade_server_pid() -> tuple[int | None, bool]: pid_path = _upgrade_server_pid_path() if not pid_path.exists(): @@ -2180,25 +2320,31 @@ def read_upgrade_runtime_state(frontend_port: int | None = None) -> dict[str, An } +def _webui_runtime_ready(state: str) -> bool: + return state in {"healthy", "static"} + + def _start_frontend_with_fallback(config, console, *, allow_build_fallback: bool) -> None: - from flocks.cli import service_manager + from flocks.cli.service_config import with_frontend_build + from flocks.cli.service_control import request_restart_webui, request_resume_upgrade try: - service_manager.start_frontend(config, console) + status = request_resume_upgrade( + config, + paths=None, + timeout=180.0, + ) + if not _webui_runtime_ready(status.webui.state): + raise RuntimeError(status.webui.last_error or "WebUI restart did not become healthy") return except Exception: if not allow_build_fallback or not config.skip_frontend_build: raise - rebuilt_config = service_manager.ServiceConfig( - backend_host=config.backend_host, - backend_port=config.backend_port, - frontend_host=config.frontend_host, - frontend_port=config.frontend_port, - no_browser=config.no_browser, - skip_frontend_build=False, - ) - service_manager.start_frontend(rebuilt_config, console) + rebuilt_config = with_frontend_build(config, skip_frontend_build=False) + result = request_restart_webui(rebuilt_config, force_frontend_build=True, paths=None, timeout=180.0) + if not _webui_runtime_ready(result.webui.state): + raise RuntimeError(result.webui.last_error or "WebUI restart did not become healthy") def cleanup_orphan_upgrade_state(*, frontend_port: int | None = None) -> bool: @@ -2531,35 +2677,13 @@ def _read_pro_bundle_install_marker() -> dict[str, Any]: return payload if isinstance(payload, dict) else {} -def _read_pro_bundle_installed_bundle_version() -> str: - payload = _read_pro_bundle_install_marker() - for key in ("bundle_version", "installed_version", "display_version"): - version = str(payload.get(key) or "").strip() - if version: - return version - return "" - - -def _read_pro_bundle_installed_core_version() -> str: - payload = _read_pro_bundle_install_marker() - for key in ("core_version", "oss_version"): - version = str(payload.get(key) or "").strip() - if version: - return version - return "" - - -def _read_pro_bundle_installed_component_version() -> str: - payload = _read_pro_bundle_install_marker() - return str(payload.get("flockspro_component_version") or "").strip() - - def _current_pro_version_state(local_core_version: str) -> ProVersionState: - core_version = _pick_newer_version(_read_pro_bundle_installed_core_version(), local_core_version) + marker = _read_pro_bundle_install_marker() + core_version = _pro_bundle_core_version_or_empty(marker) if marker else local_core_version return ProVersionState( - bundle_version=_version_label(_read_pro_bundle_installed_bundle_version()), + bundle_version=_version_label(str(marker.get("bundle_version") or "").strip()), core_version=_version_label(core_version), - pro_component_version=_read_pro_bundle_installed_component_version() or None, + pro_component_version=str(marker.get("flockspro_component_version") or "").strip() or None, ) @@ -2581,16 +2705,6 @@ def _is_newer_version(latest: str | None, current: str | None) -> bool: return _parse_version(latest_version) > _parse_version(current_version) -def _pick_newer_version(left: str | None, right: str | None) -> str: - left_version = str(left or "").strip() - right_version = str(right or "").strip() - if not left_version: - return right_version - if not right_version: - return left_version - return left_version if _parse_version(left_version) >= _parse_version(right_version) else right_version - - def get_current_version() -> str: """ Return the running version. @@ -2878,6 +2992,138 @@ async def perform_pro_bundle_install( yield progress +async def perform_pro_bundle_downgrade( + *, + restart: bool = True, + reason: str | None = None, + after_uninstall: Callable[[], Awaitable[None]] | None = None, +) -> AsyncGenerator[UpdateProgress, None]: + """Remove the local Pro component and return this installation to OSS runtime.""" + from flocks.updater.deploy import detect_deploy_mode + + if detect_deploy_mode() == "docker": + yield UpdateProgress( + stage="error", + message="Downgrading to OSS is not supported in Docker deployments. Please redeploy with the OSS image.", + success=False, + ) + return + + install_root = _get_repo_root() + current_version = get_current_version() + marker = _read_pro_bundle_install_marker() + component_installed = _is_pro_component_installed() + if not component_installed and not marker: + yield UpdateProgress(stage="done", message="Already running the OSS edition.", success=True) + return + try: + oss_core_version = _pro_bundle_core_version_for_marker(marker) if marker else current_version + except ValueError as exc: + yield UpdateProgress( + stage="error", + message=f"Downgrade failed: {exc}. Cannot safely report OSS core version without the install marker.", + success=False, + ) + return + + yield UpdateProgress(stage="checking", message="Checking local Pro installation...") + uv_path = _find_executable("uv") + if not uv_path: + yield UpdateProgress( + stage="error", + message="Downgrade failed: uv is required but was not found.", + success=False, + ) + return + + sync_env = _build_uv_sync_env() + yield UpdateProgress(stage="downgrading", message="Removing Flocks Pro component...") + uninstall_error = await _uninstall_pro_component(uv_path=uv_path, install_root=install_root, env=sync_env) + if uninstall_error is not None: + yield UpdateProgress(stage="error", message=uninstall_error, success=False) + return + + if after_uninstall is not None: + yield UpdateProgress(stage="reporting", message="Reporting OSS downgrade to Console...") + try: + await after_uninstall() + except Exception as exc: + log.warning("updater.downgrade.report_failed_pending_retry", {"error": str(exc)}) + + try: + if oss_core_version: + _write_version_marker(oss_core_version.lstrip("v")) + except Exception as exc: + yield UpdateProgress( + stage="error", + message=f"Flocks Pro component was removed, but version marker update failed: {exc}", + success=False, + ) + return + + try: + _archive_pending_pro_bundle_install_receipt(reason=reason or "downgraded_to_oss") + except Exception as exc: + yield UpdateProgress( + stage="error", + message=f"Flocks Pro component was removed, but pending install receipt cleanup failed: {exc}", + success=False, + ) + return + + try: + _archive_pro_bundle_install_marker(reason=reason or "downgraded_to_oss") + except Exception as exc: + yield UpdateProgress( + stage="error", + message=f"Flocks Pro component was removed, but install marker cleanup failed: {exc}", + success=False, + ) + return + + if not restart: + try: + _refresh_global_cli_entry(install_root) + except Exception as exc: + log.warning("updater.refresh_cli.failed", {"error": str(exc)}) + yield UpdateProgress(stage="done", message="Downgraded to OSS edition.", success=True) + return + + yield UpdateProgress(stage="restarting", message="Restarting service...") + await asyncio.sleep(0.8) + + if "--reload" in sys.argv: + log.info("updater.downgrade.reload_exit3") + sys.exit(3) + + try: + restart_argv = _build_restart_argv(install_root) + sync_timeout = _dependency_sync_timeout_seconds() + handoff_argv = _build_restart_handoff_argv( + restart_argv, + install_root, + uv_path=uv_path, + sync_timeout=sync_timeout, + version=oss_core_version or current_version, + current_version=current_version, + ) + log.info("updater.downgrade.restart_handoff_spawn", {"argv": handoff_argv}) + subprocess.Popen( + handoff_argv, + cwd=install_root, + close_fds=True, + ) + os._exit(0) + except Exception as exc: + log.error("updater.downgrade.restart_failed", {"error": str(exc)}) + yield UpdateProgress( + stage="error", + message=f"Failed to restart service after downgrade: {exc}", + success=False, + ) + return + + async def perform_update( latest_tag: str, *, @@ -2914,7 +3160,6 @@ async def perform_update( current_version = get_current_version() effective_update_version = current_version skip_core_replace = False - handover_active = False console_manifest_info: ConsoleManifestRelease | None = None console_manifest_payload = console_manifest_payload if isinstance(console_manifest_payload, dict) else None fmt = _choose_archive_format(ucfg.archive_format) @@ -3058,6 +3303,7 @@ async def _queue_download_progress(progress: UpdateProgress) -> None: pro_bundle_manifest, console_manifest_payload, ) + _validate_pro_bundle_marker_manifest(pro_bundle_manifest) if profile.sources == ["console-manifest"] and pro_wheel_path is None: raise ValueError("Pro bundle 中未找到 flockspro wheel") except Exception as exc: @@ -3069,16 +3315,16 @@ async def _queue_download_progress(progress: UpdateProgress) -> None: yield UpdateProgress(stage="error", message=msg, success=False) return if profile.sources == ["console-manifest"]: - skip_core_replace = _is_pro_bundle_oss_older_than_local(pro_bundle_manifest, current_version) + skip_core_replace = _is_pro_bundle_core_older_than_local(pro_bundle_manifest, current_version) if skip_core_replace: - bundle_oss_version = _pro_bundle_oss_version(pro_bundle_manifest) + bundle_core_version = _pro_bundle_core_version_for_compare(pro_bundle_manifest) pro_bundle_manifest = _effective_pro_bundle_manifest(pro_bundle_manifest, current_version) log.info( "updater.pro_bundle.keep_local_core", - {"local_version": current_version, "bundle_oss_version": bundle_oss_version}, + {"local_version": current_version, "bundle_core_version": bundle_core_version}, ) else: - effective_update_version = latest_tag + effective_update_version = _pro_bundle_core_version_for_marker(pro_bundle_manifest) else: effective_update_version = latest_tag @@ -3106,20 +3352,7 @@ async def _queue_download_progress(progress: UpdateProgress) -> None: ) async def _restore_after_apply_failure() -> None: - nonlocal handover_active if backup_path is None: - if handover_active: - await asyncio.to_thread(rollback_upgrade_handover) - handover_active = False - return - if handover_active: - await asyncio.to_thread( - _rollback_failed_update, - backup_path, - install_root, - current_version, - ) - handover_active = False return await asyncio.to_thread( _restore_backup_if_possible, @@ -3137,28 +3370,6 @@ async def _restore_after_apply_failure() -> None: ) except Exception as exc: final_replace_error: Exception | None = exc - if ( - sys.platform == "win32" - and restart - and needs_handover - and not handover_active - and _is_windows_file_in_use_error(exc) - ): - log.warning("updater.replace.locked_retry_with_handover", {"error": str(exc)}) - try: - _prepare_upgrade_handover(latest_tag) - handover_active = True - if not skip_core_replace: - await asyncio.to_thread( - _replace_install_dir, - content_root, - install_root, - ) - except Exception as retry_exc: - final_replace_error = retry_exc - else: - final_replace_error = None - if final_replace_error is not None: shutil.rmtree(tmp_dir, ignore_errors=True) await _restore_after_apply_failure() @@ -3255,12 +3466,6 @@ async def _restore_after_apply_failure() -> None: restart_argv = _build_restart_argv(install_root) except Exception as exc: log.error("updater.restart.build_argv_failed", {"error": str(exc)}) - if handover_active: - try: - rollback_upgrade_handover() - except Exception: - pass - handover_active = False yield UpdateProgress( stage="error", message=f"Failed to build restart command: {exc}", @@ -3268,20 +3473,6 @@ async def _restore_after_apply_failure() -> None: ) return - if needs_handover and not handover_active: - try: - _prepare_upgrade_handover(latest_tag) - handover_active = True - except Exception as exc: - log.error("updater.handover.failed", {"error": str(exc)}) - await _restore_after_apply_failure() - yield UpdateProgress( - stage="error", - message=f"Failed to prepare WebUI handover: {exc}", - success=False, - ) - return - try: handoff_argv = _build_restart_handoff_argv( restart_argv, @@ -3297,6 +3488,7 @@ async def _restore_after_apply_failure() -> None: pro_bundle_manifest_path=pro_bundle_manifest_path, bundle_sha256=bundle_sha256, cleanup_dir=tmp_dir, + prepare_handover=needs_handover, ) log.info( "updater.restart.handoff_spawn", @@ -3305,21 +3497,11 @@ async def _restore_after_apply_failure() -> None: "restart_argv": restart_argv, }, ) - subprocess.Popen( - handoff_argv, - cwd=install_root, - close_fds=True, - ) + _spawn_restart_handoff(handoff_argv, cwd=install_root) os._exit(0) except Exception as exc: log.error("updater.restart.handoff_spawn_failed", {"error": str(exc)}) shutil.rmtree(tmp_dir, ignore_errors=True) - if handover_active: - try: - rollback_upgrade_handover() - except Exception: - pass - handover_active = False yield UpdateProgress( stage="error", message=f"Failed to restart service: {exc}", @@ -3440,15 +3622,29 @@ def _build_restart_handoff_argv( pro_bundle_manifest_path: Path | None = None, bundle_sha256: str | None = None, cleanup_dir: Path | None = None, + prepare_handover: bool = False, ) -> list[str]: """Wrap the real restart command in a helper that finishes upgrade work.""" - from flocks.cli import service_manager - if not restart_argv: raise ValueError("restart command is empty") - config = _current_service_config() - paths = service_manager.ensure_runtime_dirs() + config = _handoff_service_config() + managed_restart_argv = [ + restart_argv[0], + "-m", + "flocks.cli.main", + "start", + "--no-browser", + "--skip-webui-build", + "--host", + str(config.backend_host), + "--port", + str(config.backend_port), + ] + if config.legacy_backend_host is not None: + managed_restart_argv.extend(["--server-host", str(config.legacy_backend_host)]) + if config.legacy_backend_port is not None: + managed_restart_argv.extend(["--server-port", str(config.legacy_backend_port)]) argv = [ restart_argv[0], "-m", @@ -3463,8 +3659,6 @@ def _build_restart_handoff_argv( str(config.frontend_host), "--frontend-port", str(config.frontend_port), - "--backend-pid-file", - str(paths.backend_pid), "--install-root", str(install_root), "--uv-path", @@ -3490,7 +3684,9 @@ def _build_restart_handoff_argv( argv.extend(["--bundle-sha256", bundle_sha256]) if cleanup_dir is not None: argv.extend(["--cleanup-dir", str(cleanup_dir)]) - argv.extend(["--", *restart_argv]) + if prepare_handover: + argv.append("--prepare-handover") + argv.extend(["--", *managed_restart_argv]) return argv diff --git a/flocks/workflow/engine.py b/flocks/workflow/engine.py index e0621865a..a0dcffe27 100644 --- a/flocks/workflow/engine.py +++ b/flocks/workflow/engine.py @@ -841,9 +841,9 @@ def _execute_llm_node( """Execute an LLM node: render Jinja2 prompt template, call LLM.""" assert node.prompt, "llm node requires prompt" try: - from jinja2 import Template, TemplateError + from jinja2.sandbox import SandboxedEnvironment - rendered = Template(node.prompt).render(**inputs) + rendered = SandboxedEnvironment().from_string(node.prompt).render(**inputs) except Exception as e: raise NodeExecutionError( node_id=node.id, @@ -871,14 +871,15 @@ def _execute_http_request_node(self, node: Node, inputs: Dict[str, Any]) -> Tupl assert node.url, "http_request node requires url" assert node.method, "http_request node requires method" try: - from jinja2 import Template + from jinja2.sandbox import SandboxedEnvironment - url = Template(node.url).render(**inputs) + _sandbox = SandboxedEnvironment() + url = _sandbox.from_string(node.url).render(**inputs) method = node.method.upper() headers = node.headers or {} body = node.body if isinstance(body, str): - body = Template(body).render(**inputs) + body = _sandbox.from_string(body).render(**inputs) except Exception as e: raise NodeExecutionError( node_id=node.id, diff --git a/flocks/workflow/repl_runtime.py b/flocks/workflow/repl_runtime.py index ad78f0b59..4b9618e96 100644 --- a/flocks/workflow/repl_runtime.py +++ b/flocks/workflow/repl_runtime.py @@ -46,6 +46,12 @@ def _drain_text_stream(stream: TextIO, chunks: list[str]) -> None: @dataclass class PythonExecRuntime(Runtime): + """Trusted host-process runtime. + + This class intentionally is not a security sandbox. Untrusted workflow + execution must use SandboxPythonExecRuntime plus authenticated entrypoints. + """ + globals: Dict[str, Any] = field(default_factory=dict) tool_registry: Optional[Any] = None # FlocksToolAdapter or compatible cancel_checker: Optional[Callable[[], bool]] = None diff --git a/flocks/workflow/store.py b/flocks/workflow/store.py index 8212383ad..3c238c236 100644 --- a/flocks/workflow/store.py +++ b/flocks/workflow/store.py @@ -78,7 +78,8 @@ async def init(cls) -> None: await Storage._ensure_init() db_path.parent.mkdir(parents=True, exist_ok=True) - try: + + async def _open_and_migrate() -> None: cls._conn = await aiosqlite.connect( db_path, timeout=Storage._sqlite_timeout_s, @@ -93,14 +94,26 @@ async def init(cls) -> None: cls._init_pid = current_pid cls._db_path = db_path await cls._migrate_legacy_kv() + + try: + await _open_and_migrate() log.info("workflow.store.initialized") - except Exception: + except Exception as exc: if cls._conn: await cls._conn.close() cls._conn = None cls._initialized = False cls._init_pid = None cls._db_path = None + if Storage._is_db_corruption_error(exc): + await Storage.recover_corrupt_db( + db_path, + action="workflow.store.init", + exc=exc, + reinitialize=_open_and_migrate, + ) + log.info("workflow.store.initialized") + return raise @classmethod diff --git a/pyproject.toml b/pyproject.toml index 81ebf74ef..e68732a07 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "flocks" -version = "v2026.7.1" +version = "v2026.7.8" description = "AI-Native SecOps platform with multi-agent collaboration" authors = [ {name = "Flocks Team", email = "team@example.com"} @@ -87,6 +87,7 @@ dependencies = [ "datasketch>=1.10.0", # Kafka ingest (workflow input/output) "aiokafka>=0.14.0", + "ddddocr>=1.6.1", ] [dependency-groups] diff --git a/scripts/dev.sh b/scripts/dev.sh index 8a7554c24..80474efb6 100644 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -217,6 +217,10 @@ start_backend() { } start_frontend() { + echo -e "${GREEN}🏗️ 构建前端...${NC}" + cd "${PROJECT_ROOT}/webui" + npm run build + echo -e "${GREEN}🎨 启动前端服务: http://${FRONTEND_HOST}:${FRONTEND_PORT}${NC}" cd "${PROJECT_ROOT}/webui" VITE_API_BASE_URL="${BACKEND_BASE_URL}" \ diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 886c110f5..8dffbb00d 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -1016,6 +1016,35 @@ function Install-FlocksCli { } } +function Write-InstallProfile { + $configDir = if ([string]::IsNullOrWhiteSpace($env:FLOCKS_CONFIG_DIR)) { + $flocksRoot = if ([string]::IsNullOrWhiteSpace($env:FLOCKS_ROOT)) { + Join-Path $HOME ".flocks" + } + else { + $env:FLOCKS_ROOT + } + Join-Path $flocksRoot "config" + } + else { + $env:FLOCKS_CONFIG_DIR + } + + try { + $language = if (Test-IsZhInstall) { "zh-CN" } else { "en" } + if (-not (Test-Path $configDir)) { + New-Item -ItemType Directory -Path $configDir -Force | Out-Null + } + + $profilePath = Join-Path $configDir "install_profile.json" + $profile = [ordered]@{ Language = $language } | ConvertTo-Json + [System.IO.File]::WriteAllText($profilePath, $profile + [Environment]::NewLine, [System.Text.UTF8Encoding]::new($false)) + } + catch { + Write-Warning "Failed to write install profile. Continuing with the default installer behavior." + } +} + function Install-Bun { if (Test-Command "bun") { return @@ -1229,6 +1258,7 @@ function Main { } Write-Info (Get-LocalizedText -English "Project directory: $RootDir" -Chinese "项目目录: $RootDir") + Write-InstallProfile Install-Uv Ensure-NpmInstalled Initialize-InstallSources @@ -1263,6 +1293,19 @@ function Main { finally { Pop-Location } + Write-Info (Get-LocalizedText -English "Building WebUI static assets..." -Chinese "正在构建 WebUI 静态资源...") + Push-Location (Join-Path $RootDir "webui") + try { + $null = Invoke-NativeCommandOrFail ` + -Description "WebUI static asset build" ` + -FilePath "npm.cmd" ` + -ArgumentList @("run", "build") ` + -WorkingDirectory (Join-Path $RootDir "webui") ` + -StreamOutput + } + finally { + Pop-Location + } if ($InstallTui) { Install-Bun @@ -1320,4 +1363,4 @@ function Main { } } -Main \ No newline at end of file +Main diff --git a/scripts/install.sh b/scripts/install.sh index 8781db298..35780c3dc 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -948,6 +948,25 @@ ensure_env_var_persisted() { fi } +write_install_profile() { + local config_dir language + if [[ -n "${FLOCKS_CONFIG_DIR:-}" ]]; then + config_dir="$FLOCKS_CONFIG_DIR" + else + config_dir="${FLOCKS_ROOT:-$HOME/.flocks}/config" + fi + + if is_zh_install; then + language="zh-CN" + else + language="en" + fi + + if ! { mkdir -p "$config_dir" && printf '{\n "Language": "%s"\n}\n' "$language" > "$config_dir/install_profile.json"; }; then + warn "Failed to write install profile. Continuing with the default installer behavior." + fi +} + detect_system_browser_path() { case "$(uname -s)" in Darwin) @@ -1105,6 +1124,7 @@ main() { else info "Project directory: $ROOT_DIR" fi + write_install_profile install_uv ensure_npm_installed select_install_sources @@ -1130,6 +1150,15 @@ main() { cd "$ROOT_DIR/webui" npm_config_registry="$NPM_REGISTRY" "$NPM_CMD" install ) + if is_zh_install; then + info "正在构建 WebUI 静态资源..." + else + info "Building WebUI static assets..." + fi + ( + cd "$ROOT_DIR/webui" + "$NPM_CMD" run build + ) if [[ "$INSTALL_TUI" -eq 1 ]]; then install_bun @@ -1193,4 +1222,4 @@ EOF # show_path_update_hint } -main "$@" \ No newline at end of file +main "$@" diff --git a/tests/channel/test_feishu.py b/tests/channel/test_feishu.py index 7681b7778..1a44933dc 100644 --- a/tests/channel/test_feishu.py +++ b/tests/channel/test_feishu.py @@ -24,6 +24,7 @@ from flocks.channel.builtin.feishu.media import send_media_feishu from flocks.channel.builtin.feishu.monitor import ( _build_ws_client, + _parse_event, _start_single_websocket, start_websocket, ) @@ -81,6 +82,55 @@ def test_resolve_feishu_group_overrides_uses_normalized_group_config() -> None: assert agent == "specific" +def _feishu_text_event( + text: str, + mentions: list[dict] | None = None, +) -> dict: + return { + "header": {"event_type": "im.message.receive_v1"}, + "event": { + "sender": {"sender_id": {"open_id": "ou_sender"}}, + "message": { + "message_id": "om_1", + "chat_id": "oc_group", + "chat_type": "group", + "message_type": "text", + "content": json.dumps({"text": text}), + "mentions": mentions or [ + {"key": "@_user_1", "id": {"open_id": "ou_bot"}}, + ], + }, + }, + } + + +def test_parse_event_strips_feishu_mention_key() -> None: + msg = _parse_event( + _feishu_text_event('@_user_1 "friday@park.example.ai"'), + {}, + bot_open_id="ou_bot", + ) + + assert msg is not None + assert msg.mention_text == '"friday@park.example.ai"' + + +def test_parse_event_preserves_email_when_mention_key_matches_domain() -> None: + msg = _parse_event( + _feishu_text_event( + '@ArchSec "friday@park.example.ai", 邮箱域名是park.example.ai', + mentions=[{"key": "@park.example.ai", "id": {"open_id": "ou_bot"}}], + ), + {}, + bot_open_id="ou_bot", + ) + + assert msg is not None + assert msg.mention_text == ( + '@ArchSec "friday@park.example.ai", 邮箱域名是park.example.ai' + ) + + def test_resolve_webhook_account_config_matches_named_account_token() -> None: config = { "connectionMode": "webhook", diff --git a/tests/channel/test_session_binding.py b/tests/channel/test_session_binding.py new file mode 100644 index 000000000..45e7ad366 --- /dev/null +++ b/tests/channel/test_session_binding.py @@ -0,0 +1,27 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest + +from flocks.channel.inbound.session_binding import SessionBindingService + + +@pytest.mark.asyncio +async def test_latest_active_user_binding_returns_none_when_channel_is_ambiguous() -> None: + first = SimpleNamespace(session_id="ses_newest") + second = SimpleNamespace(session_id="ses_other") + service = SessionBindingService() + service.list_bindings = AsyncMock(return_value=[first, second]) + + with patch( + "flocks.session.session.Session.get_by_id", + AsyncMock( + side_effect=[ + SimpleNamespace(status="active", category="user"), + SimpleNamespace(status="active", category="user"), + ] + ), + ): + result = await service.latest_active_user_binding(channel_id="wecom") + + assert result is None diff --git a/tests/channel/test_wecom.py b/tests/channel/test_wecom.py index b1978c347..72352adbb 100644 --- a/tests/channel/test_wecom.py +++ b/tests/channel/test_wecom.py @@ -577,6 +577,26 @@ def test_text_message_group(self): assert "查一下" in msg.text assert msg.mentioned is True + def test_group_message_preserves_email_after_leading_mention(self): + frame = { + "body": { + "msgid": "msg002b", + "chattype": "group", + "chatid": "grp_001", + "from": {"userid": "lisi"}, + "msgtype": "text", + "text": { + "content": '@ArchSec "friday@park.example.ai", 邮箱域名是park.example.ai' + }, + } + } + + msg = _parse_frame(frame, {}) + + assert msg is not None + assert msg.text == '"friday@park.example.ai", 邮箱域名是park.example.ai' + assert msg.mentioned is True + def test_image_message(self): frame = { "body": { diff --git a/tests/cli/test_doctor_command.py b/tests/cli/test_doctor_command.py new file mode 100644 index 000000000..dea132de8 --- /dev/null +++ b/tests/cli/test_doctor_command.py @@ -0,0 +1,192 @@ +from __future__ import annotations + +from typer.testing import CliRunner + +import flocks.cli.commands.doctor as doctor_cmd +import flocks.cli.main as cli_main + +runner = CliRunner() + + +async def _noop_log_init(**_: object) -> None: + return None + + +def test_doctor_runs_source_installer_from_source_root(monkeypatch, tmp_path) -> None: + monkeypatch.setenv("FLOCKS_CONFIG_DIR", str(tmp_path)) + monkeypatch.setattr(cli_main.Log, "init", _noop_log_init) + + calls: list[tuple[list[str], object, bool]] = [] + + def fake_run(command, *, cwd, check, env): + _ = env + calls.append((command, cwd, check)) + + monkeypatch.setattr(doctor_cmd.subprocess, "run", fake_run) + monkeypatch.setattr( + "flocks.cli.service_manager.build_status_lines", + lambda: [ + "[flocks] daemon: state=running PID=111", + "[flocks] flocks: state=healthy PID=222 URL=http://127.0.0.1:5173", + ], + ) + + result = runner.invoke(cli_main.app, ["doctor"]) + + assert result.exit_code == 0, result.stdout + assert "Flocks source directory:" in result.stdout + assert "scripts/install.sh" in result.stdout + assert "安装正常" in result.stdout + assert "运行状态正常" in result.stdout + assert len(calls) == 1 + + command, cwd, check = calls[0] + assert command[0] == "bash" + assert command[1].endswith("scripts/install.sh") + assert cwd == doctor_cmd._find_source_root() + assert check is True + + +def test_doctor_uses_cn_environment_for_zh_install_profile(monkeypatch, tmp_path) -> None: + profile = tmp_path / "install_profile.json" + profile.write_text('{"Language": "zh-CN"}', encoding="utf-8") + monkeypatch.setenv("FLOCKS_CONFIG_DIR", str(tmp_path)) + monkeypatch.setattr(cli_main.Log, "init", _noop_log_init) + + captured: dict[str, object] = {} + + def fake_run(command, *, cwd, check, env): + captured["command"] = command + captured["cwd"] = cwd + captured["check"] = check + captured["env"] = env + + monkeypatch.setattr(doctor_cmd.subprocess, "run", fake_run) + monkeypatch.setattr("flocks.cli.service_manager.build_status_lines", lambda: ["[flocks] 后端未运行", "[flocks] WebUI 未运行"]) + + result = runner.invoke(cli_main.app, ["doctor"]) + + assert result.exit_code == 0, result.stdout + env = captured["env"] + assert isinstance(env, dict) + assert env["FLOCKS_INSTALL_LANGUAGE"] == "zh-CN" + assert env["FLOCKS_UV_DEFAULT_INDEX"] == "https://mirrors.aliyun.com/pypi/simple" + assert "运行状态异常,请执行 `flocks restart`" in result.stdout + + +def test_doctor_on_windows_starts_handoff_before_running_installer(monkeypatch, tmp_path) -> None: + monkeypatch.setenv("FLOCKS_CONFIG_DIR", str(tmp_path)) + monkeypatch.delenv("FLOCKS_DOCTOR_WINDOWS_HANDOFF", raising=False) + monkeypatch.setattr(cli_main.Log, "init", _noop_log_init) + monkeypatch.setattr(doctor_cmd, "_is_windows", lambda: True) + monkeypatch.setattr(doctor_cmd.os, "getpid", lambda: 4242) + + captured: dict[str, object] = {} + + def fake_popen(command, *, cwd, env, close_fds): + captured["command"] = command + captured["cwd"] = cwd + captured["env"] = env + captured["close_fds"] = close_fds + return object() + + def fail_run(*_args, **_kwargs): + raise AssertionError("Windows doctor should hand off before running the installer") + + monkeypatch.setattr(doctor_cmd.subprocess, "Popen", fake_popen) + monkeypatch.setattr(doctor_cmd.subprocess, "run", fail_run) + + result = runner.invoke(cli_main.app, ["doctor"]) + + assert result.exit_code == 0, result.stdout + assert "scripts/install.ps1" in result.stdout + assert "installer will continue in this console" in result.stdout + assert captured["cwd"] == doctor_cmd._find_source_root() + assert captured["close_fds"] is True + env = captured["env"] + assert isinstance(env, dict) + assert env["FLOCKS_DOCTOR_WINDOWS_HANDOFF"] == "1" + command = captured["command"] + assert isinstance(command, list) + assert "-Command" in command + assert "Wait-Process -Id 4242" in command[-1] + assert "-m flocks.cli.main doctor" in command[-1] + + +def test_doctor_windows_handoff_child_runs_installer_synchronously(monkeypatch, tmp_path) -> None: + monkeypatch.setenv("FLOCKS_CONFIG_DIR", str(tmp_path)) + monkeypatch.setenv("FLOCKS_DOCTOR_WINDOWS_HANDOFF", "1") + monkeypatch.setattr(cli_main.Log, "init", _noop_log_init) + monkeypatch.setattr(doctor_cmd, "_is_windows", lambda: True) + + captured: dict[str, object] = {} + + def fake_run(command, *, cwd, check, env): + captured["command"] = command + captured["cwd"] = cwd + captured["check"] = check + captured["env"] = env + + def fail_popen(*_args, **_kwargs): + raise AssertionError("handoff child should run the installer directly") + + monkeypatch.setattr(doctor_cmd.subprocess, "run", fake_run) + monkeypatch.setattr(doctor_cmd.subprocess, "Popen", fail_popen) + monkeypatch.setattr( + "flocks.cli.service_manager.build_status_lines", + lambda: [ + "[flocks] daemon: state=running PID=111", + "[flocks] flocks: state=healthy PID=222 URL=http://127.0.0.1:5173", + ], + ) + + result = runner.invoke(cli_main.app, ["doctor"]) + + assert result.exit_code == 0, result.stdout + command = captured["command"] + assert isinstance(command, list) + assert command[-1].endswith("scripts/install.ps1") + assert captured["cwd"] == doctor_cmd._find_source_root() + assert captured["check"] is True + assert "安装正常" in result.stdout + assert "运行状态正常" in result.stdout + + +def test_service_status_is_healthy_accepts_current_daemon_status() -> None: + assert doctor_cmd._service_status_is_healthy( + [ + "[flocks] daemon: state=running PID=111", + "[flocks] flocks: state=healthy PID=222 URL=http://127.0.0.1:5173", + ] + ) + assert not doctor_cmd._service_status_is_healthy( + [ + "[flocks] daemon: state=running PID=111", + "[flocks] flocks: state=degraded PID=222 URL=http://127.0.0.1:5173", + ] + ) + + +def test_service_status_is_healthy_accepts_legacy_backend_and_webui() -> None: + assert doctor_cmd._service_status_is_healthy( + [ + "[flocks] 后端运行中: PID=111 URL=http://127.0.0.1:8000", + "[flocks] WebUI 运行中: PID=222 URL=http://127.0.0.1:5173", + ] + ) + assert not doctor_cmd._service_status_is_healthy(["[flocks] 后端运行中: PID=111", "[flocks] WebUI 未运行"]) + + +def test_doctor_builds_windows_install_command(monkeypatch) -> None: + monkeypatch.setattr(doctor_cmd.shutil, "which", lambda name: None) + + command = doctor_cmd._build_source_install_command(doctor_cmd._find_source_root() / "scripts" / "install.ps1") + + assert command == [ + "powershell", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + str(doctor_cmd._find_source_root() / "scripts" / "install.ps1"), + ] diff --git a/tests/cli/test_service_commands.py b/tests/cli/test_service_commands.py index 99a267c17..d80928bc5 100644 --- a/tests/cli/test_service_commands.py +++ b/tests/cli/test_service_commands.py @@ -31,7 +31,7 @@ def test_cli_help_lists_service_commands(monkeypatch, tmp_path) -> None: assert result.exit_code == 0 for command in ("start", "stop", "restart", "status", "logs", "session", "mcp", "task", "skills"): assert _help_contains_command(result.stdout, command) - for command in ("agent", "acp", "debug", "run", "serve", "auth", "models"): + for command in ("agent", "acp", "debug", "run", "serve", "service-watchdog", "service-daemon", "auth", "models"): assert not _help_contains_command(result.stdout, command) diff --git a/tests/cli/test_service_manager.py b/tests/cli/test_service_manager.py index ba42f890d..cef7885e4 100644 --- a/tests/cli/test_service_manager.py +++ b/tests/cli/test_service_manager.py @@ -1,6 +1,6 @@ -import contextlib import json -import signal +import shutil +import subprocess import sys from pathlib import Path from types import SimpleNamespace @@ -9,6 +9,14 @@ import pytest from flocks.cli import service_manager +from flocks.cli import service_supervisor +from flocks.cli import service_control +from flocks.cli import service_process +from tests.helpers.service_supervisor import ( + SleeperProcessAdapter, + make_short_runtime_root, + wait_for_process_exit, +) class DummyConsole: @@ -19,6 +27,61 @@ def print(self, *args, **kwargs) -> None: self.messages.append(" ".join(str(arg) for arg in args)) +@pytest.fixture(autouse=True) +def _skip_backend_webui_dist_check(monkeypatch) -> None: + monkeypatch.setattr(service_manager, "_ensure_webui_dist", lambda *_args, **_kwargs: None) + monkeypatch.setattr(service_manager, "_resolve_upgrade_runtime", lambda *_args, **_kwargs: {"action": "noop", "error": None}) + + +def _make_runtime_paths(tmp_path: Path) -> service_manager.RuntimePaths: + return service_manager.RuntimePaths( + root=tmp_path, + run_dir=tmp_path / "run", + log_dir=tmp_path / "logs", + backend_pid=tmp_path / "run" / "backend.pid", + frontend_pid=tmp_path / "run" / "webui.pid", + backend_log=tmp_path / "logs" / "backend.log", + frontend_log=tmp_path / "logs" / "webui.log", + ) + + +def _write_legacy_runtime_record(pid_file: Path, record: service_manager.RuntimeRecord) -> None: + payload: dict[str, object] = {"pid": record.pid} + if record.pgid is not None: + payload["pgid"] = record.pgid + if record.host is not None: + payload["host"] = record.host + if record.port is not None: + payload["port"] = record.port + if record.command: + payload["command"] = list(record.command) + if record.started_at is not None: + payload["started_at"] = record.started_at + pid_file.write_text(json.dumps(payload, ensure_ascii=True, sort_keys=True), encoding="utf-8") + + +def test_supervisor_uses_tcp_control_when_af_unix_is_unavailable(monkeypatch) -> None: + monkeypatch.setattr(service_control.sys, "platform", "linux") + monkeypatch.delattr(service_control.socket, "AF_UNIX", raising=False) + + assert service_control.supervisor_uses_tcp_control() is True + + +def test_service_supervisor_imports_when_af_unix_is_unavailable() -> None: + code = "\n".join( + [ + "import socket", + "if hasattr(socket, 'AF_UNIX'):", + " delattr(socket, 'AF_UNIX')", + "import flocks.cli.service_supervisor", + ] + ) + + completed = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, check=False) + + assert completed.returncode == 0, completed.stderr + + def test_runtime_paths_follow_flocks_root_env(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("FLOCKS_ROOT", str(tmp_path)) @@ -241,7 +304,7 @@ def test_runtime_record_round_trip_preserves_metadata(tmp_path: Path) -> None: started_at=1234.5, ) - service_manager.write_runtime_record(pid_file, record) + _write_legacy_runtime_record(pid_file, record) assert json.loads(pid_file.read_text(encoding="utf-8")) == { "command": ["python", "-m", "uvicorn"], @@ -264,7 +327,7 @@ def test_runtime_record_round_trip_preserves_host(tmp_path: Path) -> None: started_at=1234.5, ) - service_manager.write_runtime_record(pid_file, record) + _write_legacy_runtime_record(pid_file, record) assert json.loads(pid_file.read_text(encoding="utf-8")) == { "command": ["python", "-m", "uvicorn"], @@ -286,7 +349,7 @@ def test_read_runtime_record_rejects_invalid_content(tmp_path: Path) -> None: def test_cleanup_stale_pid_file_keeps_live_process_group(monkeypatch, tmp_path: Path) -> None: pid_file = tmp_path / "backend.pid" - service_manager.write_runtime_record( + _write_legacy_runtime_record( pid_file, service_manager.RuntimeRecord(pid=1001, pgid=2002, port=8000), ) @@ -301,7 +364,7 @@ def test_cleanup_stale_pid_file_keeps_live_process_group(monkeypatch, tmp_path: def test_cleanup_stale_pid_file_removes_reused_windows_pid(monkeypatch, tmp_path: Path) -> None: pid_file = tmp_path / "backend.pid" - service_manager.write_runtime_record( + _write_legacy_runtime_record( pid_file, service_manager.RuntimeRecord( pid=1232, @@ -341,8 +404,95 @@ def test_selected_log_paths_support_specific_targets(tmp_path: Path) -> None: ) assert service_manager.selected_log_paths(paths, backend=True) == [paths.backend_log] - assert service_manager.selected_log_paths(paths, webui=True) == [paths.frontend_log] - assert service_manager.selected_log_paths(paths) == [paths.backend_log, paths.frontend_log] + assert service_manager.selected_log_paths(paths, webui=True) == [paths.backend_log] + assert service_manager.selected_log_paths(paths) == [paths.backend_log] + + +def test_show_logs_falls_back_to_local_files_when_daemon_unavailable(monkeypatch, tmp_path: Path) -> None: + paths = _make_runtime_paths(tmp_path) + paths.log_dir.mkdir(parents=True) + paths.backend_log.write_text("backend-one\nbackend-two\n", encoding="utf-8") + paths.frontend_log.write_text("webui-one\n", encoding="utf-8") + (paths.log_dir / "daemon.log").write_text("daemon-one\n", encoding="utf-8") + console = DummyConsole() + + monkeypatch.setattr(service_manager, "ensure_runtime_dirs", lambda: paths) + monkeypatch.setattr( + service_manager, + "read_logs", + lambda **_kwargs: (_ for _ in ()).throw(service_manager.ServiceError("down")), + ) + + service_manager.show_logs(console, follow=False, lines=1) + + assert any("改为读取本地日志文件" in message for message in console.messages) + assert "[flocks] backend-two" in console.messages + assert "[daemon] daemon-one" in console.messages + + +def test_daemon_log_service_name_uses_daemon_only(tmp_path: Path) -> None: + paths = _make_runtime_paths(tmp_path) + daemon = service_supervisor.SupervisorDaemon(service_manager.ServiceConfig()) + daemon.paths = paths + + assert daemon._log_paths_for_service("daemon") == [("daemon", paths.log_dir / "daemon.log")] + assert daemon._log_paths_for_service("supervisor") == [] + + +def test_daemon_log_event_prefix_uses_daemon(capsys) -> None: + service_supervisor._daemon_log("stopped") + + assert "daemon.stopped" in capsys.readouterr().out + + +@pytest.mark.parametrize("disconnect_error", [BrokenPipeError, ConnectionResetError, ConnectionAbortedError]) +def test_supervisor_control_send_json_ignores_disconnected_client(disconnect_error: type[Exception]) -> None: + daemon = service_supervisor.SupervisorDaemon(service_manager.ServiceConfig()) + handler_class = daemon._handler_class() + handler = handler_class.__new__(handler_class) + calls: list[tuple[str, object]] = [] + + handler.send_response = lambda status: calls.append(("status", status)) + handler.send_header = lambda name, value: calls.append((name, value)) + handler.end_headers = lambda: calls.append(("end_headers", None)) + handler.wfile = SimpleNamespace(write=lambda _body: (_ for _ in ()).throw(disconnect_error())) + + handler._send_json({"ok": True}) + + assert calls[0] == ("status", 200) + + +def test_supervisor_control_get_ignores_logs_client_disconnect() -> None: + daemon = service_supervisor.SupervisorDaemon(service_manager.ServiceConfig()) + handler_class = daemon._handler_class() + handler = handler_class.__new__(handler_class) + sent: list[dict[str, object]] = [] + + daemon.handle_logs_request = lambda *_args, **_kwargs: (_ for _ in ()).throw(BrokenPipeError()) + handler.path = "/logs?service=daemon" + handler._send_json = lambda payload, **_kwargs: sent.append(payload) + + handler.do_GET() + + assert sent == [] + + +def test_open_default_browser_uses_windows_startfile(monkeypatch) -> None: + opened: list[str] = [] + console = DummyConsole() + + monkeypatch.setattr(service_manager.sys, "platform", "win32") + monkeypatch.setattr(service_manager.os, "startfile", lambda url: opened.append(url), raising=False) + monkeypatch.setattr( + service_manager.webbrowser, + "open", + lambda _url: (_ for _ in ()).throw(AssertionError("webbrowser should not be used on Windows when startfile exists")), + ) + + service_manager.open_default_browser("http://127.0.0.1:5173", console) + + assert opened == ["http://127.0.0.1:5173"] + assert console.messages == ["[flocks] 浏览器已打开: http://127.0.0.1:5173"] def test_tail_lines_returns_recent_content(tmp_path: Path) -> None: @@ -673,158 +823,380 @@ def test_resolve_flocks_cli_command_falls_back_to_python_module(monkeypatch, tmp ] -def test_build_status_lines_reports_running_and_idle_services(monkeypatch, tmp_path: Path) -> None: - paths = service_manager.RuntimePaths( - root=tmp_path, - run_dir=tmp_path / "run", - log_dir=tmp_path / "logs", - backend_pid=tmp_path / "run" / "backend.pid", - frontend_pid=tmp_path / "run" / "webui.pid", - backend_log=tmp_path / "logs" / "backend.log", - frontend_log=tmp_path / "logs" / "webui.log", +def _supervisor_status_payload() -> dict[str, object]: + return { + "daemon": { + "pid": 100, + "state": "running", + "log_path": "/tmp/logs/daemon.log", + }, + "backend": { + "pid": 111, + "host": "0.0.0.0", + "port": 9000, + "state": "healthy", + "last_error": None, + "log_path": "/tmp/logs/backend.log", + }, + "webui": { + "host": "0.0.0.0", + "port": 9000, + "state": "static", + "last_error": None, + "log_path": "/tmp/logs/backend.log", + }, + } + + +def _supervisor_status(payload: dict[str, object] | None = None) -> service_control.SupervisorStatus: + return service_control.parse_supervisor_status(payload or _supervisor_status_payload()) + + +def test_build_status_lines_reports_supervisor_control_status(monkeypatch, tmp_path: Path) -> None: + paths = _make_runtime_paths(tmp_path) + monkeypatch.setattr(service_manager, "read_supervisor_status", lambda *_args, **_kwargs: _supervisor_status()) + + lines = service_manager.build_status_lines(paths) + + assert lines[0] == "[flocks] 服务" + assert lines[1] == "[flocks] daemon: state=running PID=100" + assert "http://127.0.0.1:9000" in lines[2] + assert lines[5] == "[flocks] daemon: /tmp/logs/daemon.log" + assert lines[6] == "[flocks] flocks: /tmp/logs/backend.log" + + +def test_startup_status_lines_use_progress_summary() -> None: + lines = service_manager._startup_status_lines_from_payload(_supervisor_status_payload()) + + assert lines[:2] == [ + "[flocks] Flocks daemon 已启动。", + "[flocks] Flocks service 已启动。", + ] + assert lines[4] == "[flocks] daemon: state=running PID=100" + assert lines[5] == "[flocks] flocks: state=healthy PID=111 URL=http://127.0.0.1:9000" + assert lines[8] == "[flocks] daemon: /tmp/logs/daemon.log" + assert lines[9] == "[flocks] flocks: /tmp/logs/backend.log" + + +def test_startup_status_lines_mark_unhealthy_steps() -> None: + payload = _supervisor_status_payload() + payload["backend"]["state"] = "degraded" + payload["backend"]["last_error"] = "port occupied" + + lines = service_manager._startup_status_lines_from_payload(payload) + + assert lines[1] == "[flocks] Flocks service 启动异常。" + assert lines[5] == "[flocks] flocks: state=degraded PID=111 URL=http://127.0.0.1:9000 last_error=port occupied" + + +def test_startup_status_lines_can_skip_daemon_step() -> None: + lines = service_manager._startup_status_lines_from_payload( + _supervisor_status_payload(), + include_daemon_step=False, ) - paths.run_dir.mkdir(parents=True) - paths.log_dir.mkdir(parents=True) - paths.backend_pid.write_text("111", encoding="utf-8") - paths.frontend_pid.write_text("222", encoding="utf-8") - monkeypatch.setattr(service_manager, "cleanup_stale_pid_file", lambda _: None) + assert lines[:1] == ["[flocks] Flocks service 已启动。"] + + +def test_build_status_lines_reports_daemon_down_without_port_scans(monkeypatch, tmp_path: Path) -> None: + paths = _make_runtime_paths(tmp_path) + calls: list[str] = [] + monkeypatch.setattr( service_manager, - "port_owner_pids", - lambda port: [111] if port == 8000 else [], + "read_supervisor_status", + lambda *_args, **_kwargs: (_ for _ in ()).throw(service_manager.ServiceError("down")), ) - monkeypatch.setattr(service_manager, "pid_is_running", lambda pid: pid == 222) + monkeypatch.setattr(service_manager, "port_owner_pids", lambda _port: calls.append("port_owner") or []) + monkeypatch.setattr(service_manager, "port_is_in_use", lambda *_args, **_kwargs: calls.append("port_in_use") or False) + monkeypatch.setattr(service_manager, "trusted_daemon_process_pids", lambda **_kwargs: []) lines = service_manager.build_status_lines(paths) - assert "后端运行中" in lines[0] - assert "WebUI 主进程仍在运行" in lines[1] + assert lines[0] == "[flocks] Flocks daemon 未运行" + assert calls == [] -def test_build_status_lines_uses_custom_server_and_webui_ports(monkeypatch, tmp_path: Path) -> None: - paths = service_manager.RuntimePaths( - root=tmp_path, - run_dir=tmp_path / "run", - log_dir=tmp_path / "logs", - backend_pid=tmp_path / "run" / "backend.pid", - frontend_pid=tmp_path / "run" / "webui.pid", - backend_log=tmp_path / "logs" / "backend.log", - frontend_log=tmp_path / "logs" / "webui.log", - ) - paths.run_dir.mkdir(parents=True) - paths.log_dir.mkdir(parents=True) - service_manager.write_runtime_record( - paths.backend_pid, - service_manager.RuntimeRecord(pid=111, host="0.0.0.0", port=9000), - ) - service_manager.write_runtime_record( - paths.frontend_pid, - service_manager.RuntimeRecord(pid=222, host="0.0.0.0", port=5174), - ) +def test_build_status_lines_reports_residual_daemon_when_control_api_is_down(monkeypatch, tmp_path: Path) -> None: + paths = _make_runtime_paths(tmp_path) - monkeypatch.setattr(service_manager, "cleanup_stale_pid_file", lambda _: None) monkeypatch.setattr( service_manager, - "port_owner_pids", - lambda port: [111] if port in {9000, 5174} else [], + "read_supervisor_status", + lambda *_args, **_kwargs: (_ for _ in ()).throw(service_manager.ServiceError("down")), ) - monkeypatch.setattr(service_manager, "pid_is_running", lambda _pid: False) + monkeypatch.setattr(service_manager, "ensure_install_layout", lambda: tmp_path) + monkeypatch.setattr(service_manager, "trusted_daemon_process_pids", lambda **_kwargs: [52058]) lines = service_manager.build_status_lines(paths) - assert "http://127.0.0.1:9000" in lines[0] - assert "http://127.0.0.1:5174" in lines[1] + assert lines == [ + "[flocks] Flocks daemon control API 未运行", + "[flocks] 检测到残留 daemon 进程: PID=52058", + f"[flocks] 日志: {paths.log_dir / 'daemon.log'}", + "[flocks] 可执行 `flocks stop` 清理残留进程。", + ] -def test_start_all_stops_services_before_starting(monkeypatch) -> None: +def test_start_all_starts_supervisor_when_control_api_is_down(monkeypatch) -> None: call_order: list[str] = [] - paths = service_manager.RuntimePaths( - root=Path("/tmp"), - run_dir=Path("/tmp/run"), - log_dir=Path("/tmp/logs"), - backend_pid=Path("/tmp/run/backend.pid"), - frontend_pid=Path("/tmp/run/webui.pid"), - backend_log=Path("/tmp/logs/backend.log"), - frontend_log=Path("/tmp/logs/webui.log"), - ) + paths = _make_runtime_paths(Path("/tmp/flocks-test")) monkeypatch.setattr(service_manager, "ensure_runtime_dirs", lambda: (call_order.append("ensure_runtime_dirs"), paths)[1]) - monkeypatch.setattr(service_manager, "service_lock", lambda _paths: _record_call(call_order, "service_lock")) - monkeypatch.setattr(service_manager, "stop_one", lambda port, _pid_file, _name, _console: call_order.append(f"stop_one:{port}")) - monkeypatch.setattr(service_manager, "stop_all_browser_daemons", lambda: call_order.append("stop_browser") or []) + monkeypatch.setattr(service_manager, "supervisor_is_running", lambda _paths: False) monkeypatch.setattr(service_manager, "_start_all_without_stop", lambda _config, _console: call_order.append("_start_all_without_stop")) service_manager.start_all(service_manager.ServiceConfig(), console=None) - assert call_order == [ - "ensure_runtime_dirs", - "service_lock", - "stop_one:5173", - "stop_one:8000", - "stop_browser", - "_start_all_without_stop", - ] + assert call_order == ["ensure_runtime_dirs", "_start_all_without_stop"] -def test_restart_all_stops_then_starts_under_lock(monkeypatch) -> None: - call_order: list[str] = [] - paths = service_manager.RuntimePaths( - root=Path("/tmp"), - run_dir=Path("/tmp/run"), - log_dir=Path("/tmp/logs"), - backend_pid=Path("/tmp/run/backend.pid"), - frontend_pid=Path("/tmp/run/webui.pid"), - backend_log=Path("/tmp/logs/backend.log"), - frontend_log=Path("/tmp/logs/webui.log"), +def test_start_all_resolves_upgrade_runtime_before_supervisor_status(monkeypatch) -> None: + events: list[str] = [] + console = DummyConsole() + paths = _make_runtime_paths(Path("/tmp/flocks-test")) + + def resolve_upgrade_runtime(_console, *, frontend_port: int, attempt_recover: bool) -> dict[str, object]: + events.append(f"upgrade:{frontend_port}:{attempt_recover}") + return {"action": "cleaned", "error": None} + + def supervisor_running(_paths) -> bool: + events.append("supervisor") + return False + + monkeypatch.setattr(service_manager, "ensure_runtime_dirs", lambda: paths) + monkeypatch.setattr(service_manager, "_resolve_upgrade_runtime", resolve_upgrade_runtime) + monkeypatch.setattr(service_manager, "supervisor_is_running", supervisor_running) + monkeypatch.setattr(service_manager, "_start_all_without_stop", lambda _config, _console: events.append("start")) + + service_manager.start_all(service_manager.ServiceConfig(frontend_port=5173), console) + + assert events == ["upgrade:5173:False", "supervisor", "start"] + + +def test_start_all_does_not_duplicate_running_supervisor(monkeypatch) -> None: + calls: list[str] = [] + console = DummyConsole() + + monkeypatch.setattr(service_manager, "ensure_runtime_dirs", lambda: _make_runtime_paths(Path("/tmp/flocks-test"))) + monkeypatch.setattr(service_manager, "supervisor_is_running", lambda _paths: True) + monkeypatch.setattr(service_manager, "show_status", lambda _console: calls.append("status")) + monkeypatch.setattr(service_manager, "open_default_browser", lambda _url, _console: calls.append("browser")) + monkeypatch.setattr(service_manager, "_start_all_without_stop", lambda *_args: calls.append("start")) + + service_manager.start_all(service_manager.ServiceConfig(no_browser=True), console=console) + + assert calls == ["status"] + assert "[flocks] Flocks daemon 已在运行。" in console.messages + + +def test_start_all_restarts_paused_supervisor(monkeypatch) -> None: + calls: list[str] = [] + console = DummyConsole() + paths = _make_runtime_paths(Path("/tmp/flocks-test")) + paused_payload = _supervisor_status_payload() + paused_payload["backend"].update({ + "pid": None, + "state": "paused", + "health": "paused", + "paused": True, + "last_error": "control upgrade prepare", + }) + paused_payload["webui"].update({ + "state": "paused", + "health": "paused", + "paused": True, + "last_error": "control upgrade prepare", + }) + + monkeypatch.setattr(service_manager, "ensure_runtime_dirs", lambda: paths) + monkeypatch.setattr(service_manager, "supervisor_is_running", lambda _paths: True) + monkeypatch.setattr(service_manager, "read_supervisor_status", lambda *_args, **_kwargs: _supervisor_status(paused_payload)) + monkeypatch.setattr(service_manager, "_stop_all_unlocked", lambda _console, **_kwargs: calls.append("stop")) + monkeypatch.setattr(service_manager, "_start_all_without_stop", lambda _config, _console: calls.append("start")) + + service_manager.start_all(service_manager.ServiceConfig(), console) + + assert calls == ["stop", "start"] + assert "[flocks] Flocks daemon 已在运行,但 Flocks service 处于暂停状态,正在重新启动..." in console.messages + + +def test_start_all_does_not_open_browser_when_restarted_service_remains_unhealthy(monkeypatch) -> None: + calls: list[str] = [] + console = DummyConsole() + paths = _make_runtime_paths(Path("/tmp/flocks-test")) + degraded_payload = _supervisor_status_payload() + degraded_payload["backend"].update({ + "state": "degraded", + "health": "degraded", + "last_error": "port unavailable", + }) + degraded_status = _supervisor_status(degraded_payload) + + monkeypatch.setattr(service_manager, "ensure_runtime_dirs", lambda: paths) + monkeypatch.setattr(service_manager, "supervisor_is_running", lambda _paths: True) + monkeypatch.setattr(service_manager, "read_supervisor_status", lambda *_args, **_kwargs: degraded_status) + monkeypatch.setattr( + service_manager, + "request_restart", + lambda _config, **_kwargs: calls.append("restart") or degraded_status, ) + monkeypatch.setattr(service_manager, "_print_status_payload", lambda *_args, **_kwargs: calls.append("status")) + monkeypatch.setattr(service_manager, "open_default_browser", lambda *_args, **_kwargs: calls.append("browser")) - monkeypatch.setattr(service_manager, "ensure_runtime_dirs", lambda: (call_order.append("ensure_runtime_dirs"), paths)[1]) - monkeypatch.setattr(service_manager, "service_lock", lambda _paths: _record_call(call_order, "service_lock")) - monkeypatch.setattr(service_manager, "stop_one", lambda port, _pid_file, _name, _console: call_order.append(f"stop_one:{port}")) - monkeypatch.setattr(service_manager, "stop_all_browser_daemons", lambda: call_order.append("stop_browser") or []) - monkeypatch.setattr(service_manager, "_start_all_without_stop", lambda _config, _console: call_order.append("_start_all_without_stop")) + with pytest.raises(service_manager.ServiceError, match="Flocks service 启动失败"): + service_manager.start_all(service_manager.ServiceConfig(), console) + + assert calls == ["restart", "status"] + assert "[flocks] Flocks daemon 已在运行,但 Flocks service 不可用,正在重启..." in console.messages + + +def test_start_all_restarts_running_daemon_when_config_changes(monkeypatch) -> None: + calls: list[str] = [] + console = DummyConsole() + paths = _make_runtime_paths(Path("/tmp/flocks-test")) + payload = _supervisor_status_payload() + payload["config"] = { + "backend_host": "127.0.0.1", + "backend_port": 8000, + "frontend_host": "127.0.0.1", + "frontend_port": 5173, + } + + monkeypatch.setattr(service_manager, "ensure_runtime_dirs", lambda: paths) + monkeypatch.setattr(service_manager, "supervisor_is_running", lambda _paths: True) + monkeypatch.setattr(service_manager, "read_supervisor_status", lambda *_args, **_kwargs: _supervisor_status(payload)) + monkeypatch.setattr(service_manager, "_stop_all_unlocked", lambda _console, **_kwargs: calls.append("stop")) + monkeypatch.setattr(service_manager, "_start_all_without_stop", lambda _config, _console: calls.append("start")) + + service_manager.start_all( + service_manager.ServiceConfig( + backend_host="0.0.0.0", + backend_port=9000, + frontend_host="0.0.0.0", + frontend_port=5273, + no_browser=True, + ), + console, + ) + + assert calls == ["stop", "start"] + assert "[flocks] Flocks daemon 已在运行,但配置已变化,正在按新配置重启..." in console.messages + + +def test_restart_all_stops_then_starts_daemon(monkeypatch) -> None: + call_order: list[str] = [] + paths = _make_runtime_paths(Path("/tmp/flocks-test")) + + monkeypatch.setattr(service_manager, "ensure_runtime_dirs", lambda: paths) + monkeypatch.setattr(service_manager, "_stop_all_unlocked", lambda _console, **_kwargs: call_order.append("stop")) + monkeypatch.setattr(service_manager, "_start_all_unlocked", lambda _config, _console, **_kwargs: call_order.append("start")) service_manager.restart_all(service_manager.ServiceConfig(), console=None) - assert call_order == [ - "ensure_runtime_dirs", - "service_lock", - "stop_one:5173", - "stop_one:8000", - "stop_browser", - "_start_all_without_stop", + assert call_order == ["stop", "start"] + + +def test_start_all_without_stop_starts_supervisor_daemon(monkeypatch, tmp_path: Path) -> None: + paths = _make_runtime_paths(tmp_path) + calls: list[str] = [] + console = DummyConsole() + + monkeypatch.setattr(service_manager, "ensure_runtime_dirs", lambda: paths) + monkeypatch.setattr(service_manager, "cleanup_legacy_runtime_processes", lambda *_args, **_kwargs: None) + monkeypatch.setattr(service_manager, "cleanup_orphan_service_ports", lambda *_args, **_kwargs: None) + monkeypatch.setattr(service_manager, "_start_supervisor_process", lambda _config, _paths, _console: calls.append("daemon") or SimpleNamespace(poll=lambda: None)) + monkeypatch.setattr(service_manager, "_wait_for_supervisor_ready", lambda _paths, **_kwargs: calls.append("ready") or _supervisor_status_payload()) + monkeypatch.setattr(service_manager, "_print_status_payload", lambda _payload, _console, **_kwargs: calls.append("status")) + monkeypatch.setattr( + service_manager, + "open_default_browser", + lambda _url, _console: calls.append("browser"), + ) + + service_manager._start_all_without_stop(service_manager.ServiceConfig(no_browser=True), console) + + assert calls == ["daemon", "ready", "status"] + assert console.messages == [ + "[flocks] Flocks daemon 启动中...", + "[flocks] Flocks daemon 已启动。", ] -def test_start_all_stops_on_failure_before_restart(monkeypatch) -> None: - paths = service_manager.RuntimePaths( - root=Path("/tmp"), - run_dir=Path("/tmp/run"), - log_dir=Path("/tmp/logs"), - backend_pid=Path("/tmp/run/backend.pid"), - frontend_pid=Path("/tmp/run/webui.pid"), - backend_log=Path("/tmp/logs/backend.log"), - frontend_log=Path("/tmp/logs/webui.log"), +def test_start_all_without_stop_raises_when_service_starts_degraded(monkeypatch, tmp_path: Path) -> None: + paths = _make_runtime_paths(tmp_path) + calls: list[str] = [] + console = DummyConsole() + degraded_payload = _supervisor_status_payload() + degraded_payload["backend"].update({ + "state": "degraded", + "health": "degraded", + "last_error": "port unavailable", + }) + + monkeypatch.setattr(service_manager, "ensure_runtime_dirs", lambda: paths) + monkeypatch.setattr(service_manager, "cleanup_legacy_runtime_processes", lambda *_args, **_kwargs: None) + monkeypatch.setattr(service_manager, "cleanup_orphan_service_ports", lambda *_args, **_kwargs: None) + monkeypatch.setattr( + service_manager, + "_start_supervisor_process", + lambda _config, _paths, _console: calls.append("daemon") or SimpleNamespace(poll=lambda: None), + ) + monkeypatch.setattr( + service_manager, + "_wait_for_supervisor_ready", + lambda _paths, **_kwargs: calls.append("ready") or degraded_payload, ) + monkeypatch.setattr(service_manager, "_print_status_payload", lambda _payload, _console, **_kwargs: calls.append("status")) + monkeypatch.setattr(service_manager, "open_default_browser", lambda *_args, **_kwargs: calls.append("browser")) + + with pytest.raises(service_manager.ServiceError, match="Flocks service 启动失败"): + service_manager._start_all_without_stop(service_manager.ServiceConfig(), console) + + assert calls == ["daemon", "ready", "status"] + + +def test_start_all_without_stop_prints_before_cleanup(monkeypatch, tmp_path: Path) -> None: + paths = _make_runtime_paths(tmp_path) + events: list[str] = [] + console = DummyConsole() + + def record_print(message: str) -> None: + events.append(f"print:{message}") + console.messages.append(message) + + console.print = record_print monkeypatch.setattr(service_manager, "ensure_runtime_dirs", lambda: paths) - monkeypatch.setattr(service_manager, "service_lock", lambda _paths: _record_call([], "service_lock")) + monkeypatch.setattr(service_manager, "cleanup_legacy_runtime_processes", lambda *_args, **_kwargs: events.append("legacy")) + monkeypatch.setattr(service_manager, "cleanup_orphan_service_ports", lambda *_args, **_kwargs: events.append("orphan")) + monkeypatch.setattr(service_manager, "_ensure_webui_dist", lambda *_args, **_kwargs: events.append("dist")) monkeypatch.setattr( service_manager, - "stop_one", - lambda *_args: (_ for _ in ()).throw(service_manager.ServiceError("stop failed")), + "_start_supervisor_process", + lambda _config, _paths, _console: events.append("daemon") or SimpleNamespace(poll=lambda: None), ) + monkeypatch.setattr(service_manager, "_wait_for_supervisor_ready", lambda _paths, **_kwargs: _supervisor_status_payload()) + monkeypatch.setattr(service_manager, "_print_status_payload", lambda *_args, **_kwargs: None) + + service_manager._start_all_without_stop(service_manager.ServiceConfig(no_browser=True), console) + + assert events[:5] == ["print:[flocks] Flocks daemon 启动中...", "legacy", "orphan", "dist", "daemon"] + + +def test_start_all_propagates_supervisor_start_failure(monkeypatch) -> None: + monkeypatch.setattr(service_manager, "ensure_runtime_dirs", lambda: _make_runtime_paths(Path("/tmp/flocks-test"))) + monkeypatch.setattr(service_manager, "supervisor_is_running", lambda _paths: False) monkeypatch.setattr( service_manager, "_start_all_without_stop", - lambda *_args: (_ for _ in ()).throw(AssertionError("should not start")), + lambda *_args: (_ for _ in ()).throw(service_manager.ServiceError("daemon failed")), ) - with pytest.raises(service_manager.ServiceError, match="stop failed"): + with pytest.raises(service_manager.ServiceError, match="daemon failed"): service_manager.start_all(service_manager.ServiceConfig(), console=None) -def test_start_backend_writes_runtime_metadata(monkeypatch, tmp_path: Path) -> None: +def test_start_backend_process_does_not_write_runtime_metadata(monkeypatch, tmp_path: Path) -> None: paths = service_manager.RuntimePaths( root=tmp_path, run_dir=tmp_path / "run", @@ -869,26 +1241,12 @@ def _capture_spawn(*_args, **kwargs) -> SimpleNamespace: monkeypatch.setattr(service_manager, "_spawn_process", _capture_spawn) - service_manager.start_backend(service_manager.ServiceConfig(), console) + process = service_manager._start_backend_process(service_manager.ServiceConfig(), console) - record = service_manager.read_runtime_record(paths.backend_pid) - assert record is not None - assert record.pid == 2468 - assert record.pgid == 2468 - assert record.host == "127.0.0.1" - assert record.port == 8000 - assert record.command == ( - "python", - "-m", - "flocks.cli.main", - "serve", - "--host", - "127.0.0.1", - "--port", - "8000", - ) + assert process.pid == 2468 + assert not paths.backend_pid.exists() assert probe_calls == [{ - "urls": ["http://127.0.0.1:8000"], + "urls": ["http://127.0.0.1:5173"], "name": "后端服务", "attempts": 30, "delay": 3.0, @@ -896,6 +1254,7 @@ def _capture_spawn(*_args, **kwargs) -> SimpleNamespace: }] assert spawn_env is not None assert spawn_env.get("PYTHONUNBUFFERED") == "1" + assert "[flocks] 启动 Flocks service..." not in console.messages def test_start_backend_rolls_back_when_probe_fails(monkeypatch, tmp_path: Path) -> None: @@ -912,7 +1271,7 @@ def test_start_backend_rolls_back_when_probe_fails(monkeypatch, tmp_path: Path) paths.log_dir.mkdir(parents=True) paths.backend_log.write_text("line1\nline2\nboot failed here\n", encoding="utf-8") console = DummyConsole() - stop_calls: list[tuple[int, Path, str]] = [] + stop_calls: list[str] = [] monkeypatch.setattr(service_manager, "ensure_install_layout", lambda: tmp_path) monkeypatch.setattr(service_manager, "ensure_runtime_dirs", lambda: paths) @@ -927,7 +1286,7 @@ def test_start_backend_rolls_back_when_probe_fails(monkeypatch, tmp_path: Path) monkeypatch.setattr( service_manager, "_spawn_process", - lambda *_args, **_kwargs: SimpleNamespace(pid=2468), + lambda *_args, **_kwargs: SimpleNamespace(pid=2468, poll=lambda: None), ) monkeypatch.setattr( service_manager, @@ -936,14 +1295,14 @@ def test_start_backend_rolls_back_when_probe_fails(monkeypatch, tmp_path: Path) ) monkeypatch.setattr( service_manager, - "stop_one", - lambda port, pid_file, name, _console: stop_calls.append((port, pid_file, name)), + "_terminate_process", + lambda _process, name, _console: stop_calls.append(name), ) with pytest.raises(service_manager.ServiceError, match="启动超时"): - service_manager.start_backend(service_manager.ServiceConfig(), console) + service_manager._start_backend_process(service_manager.ServiceConfig(), console) - assert stop_calls == [(8000, paths.backend_pid, "后端")] + assert stop_calls == ["后端"] joined = "\n".join(console.messages) assert "近期日志" in joined assert "boot failed here" in joined @@ -985,15 +1344,12 @@ def test_start_backend_reports_started_after_probe_succeeds(monkeypatch, tmp_pat lambda *_args, **_kwargs: None, ) - service_manager.start_backend(service_manager.ServiceConfig(), console) + service_manager._start_backend_process(service_manager.ServiceConfig(), console) - record = service_manager.read_runtime_record(paths.backend_pid) - assert record is not None - assert record.pid == 2468 backend_env = spawn_calls[0]["kwargs"]["env"] assert backend_env["_FLOCKS_WEBUI_HOST"] == "127.0.0.1" assert backend_env["_FLOCKS_WEBUI_PORT"] == "5173" - assert console.messages[-1] == f"[flocks] 后端已启动,日志: {paths.backend_log}" + assert not paths.backend_pid.exists() assert backend_env["FLOCKS_CONSOLE_BASE_URL"] == service_manager.DEFAULT_FLOCKS_CONSOLE_BASE_URL @@ -1034,7 +1390,7 @@ def test_start_backend_allows_overriding_console_base_url(monkeypatch, tmp_path: ) monkeypatch.setenv("FLOCKS_CONSOLE_BASE_URL", "https://custom-console.example.com") - service_manager.start_backend(service_manager.ServiceConfig(), console) + service_manager._start_backend_process(service_manager.ServiceConfig(), console) backend_env = spawn_calls[0]["kwargs"]["env"] assert backend_env["FLOCKS_CONSOLE_BASE_URL"] == "https://custom-console.example.com" @@ -1139,185 +1495,205 @@ def test_build_frontend_env_allows_direct_backend_urls_when_opted_in(monkeypatch assert env["VITE_WS_BASE_URL"] == "ws://10.0.0.8:9000" -def test_start_frontend_passes_backend_urls_to_build_and_preview(monkeypatch, tmp_path: Path) -> None: - paths = service_manager.RuntimePaths( - root=tmp_path, - run_dir=tmp_path / "run", - log_dir=tmp_path / "logs", - backend_pid=tmp_path / "run" / "backend.pid", - frontend_pid=tmp_path / "run" / "webui.pid", - backend_log=tmp_path / "logs" / "backend.log", - frontend_log=tmp_path / "logs" / "webui.log", +def _fake_process(pid: int, args: list[str] | None = None, returncode: int | None = None): + return SimpleNamespace(pid=pid, args=args or [str(pid)], returncode=returncode, poll=lambda: returncode) + + +def test_supervisor_recovers_backend_when_port_disappears(monkeypatch, tmp_path: Path) -> None: + paths = _make_runtime_paths(tmp_path) + calls: list[str] = [] + monkeypatch.setattr(service_manager, "ensure_runtime_dirs", lambda: paths) + daemon = service_supervisor.SupervisorDaemon(service_manager.ServiceConfig(backend_port=9995, frontend_port=9996)) + daemon.paths = paths + daemon.backend.log_path = paths.backend_log + daemon.backend.process = _fake_process(111, ["backend"]) + + monkeypatch.setattr(service_process, "tcp_port_accepts_connections", lambda _host, port: port != 9995) + monkeypatch.setattr(service_manager, "_terminate_process", lambda _process, name, _console: calls.append(f"stop:{name}")) + monkeypatch.setattr( + service_manager, + "_start_backend_process", + lambda *_args, **_kwargs: calls.append("start:backend") or _fake_process(333, ["backend-new"]), ) - paths.run_dir.mkdir(parents=True) - paths.log_dir.mkdir(parents=True) - console = DummyConsole() - build_calls: list[dict[str, object]] = [] - preview_calls: list[dict[str, object]] = [] - def fake_run(command, **kwargs): - build_calls.append({"command": command, "kwargs": kwargs}) - return SimpleNamespace(returncode=0) + daemon.tick() - def fake_spawn(command, **kwargs): - preview_calls.append({"command": command, "kwargs": kwargs}) - return SimpleNamespace(pid=2468) + assert calls == ["stop:后端", "start:backend"] + assert daemon.backend.pid == 333 - monkeypatch.setattr(service_manager, "ensure_install_layout", lambda: tmp_path) + +def test_supervisor_waits_for_second_backend_health_failure(monkeypatch, tmp_path: Path) -> None: + paths = _make_runtime_paths(tmp_path) + calls: list[str] = [] monkeypatch.setattr(service_manager, "ensure_runtime_dirs", lambda: paths) - monkeypatch.setattr(service_manager, "cleanup_stale_pid_file", lambda _path: None) - monkeypatch.setattr(service_manager, "port_owner_pids", lambda _port: []) - monkeypatch.setattr(service_manager, "wait_for_http", lambda *_args, **_kwargs: None) - monkeypatch.setattr(service_manager.os, "getpgid", lambda pid: pid) - monkeypatch.setattr(service_manager, "resolve_npm_executable", lambda: "/usr/bin/npm") - monkeypatch.setattr(service_manager, "node_version_satisfies_requirement", lambda: True) - monkeypatch.setattr(service_manager.subprocess, "run", fake_run) - monkeypatch.setattr(service_manager, "_spawn_process", fake_spawn) - monkeypatch.setenv("__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS", "preview.example.com") + daemon = service_supervisor.SupervisorDaemon( + service_manager.ServiceConfig(backend_port=9995, frontend_port=9996), + failure_threshold=2, + ) + daemon.paths = paths + daemon.backend.process = _fake_process(111, ["backend"]) - config = service_manager.ServiceConfig( - backend_host="10.0.0.8", - backend_port=9000, - frontend_host="0.0.0.0", - frontend_port=5174, - ) - service_manager.start_frontend(config, console) - - assert build_calls[0]["command"] == ["/usr/bin/npm", "run", "build"] - assert build_calls[0]["kwargs"]["env"]["FLOCKS_API_PROXY_TARGET"] == "http://10.0.0.8:9000" - assert build_calls[0]["kwargs"]["env"]["__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS"] == "preview.example.com" - assert "VITE_API_BASE_URL" not in build_calls[0]["kwargs"]["env"] - assert "VITE_WS_BASE_URL" not in build_calls[0]["kwargs"]["env"] - - assert preview_calls[0]["command"] == [ - "/usr/bin/npm", - "run", - "preview", - "--", - "--host", - "0.0.0.0", - "--port", - "5174", - ] - assert preview_calls[0]["kwargs"]["env"]["FLOCKS_API_PROXY_TARGET"] == "http://10.0.0.8:9000" - assert preview_calls[0]["kwargs"]["env"]["__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS"] == "preview.example.com" - assert "VITE_API_BASE_URL" not in preview_calls[0]["kwargs"]["env"] - assert "VITE_WS_BASE_URL" not in preview_calls[0]["kwargs"]["env"] - record = service_manager.read_runtime_record(paths.frontend_pid) - assert record is not None - assert record.host == "0.0.0.0" - assert record.port == 5174 + class FakeClient: + def __init__(self, *_args, **_kwargs) -> None: + pass + def __enter__(self): + return self -def test_start_frontend_tolerates_windows_node_assertion_after_build(monkeypatch, tmp_path: Path) -> None: - paths = service_manager.RuntimePaths( - root=tmp_path, - run_dir=tmp_path / "run", - log_dir=tmp_path / "logs", - backend_pid=tmp_path / "run" / "backend.pid", - frontend_pid=tmp_path / "run" / "webui.pid", - backend_log=tmp_path / "logs" / "backend.log", - frontend_log=tmp_path / "logs" / "webui.log", - ) - paths.run_dir.mkdir(parents=True) - paths.log_dir.mkdir(parents=True) - webui_dir = tmp_path / "webui" - webui_dist = webui_dir / "dist" - webui_dist.mkdir(parents=True) - console = DummyConsole() - preview_calls: list[list[str]] = [] + def __exit__(self, *_args) -> None: + return None - def fake_run(_command, **_kwargs): - (webui_dist / "index.html").write_text("", encoding="utf-8") - return SimpleNamespace( - returncode=3221226505, - stdout="built in 6.83s", - stderr="Assertion failed: !(handle->flags & UV_HANDLE_CLOSING), file src\\win\\async.c, line 76", - ) + def get(self, _url, **_kwargs): + return httpx.Response(503, json={"status": "unhealthy"}) - def fake_spawn(command, **_kwargs): - preview_calls.append(list(command)) - return SimpleNamespace(pid=2468) + monkeypatch.setattr(service_process.httpx, "Client", FakeClient) + monkeypatch.setattr(service_process, "tcp_port_accepts_connections", lambda *_args: True) + monkeypatch.setattr(service_manager, "_terminate_process", lambda _process, name, _console: calls.append(f"stop:{name}")) + monkeypatch.setattr( + service_manager, + "_start_backend_process", + lambda *_args, **_kwargs: calls.append("start:backend") or _fake_process(333, ["backend-new"]), + ) - monkeypatch.setattr(service_manager.sys, "platform", "win32") - monkeypatch.setattr(service_manager, "ensure_install_layout", lambda: tmp_path) + daemon.tick() + assert calls == [] + assert daemon.backend.state == "degraded" + + daemon.tick() + assert calls == ["stop:后端", "start:backend"] + + +def test_backend_probe_rejects_api_root_when_static_webui_missing(monkeypatch) -> None: + class FakeClient: + def __init__(self, *_args, **_kwargs) -> None: + pass + + def __enter__(self): + return self + + def __exit__(self, *_args) -> None: + return None + + def get(self, url, **_kwargs): + if str(url).endswith("/api/health"): + return httpx.Response(200, json={"status": "healthy"}) + return httpx.Response(200, json={"status": "running"}) + + monkeypatch.setattr(service_process, "tcp_port_accepts_connections", lambda *_args: True) + monkeypatch.setattr(service_process.httpx, "Client", FakeClient) + + result = service_process.BackendProcessAdapter().probe(_fake_process(111, ["backend"]), "127.0.0.1", 5173) + + assert result.healthy is False + assert result.reason == "health status=200, root status=200" + + +def test_supervisor_reports_webui_as_static_endpoint(monkeypatch, tmp_path: Path) -> None: + paths = _make_runtime_paths(tmp_path) + calls: list[str] = [] monkeypatch.setattr(service_manager, "ensure_runtime_dirs", lambda: paths) - monkeypatch.setattr(service_manager, "cleanup_stale_pid_file", lambda _path: None) - monkeypatch.setattr(service_manager, "port_owner_pids", lambda _port: []) - monkeypatch.setattr(service_manager, "wait_for_http", lambda *_args, **_kwargs: None) - monkeypatch.setattr(service_manager, "resolve_npm_executable", lambda: "npm.cmd") - monkeypatch.setattr(service_manager, "node_version_satisfies_requirement", lambda: True) - monkeypatch.setattr(service_manager.subprocess, "run", fake_run) - monkeypatch.setattr(service_manager, "_spawn_process", fake_spawn) + daemon = service_supervisor.SupervisorDaemon(service_manager.ServiceConfig(backend_port=9995, frontend_port=9996)) + daemon.paths = paths + daemon.backend.process = _fake_process(111, ["backend"]) - service_manager.start_frontend(service_manager.ServiceConfig(), console) + daemon.tick() - assert preview_calls[0][:3] == ["npm.cmd", "run", "preview"] - assert "[flocks] WebUI 构建产物已生成,忽略 Windows Node.js 退出断言。" in console.messages + assert calls == [] + assert daemon.webui.pid is None + assert daemon.webui.state == "static" -def test_start_frontend_passes_direct_backend_urls_when_opted_in(monkeypatch, tmp_path: Path) -> None: - paths = service_manager.RuntimePaths( - root=tmp_path, - run_dir=tmp_path / "run", - log_dir=tmp_path / "logs", - backend_pid=tmp_path / "run" / "backend.pid", - frontend_pid=tmp_path / "run" / "webui.pid", - backend_log=tmp_path / "logs" / "backend.log", - frontend_log=tmp_path / "logs" / "webui.log", - ) +@pytest.mark.skipif(sys.platform == "win32", reason="uses the Unix domain socket control API") +def test_supervisor_rejects_static_webui_stop_control_api(monkeypatch, tmp_path: Path) -> None: + del tmp_path + short_root = make_short_runtime_root("flocks-supervisor-") + paths = _make_runtime_paths(short_root) paths.run_dir.mkdir(parents=True) paths.log_dir.mkdir(parents=True) - console = DummyConsole() - build_calls: list[dict[str, object]] = [] - preview_calls: list[dict[str, object]] = [] + monkeypatch.setattr(service_manager, "ensure_runtime_dirs", lambda: paths) + daemon = service_supervisor.SupervisorDaemon(service_manager.ServiceConfig()) + daemon._start_control_server() - def fake_run(command, **kwargs): - build_calls.append({"command": command, "kwargs": kwargs}) - return SimpleNamespace(returncode=0) + try: + with pytest.raises(httpx.HTTPStatusError) as exc_info: + service_control.control_api_request("POST", "/stop/webui", paths=paths) + finally: + daemon._stop_control_server() + shutil.rmtree(short_root, ignore_errors=True) - def fake_spawn(command, **kwargs): - preview_calls.append({"command": command, "kwargs": kwargs}) - return SimpleNamespace(pid=2468) + assert exc_info.value.response.status_code == 409 - monkeypatch.setattr(service_manager, "ensure_install_layout", lambda: tmp_path) + +@pytest.mark.skipif(sys.platform == "win32", reason="uses the Unix domain socket control API") +def test_supervisor_upgrade_prepare_control_api_pauses_real_child_restart(monkeypatch, tmp_path: Path) -> None: + del tmp_path + short_root = make_short_runtime_root("flocks-supervisor-") + paths = _make_runtime_paths(short_root) + paths.run_dir.mkdir(parents=True) + paths.log_dir.mkdir(parents=True) monkeypatch.setattr(service_manager, "ensure_runtime_dirs", lambda: paths) - monkeypatch.setattr(service_manager, "cleanup_stale_pid_file", lambda _path: None) - monkeypatch.setattr(service_manager, "port_owner_pids", lambda _port: []) - monkeypatch.setattr(service_manager, "wait_for_http", lambda *_args, **_kwargs: None) - monkeypatch.setattr(service_manager.os, "getpgid", lambda pid: pid) - monkeypatch.setattr(service_manager, "resolve_npm_executable", lambda: "/usr/bin/npm") + backend_adapter = SleeperProcessAdapter() + daemon = service_supervisor.SupervisorDaemon( + service_manager.ServiceConfig(backend_port=9995, frontend_port=9996), + backend_adapter=backend_adapter, + ) + daemon._start_control_server() + + try: + daemon.restart_all(reason="test startup") + backend_process = daemon.backend.process + assert backend_process is not None + assert daemon.webui.process is None + assert daemon.webui.state == "static" + + status = service_control.request_prepare_upgrade(paths=paths) + + wait_for_process_exit(backend_process) + assert status.backend.paused is True + assert status.webui.paused is True + assert daemon.backend.process is None + assert backend_process.pid in backend_adapter.stopped + + daemon.tick() + + assert len(backend_adapter.started) == 1 + assert daemon.backend.process is None + assert daemon.status_payload()["backend"]["paused"] is True + finally: + daemon.shutdown_children() + daemon._stop_control_server() + shutil.rmtree(short_root, ignore_errors=True) + + +def test_build_webui_dist_tolerates_windows_node_assertion_after_build(monkeypatch, tmp_path: Path) -> None: + webui_dir = tmp_path / "webui" + webui_dist = webui_dir / "dist" + webui_dist.mkdir(parents=True) + (webui_dir / "package.json").write_text("{}", encoding="utf-8") + console = DummyConsole() + + def fake_run(_command, **_kwargs): + (webui_dist / "index.html").write_text("", encoding="utf-8") + return SimpleNamespace( + returncode=3221226505, + stdout="built in 6.83s", + stderr="Assertion failed: !(handle->flags & UV_HANDLE_CLOSING), file src\\win\\async.c, line 76", + ) + + monkeypatch.setattr(service_manager.sys, "platform", "win32") + monkeypatch.setattr(service_manager, "resolve_npm_executable", lambda: "npm.cmd") monkeypatch.setattr(service_manager, "node_version_satisfies_requirement", lambda: True) monkeypatch.setattr(service_manager.subprocess, "run", fake_run) - monkeypatch.setattr(service_manager, "_spawn_process", fake_spawn) - monkeypatch.setenv(service_manager.WEBUI_DIRECT_BACKEND_URLS_ENV, "true") - config = service_manager.ServiceConfig( - backend_host="10.0.0.8", - backend_port=9000, - frontend_host="0.0.0.0", - frontend_port=5174, - ) - service_manager.start_frontend(config, console) + service_manager._build_webui_dist(tmp_path, service_manager.ServiceConfig(), console) - assert build_calls[0]["kwargs"]["env"]["VITE_API_BASE_URL"] == "http://10.0.0.8:9000" - assert build_calls[0]["kwargs"]["env"]["VITE_WS_BASE_URL"] == "ws://10.0.0.8:9000" - assert preview_calls[0]["kwargs"]["env"]["VITE_API_BASE_URL"] == "http://10.0.0.8:9000" - assert preview_calls[0]["kwargs"]["env"]["VITE_WS_BASE_URL"] == "ws://10.0.0.8:9000" + assert "[flocks] WebUI 构建产物已生成,忽略 Windows Node.js 退出断言。" in console.messages -def test_start_frontend_prefers_bundled_npm_over_path_lookup(monkeypatch, tmp_path: Path) -> None: - paths = service_manager.RuntimePaths( - root=tmp_path, - run_dir=tmp_path / "run", - log_dir=tmp_path / "logs", - backend_pid=tmp_path / "run" / "backend.pid", - frontend_pid=tmp_path / "run" / "webui.pid", - backend_log=tmp_path / "logs" / "backend.log", - frontend_log=tmp_path / "logs" / "webui.log", - ) - paths.run_dir.mkdir(parents=True) - paths.log_dir.mkdir(parents=True) +def test_build_webui_dist_prefers_bundled_npm_over_path_lookup(monkeypatch, tmp_path: Path) -> None: + webui_dir = tmp_path / "webui" + webui_dir.mkdir() + (webui_dir / "package.json").write_text("{}", encoding="utf-8") console = DummyConsole() build_calls: list[list[str]] = [] @@ -1325,23 +1701,16 @@ def fake_run(command, **_kwargs): build_calls.append(command) return SimpleNamespace(returncode=0) - monkeypatch.setattr(service_manager, "ensure_install_layout", lambda: tmp_path) - monkeypatch.setattr(service_manager, "ensure_runtime_dirs", lambda: paths) - monkeypatch.setattr(service_manager, "cleanup_stale_pid_file", lambda _path: None) - monkeypatch.setattr(service_manager, "port_owner_pids", lambda _port: []) - monkeypatch.setattr(service_manager, "wait_for_http", lambda *_args, **_kwargs: None) - monkeypatch.setattr(service_manager.os, "getpgid", lambda pid: pid) monkeypatch.setattr(service_manager, "resolve_npm_executable", lambda: r"C:\Users\flocks\AppData\Local\Programs\Flocks\tools\node\npm.cmd") monkeypatch.setattr(service_manager, "node_version_satisfies_requirement", lambda: True) monkeypatch.setattr(service_manager.subprocess, "run", fake_run) - monkeypatch.setattr(service_manager, "_spawn_process", lambda *_args, **_kwargs: SimpleNamespace(pid=2468)) - service_manager.start_frontend(service_manager.ServiceConfig(), console) + service_manager._build_webui_dist(tmp_path, service_manager.ServiceConfig(), console) assert build_calls[0][0] == r"C:\Users\flocks\AppData\Local\Programs\Flocks\tools\node\npm.cmd" -def test_start_backend_raises_on_port_record_mismatch(monkeypatch, tmp_path: Path) -> None: +def test_start_backend_raises_when_port_has_listener(monkeypatch, tmp_path: Path) -> None: paths = service_manager.RuntimePaths( root=tmp_path, run_dir=tmp_path / "run", @@ -1353,15 +1722,68 @@ def test_start_backend_raises_on_port_record_mismatch(monkeypatch, tmp_path: Pat ) paths.run_dir.mkdir(parents=True) paths.log_dir.mkdir(parents=True) - service_manager.write_runtime_record(paths.backend_pid, service_manager.RuntimeRecord(pid=1111, port=8000)) + _write_legacy_runtime_record(paths.backend_pid, service_manager.RuntimeRecord(pid=1111, port=8000)) monkeypatch.setattr(service_manager, "ensure_install_layout", lambda: tmp_path) monkeypatch.setattr(service_manager, "ensure_runtime_dirs", lambda: paths) monkeypatch.setattr(service_manager, "cleanup_stale_pid_file", lambda _path: None) monkeypatch.setattr(service_manager, "port_owner_pids", lambda _port: [9999]) - with pytest.raises(service_manager.ServiceError, match="运行时记录不一致"): - service_manager.start_backend(service_manager.ServiceConfig(), DummyConsole()) + with pytest.raises(service_manager.ServiceError, match="端口 5173 已被占用"): + service_manager._start_backend_process(service_manager.ServiceConfig(), DummyConsole()) + + +def test_start_backend_cleans_trusted_orphan_port_owner(monkeypatch, tmp_path: Path) -> None: + paths = _make_runtime_paths(tmp_path) + paths.run_dir.mkdir(parents=True) + paths.log_dir.mkdir(parents=True) + owners = iter([[9999], [9999], [], [], []]) + cleaned: list[int] = [] + + monkeypatch.setattr(service_manager, "ensure_install_layout", lambda: tmp_path) + monkeypatch.setattr(service_manager, "ensure_runtime_dirs", lambda: paths) + monkeypatch.setattr(service_manager, "port_owner_pids", lambda _port: next(owners)) + monkeypatch.setattr(service_manager, "_process_command_line", lambda _pid: f"{tmp_path}/.venv/bin/python -m flocks.cli.main serve") + monkeypatch.setattr(service_manager, "_terminate_orphan_pid", lambda pid, *_args, **_kwargs: cleaned.append(pid)) + monkeypatch.setattr(service_manager, "port_is_in_use", lambda *_args, **_kwargs: False) + monkeypatch.setattr(service_manager, "resolve_flocks_cli_command", lambda _root: ["/env/bin/python", "-m", "flocks.cli.main"]) + monkeypatch.setattr(service_manager, "_spawn_process", lambda command, **_kwargs: SimpleNamespace(pid=1234, args=command)) + monkeypatch.setattr(service_manager, "process_runtime_record", lambda *_args, **_kwargs: service_manager.RuntimeRecord(pid=1234)) + monkeypatch.setattr(service_manager, "_log_startup_config", lambda *_args, **_kwargs: None) + monkeypatch.setattr(service_manager, "wait_for_http", lambda *_args, **_kwargs: None) + + process = service_manager._start_backend_process(service_manager.ServiceConfig(), DummyConsole(), paths=paths) + + assert process.pid == 1234 + assert cleaned == [9999] + + +def test_backend_cleanup_trusts_cross_worktree_flocks_uvicorn_owner(monkeypatch, tmp_path: Path) -> None: + cleaned: list[int] = [] + owners = iter([[18787], []]) + + monkeypatch.setattr(service_manager, "port_owner_pids", lambda _port: next(owners)) + monkeypatch.setattr( + service_manager, + "_process_command_line", + lambda _pid: ( + "/Users/zgy/.codex/worktrees/6be0/flocks/.venv/bin/python " + "/Users/zgy/.codex/worktrees/6be0/flocks/.venv/bin/uvicorn " + "flocks.server.app:app --host 127.0.0.1 --port 8000 --reload --reload-dir flocks" + ), + ) + monkeypatch.setattr(service_manager, "_terminate_orphan_pid", lambda pid, *_args, **_kwargs: cleaned.append(pid)) + + result = service_manager.cleanup_trusted_port_owners( + 8000, + service="backend", + label="后端", + console=DummyConsole(), + root=tmp_path, + ) + + assert result == [18787] + assert cleaned == [18787] def test_start_backend_raises_when_port_in_use_without_pid_lookup(monkeypatch, tmp_path: Path) -> None: @@ -1384,7 +1806,81 @@ def test_start_backend_raises_when_port_in_use_without_pid_lookup(monkeypatch, t monkeypatch.setattr(service_manager, "port_is_in_use", lambda _port, listeners=None: True) with pytest.raises(service_manager.ServiceError, match="无法识别占用 PID"): - service_manager.start_backend(service_manager.ServiceConfig(), DummyConsole()) + service_manager._start_backend_process(service_manager.ServiceConfig(), DummyConsole()) + + +def test_webui_cleanup_trusts_cross_worktree_flocks_vite_owner(monkeypatch, tmp_path: Path) -> None: + cleaned: list[int] = [] + owners = iter([[18962], []]) + + monkeypatch.setattr(service_manager, "port_owner_pids", lambda _port: next(owners)) + monkeypatch.setattr( + service_manager, + "_process_command_line", + lambda _pid: ( + "node /Users/zgy/.codex/worktrees/6be0/flocks/webui/node_modules/.bin/vite --host 127.0.0.1 --port 5173" + ), + ) + monkeypatch.setattr(service_manager, "_terminate_orphan_pid", lambda pid, *_args, **_kwargs: cleaned.append(pid)) + + result = service_manager.cleanup_trusted_port_owners( + 5173, + service="webui", + label="WebUI", + console=DummyConsole(), + root=tmp_path, + ) + + assert result == [18962] + assert cleaned == [18962] + + +def test_cleanup_trusted_daemon_processes_cleans_current_install_only(monkeypatch, tmp_path: Path) -> None: + cleaned: list[int] = [] + + monkeypatch.setattr(service_manager, "_process_list_pids", lambda: [111, 222, 333]) + monkeypatch.setattr( + service_manager, + "_process_command_line", + lambda pid: { + 111: f"{tmp_path}/.venv/bin/python -m flocks.cli.main service-daemon --server-port 8000", + 222: "/other/flocks/.venv/bin/python -m flocks.cli.main service-daemon --server-port 8000", + 333: f"{tmp_path}/.venv/bin/python -m flocks.cli.main serve --port 8000", + }[pid], + ) + monkeypatch.setattr(service_manager, "_terminate_orphan_pid", lambda pid, *_args, **_kwargs: cleaned.append(pid)) + + result = service_manager.cleanup_trusted_daemon_processes(console=DummyConsole(), root=tmp_path) + + assert result == [111] + assert cleaned == [111] + + +def test_windows_cleanup_trusted_daemon_processes_uses_single_query(monkeypatch, tmp_path: Path) -> None: + cleaned: list[int] = [] + commands: list[list[str]] = [] + + def fail_per_pid_lookup(_pid: int) -> str: + raise AssertionError("Windows daemon cleanup should not query each pid separately") + + def fake_run(command, **kwargs): + commands.append(command) + assert command[:2] == ["powershell.exe", "-NoProfile"] + assert kwargs["env"]["FLOCKS_DAEMON_ROOT_MATCH"] == str(tmp_path).lower() + return SimpleNamespace(returncode=0, stdout="111\n222\n111\n") + + monkeypatch.setattr(service_manager.sys, "platform", "win32") + monkeypatch.setattr(service_manager, "which", lambda name: "powershell.exe" if name == "powershell" else None) + monkeypatch.setattr(service_manager, "_process_list_pids", lambda: [111, 222, 333]) + monkeypatch.setattr(service_manager, "_process_command_line", fail_per_pid_lookup) + monkeypatch.setattr(service_manager.subprocess, "run", fake_run) + monkeypatch.setattr(service_manager, "_terminate_orphan_pid", lambda pid, *_args, **_kwargs: cleaned.append(pid)) + + result = service_manager.cleanup_trusted_daemon_processes(console=DummyConsole(), root=tmp_path) + + assert result == [111, 222] + assert cleaned == [111, 222] + assert len(commands) == 1 def test_spawn_process_uses_hidden_window_flags_on_windows(monkeypatch, tmp_path: Path) -> None: @@ -1435,10 +1931,12 @@ def fake_popen(*args, **kwargs): monkeypatch.setattr(service_manager.sys, "platform", "darwin") monkeypatch.setattr(service_manager.subprocess, "Popen", fake_popen) + monkeypatch.setattr(service_manager.os, "getpgid", lambda pid: 4321 if pid == 9876 else pid) process = service_manager._spawn_process(["python", "-m", "uvicorn"], cwd=tmp_path, log_path=log_path) assert process.pid == 9876 + assert process._flocks_pgid == 4321 assert captured["args"] == (["python", "-m", "uvicorn"],) assert captured["kwargs"]["cwd"] == tmp_path assert captured["kwargs"]["creationflags"] == 0 @@ -1446,6 +1944,54 @@ def fake_popen(*args, **kwargs): assert "startupinfo" not in captured["kwargs"] +def test_terminate_process_stops_cached_process_group_after_root_exits(monkeypatch) -> None: + signals: list[tuple[str, int]] = [] + group_running = iter([True, False]) + process = SimpleNamespace(pid=9876, returncode=0, poll=lambda: 0, _flocks_pgid=4321) + + monkeypatch.setattr(service_manager.sys, "platform", "darwin") + monkeypatch.setattr(service_manager, "process_group_is_running", lambda _pgid: next(group_running)) + monkeypatch.setattr( + service_manager, + "signal_process_group", + lambda sig, pgid: signals.append((sig.name, pgid)), + ) + monkeypatch.setattr(service_manager, "signal_pid_list", lambda *_args, **_kwargs: None) + monkeypatch.setattr(service_manager, "collect_process_tree_pids", lambda _pid: []) + + service_manager._terminate_process(process, "WebUI", DummyConsole(), timeout=0.1) + + assert signals == [("SIGTERM", 4321)] + + +def test_terminate_orphan_pid_stops_process_group(monkeypatch) -> None: + signals: list[tuple[str, int | tuple[int, ...] | None]] = [] + + monkeypatch.setattr(service_manager.sys, "platform", "darwin") + monkeypatch.setattr(service_manager.os, "getpgid", lambda pid: 18745 if pid == 18787 else pid) + monkeypatch.setattr(service_manager.os, "getpgrp", lambda: 99999) + monkeypatch.setattr(service_manager, "collect_process_tree_pids", lambda _pid: [18787, 18873]) + monkeypatch.setattr(service_manager, "pid_is_running", lambda _pid: False) + monkeypatch.setattr(service_manager, "process_group_is_running", lambda _pgid: False) + monkeypatch.setattr( + service_manager, + "signal_process_group", + lambda sig, pgid: signals.append((sig.name, pgid)), + ) + monkeypatch.setattr( + service_manager, + "signal_pid_list", + lambda sig, pids: signals.append((sig.name, tuple(pids))), + ) + + service_manager._terminate_orphan_pid(18787, "后端", DummyConsole(), timeout=0.1) + + assert signals == [ + ("SIGTERM", 18745), + ("SIGTERM", (18787, 18873)), + ] + + def test_spawn_process_appends_without_rotated_suffix(monkeypatch, tmp_path: Path) -> None: log_path = tmp_path / "logs" / "backend.log" log_path.parent.mkdir(parents=True) @@ -1515,309 +2061,11 @@ def fake_popen(*args, **kwargs): assert captured["kwargs"]["env"] == env -def test_stop_one_prefers_process_group_on_unix(monkeypatch, tmp_path: Path) -> None: - pid_file = tmp_path / "backend.pid" - service_manager.write_runtime_record( - pid_file, - service_manager.RuntimeRecord(pid=111, pgid=222, port=8000), - ) - console = DummyConsole() - group_alive = {"value": True} - group_signals: list[tuple[signal.Signals, int | None]] = [] - pid_signals: list[tuple[signal.Signals, list[int]]] = [] - - monkeypatch.setattr(service_manager.sys, "platform", "darwin") - monkeypatch.setattr(service_manager, "collect_process_tree_pids", lambda _pid: [111, 112]) - monkeypatch.setattr(service_manager, "port_owner_pids", lambda _port: []) - monkeypatch.setattr(service_manager, "pid_is_running", lambda _pid: False) - monkeypatch.setattr(service_manager, "process_group_is_running", lambda pgid: bool(pgid == 222 and group_alive["value"])) - - def fake_signal_group(sig, pgid): - group_signals.append((sig, pgid)) - if sig == signal.SIGTERM: - group_alive["value"] = False - - monkeypatch.setattr(service_manager, "signal_process_group", fake_signal_group) - monkeypatch.setattr( - service_manager, - "signal_pid_list", - lambda sig, pids: pid_signals.append((sig, list(pids))), - ) - - service_manager.stop_one(8000, pid_file, "后端", console) - - assert group_signals == [(signal.SIGTERM, 222)] - assert pid_signals == [] - assert not pid_file.exists() - - -def test_stop_one_falls_back_to_pid_signals_without_process_group(monkeypatch, tmp_path: Path) -> None: - pid_file = tmp_path / "backend.pid" - pid_file.write_text("111", encoding="utf-8") - console = DummyConsole() - pid_signals: list[tuple[signal.Signals, list[int]]] = [] - alive = {"value": True} - - monkeypatch.setattr(service_manager.sys, "platform", "darwin") - monkeypatch.setattr(service_manager, "collect_process_tree_pids", lambda _pid: [111, 112]) - monkeypatch.setattr(service_manager, "port_owner_pids", lambda _port: []) - monkeypatch.setattr(service_manager, "pid_is_running", lambda _pid: alive["value"]) - monkeypatch.setattr(service_manager, "process_group_is_running", lambda _pgid: False) - monkeypatch.setattr( - service_manager, - "signal_pid_list", - lambda sig, pids: ( - pid_signals.append((sig, list(pids))), - alive.__setitem__("value", False), - ), - ) - - service_manager.stop_one(8000, pid_file, "后端", console) - - assert pid_signals[0] == (signal.SIGTERM, [111, 112]) - assert not pid_file.exists() - - -def test_stop_one_uses_taskkill_on_windows(monkeypatch, tmp_path: Path) -> None: - pid_file = tmp_path / "backend.pid" - pid_file.write_text("111", encoding="utf-8") - console = DummyConsole() - commands: list[list[str]] = [] - alive = {"value": True} - - monkeypatch.setattr(service_manager.sys, "platform", "win32") - monkeypatch.setattr(service_manager, "collect_process_tree_pids", lambda _pid: [111, 222]) - monkeypatch.setattr(service_manager, "port_owner_pids", lambda _port: []) - monkeypatch.setattr(service_manager, "pid_is_running", lambda _pid: alive["value"]) - - def fake_run(args, **kwargs): - commands.append(list(args)) - alive["value"] = False - return SimpleNamespace(returncode=0) - - monkeypatch.setattr(service_manager.subprocess, "run", fake_run) - service_manager.stop_one(8000, pid_file, "后端", console) - - assert commands == [ - ["taskkill", "/PID", "111", "/T", "/F"], - ["taskkill", "/PID", "222", "/T", "/F"], - ] - - -def test_stop_one_skips_taskkill_for_reused_windows_pid(monkeypatch, tmp_path: Path) -> None: - pid_file = tmp_path / "backend.pid" - service_manager.write_runtime_record( - pid_file, - service_manager.RuntimeRecord( - pid=111, - host="127.0.0.1", - port=8000, - command=("python.exe", "-m", "flocks.cli.main", "serve"), - ), - ) - console = DummyConsole() - - monkeypatch.setattr(service_manager.sys, "platform", "win32") - monkeypatch.setattr(service_manager, "collect_process_tree_pids", lambda _pid: [111]) - monkeypatch.setattr(service_manager, "port_owner_pids", lambda _port: []) - monkeypatch.setattr(service_manager, "pid_is_running", lambda pid: pid == 111) - monkeypatch.setattr( - service_manager, - "_windows_process_snapshot", - lambda _pid: { - "name": "svchost.exe", - "command_line": r"C:\Windows\System32\svchost.exe -k netsvcs", - "executable_path": r"C:\Windows\System32\svchost.exe", - }, - ) - monkeypatch.setattr( - service_manager.subprocess, - "run", - lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("taskkill should not run")), - ) - - service_manager.stop_one(8000, pid_file, "后端", console) - - assert console.messages[-1] == "[flocks] 后端 未运行。" - assert not pid_file.exists() - - -def test_stop_one_force_kill_refreshes_process_group_members(monkeypatch, tmp_path: Path) -> None: - pid_file = tmp_path / "backend.pid" - service_manager.write_runtime_record( - pid_file, - service_manager.RuntimeRecord(pid=111, pgid=222, port=8000), - ) - console = DummyConsole() - pid_signals: list[tuple[signal.Signals, list[int]]] = [] - group_signals: list[tuple[signal.Signals, int | None]] = [] - alive_group_members = {333} - - monkeypatch.setattr(service_manager.sys, "platform", "darwin") - monkeypatch.setattr(service_manager, "collect_process_tree_pids", lambda _pid: [111]) - monkeypatch.setattr(service_manager, "_process_group_member_pids", lambda pgid: [333] if pgid == 222 and alive_group_members else []) - monkeypatch.setattr(service_manager, "port_owner_pids", lambda _port: []) - monkeypatch.setattr(service_manager, "pid_is_running", lambda pid: pid in alive_group_members) - monkeypatch.setattr(service_manager, "process_group_is_running", lambda pgid: bool(pgid == 222 and alive_group_members)) - monkeypatch.setattr(service_manager.time, "sleep", lambda _delay: None) - - def fake_signal_group(sig, pgid): - group_signals.append((sig, pgid)) - - def fake_signal_pid_list(sig, pids): - pid_list = list(pids) - pid_signals.append((sig, pid_list)) - if sig == signal.SIGKILL and 333 in pid_list: - alive_group_members.clear() - - monkeypatch.setattr(service_manager, "signal_process_group", fake_signal_group) - monkeypatch.setattr(service_manager, "signal_pid_list", fake_signal_pid_list) - - service_manager.stop_one(8000, pid_file, "后端", console) - - assert (signal.SIGTERM, 222) in group_signals - assert any(sig == signal.SIGKILL and 333 in pids for sig, pids in pid_signals) - assert not pid_file.exists() - assert console.messages[-1] == "[flocks] 后端 已停止。" - - -def test_stop_one_keeps_runtime_record_when_force_kill_still_times_out(monkeypatch, tmp_path: Path) -> None: - pid_file = tmp_path / "backend.pid" - service_manager.write_runtime_record( - pid_file, - service_manager.RuntimeRecord(pid=111, pgid=222, port=8000), - ) - console = DummyConsole() - - monkeypatch.setattr(service_manager.sys, "platform", "darwin") - monkeypatch.setattr(service_manager, "collect_process_tree_pids", lambda _pid: [111]) - monkeypatch.setattr(service_manager, "_process_group_member_pids", lambda pgid: [333] if pgid == 222 else []) - monkeypatch.setattr(service_manager, "port_owner_pids", lambda _port: []) - monkeypatch.setattr(service_manager, "pid_is_running", lambda _pid: False) - monkeypatch.setattr(service_manager, "process_group_is_running", lambda pgid: pgid == 222) - monkeypatch.setattr(service_manager, "signal_process_group", lambda *_args: None) - monkeypatch.setattr(service_manager, "signal_pid_list", lambda *_args: None) - monkeypatch.setattr(service_manager.time, "sleep", lambda _delay: None) - - with pytest.raises(service_manager.ServiceError, match="未在预期时间内退出"): - service_manager.stop_one(8000, pid_file, "后端", console) - - assert pid_file.exists() - - -@contextlib.contextmanager -def _record_call(call_order: list[str], name: str): - call_order.append(name) - yield - - -def test_stop_all_reads_port_from_runtime_record(monkeypatch, tmp_path: Path) -> None: - paths = service_manager.RuntimePaths( - root=tmp_path, - run_dir=tmp_path / "run", - log_dir=tmp_path / "logs", - backend_pid=tmp_path / "run" / "backend.pid", - frontend_pid=tmp_path / "run" / "webui.pid", - backend_log=tmp_path / "logs" / "backend.log", - frontend_log=tmp_path / "logs" / "webui.log", - ) - paths.run_dir.mkdir(parents=True) - service_manager.write_runtime_record(paths.backend_pid, service_manager.RuntimeRecord(pid=111, port=9995)) - service_manager.write_runtime_record(paths.frontend_pid, service_manager.RuntimeRecord(pid=222, port=9996)) - calls: list[tuple[int, Path, str]] = [] - - monkeypatch.setattr(service_manager, "ensure_runtime_dirs", lambda: paths) - monkeypatch.setattr(service_manager, "service_lock", lambda _paths: _record_call([], "service_lock")) - monkeypatch.setattr(service_manager, "stop_all_browser_daemons", lambda: []) - monkeypatch.setattr( - service_manager, - "stop_one", - lambda port, pid_file, name, _console: calls.append((port, pid_file, name)), - ) - - service_manager.stop_all(console=None) - - assert calls == [ - (9996, paths.frontend_pid, "WebUI"), - (9995, paths.backend_pid, "后端"), - ] - - -def test_stop_all_falls_back_to_default_port_when_record_missing(monkeypatch, tmp_path: Path) -> None: - paths = service_manager.RuntimePaths( - root=tmp_path, - run_dir=tmp_path / "run", - log_dir=tmp_path / "logs", - backend_pid=tmp_path / "run" / "backend.pid", - frontend_pid=tmp_path / "run" / "webui.pid", - backend_log=tmp_path / "logs" / "backend.log", - frontend_log=tmp_path / "logs" / "webui.log", - ) - paths.run_dir.mkdir(parents=True) - calls: list[int] = [] - - monkeypatch.setattr(service_manager, "ensure_runtime_dirs", lambda: paths) - monkeypatch.setattr(service_manager, "service_lock", lambda _paths: _record_call([], "service_lock")) - monkeypatch.setattr(service_manager, "stop_all_browser_daemons", lambda: []) - monkeypatch.setattr(service_manager, "stop_one", lambda port, *_args: calls.append(port)) - - service_manager.stop_all(console=None) - - assert calls == [5173, 8000] - - -def test_stop_all_falls_back_to_default_port_when_record_has_no_port(monkeypatch, tmp_path: Path) -> None: - paths = service_manager.RuntimePaths( - root=tmp_path, - run_dir=tmp_path / "run", - log_dir=tmp_path / "logs", - backend_pid=tmp_path / "run" / "backend.pid", - frontend_pid=tmp_path / "run" / "webui.pid", - backend_log=tmp_path / "logs" / "backend.log", - frontend_log=tmp_path / "logs" / "webui.log", - ) - paths.run_dir.mkdir(parents=True) - paths.backend_pid.write_text("111", encoding="utf-8") - paths.frontend_pid.write_text("222", encoding="utf-8") - calls: list[int] = [] - - monkeypatch.setattr(service_manager, "ensure_runtime_dirs", lambda: paths) - monkeypatch.setattr(service_manager, "service_lock", lambda _paths: _record_call([], "service_lock")) - monkeypatch.setattr(service_manager, "stop_all_browser_daemons", lambda: []) - monkeypatch.setattr(service_manager, "stop_one", lambda port, *_args: calls.append(port)) - - service_manager.stop_all(console=None) - - assert calls == [5173, 8000] - - -def test_stop_all_also_cleans_browser_daemons(monkeypatch, tmp_path: Path) -> None: - paths = service_manager.RuntimePaths( - root=tmp_path, - run_dir=tmp_path / "run", - log_dir=tmp_path / "logs", - backend_pid=tmp_path / "run" / "backend.pid", - frontend_pid=tmp_path / "run" / "webui.pid", - backend_log=tmp_path / "logs" / "backend.log", - frontend_log=tmp_path / "logs" / "webui.log", - ) - paths.run_dir.mkdir(parents=True) +def test_stop_all_uses_supervisor_control_api(monkeypatch, tmp_path: Path) -> None: + paths = _make_runtime_paths(tmp_path) calls: list[str] = [] - monkeypatch.setattr(service_manager, "ensure_runtime_dirs", lambda: paths) - monkeypatch.setattr(service_manager, "service_lock", lambda _paths: _record_call([], "service_lock")) - monkeypatch.setattr( - service_manager, - "stop_one", - lambda _port, _pid_file, name, _console: calls.append(name), - ) - monkeypatch.setattr( - service_manager, - "stop_all_browser_daemons", - lambda: calls.append("browser") or ["default", "remote"], - ) - class FakeConsole: def __init__(self) -> None: self.messages = [] @@ -1825,159 +2073,87 @@ def __init__(self) -> None: def print(self, message) -> None: self.messages.append(message) + states = iter([True, False]) + payload = _supervisor_status_payload() + monkeypatch.setattr(service_manager, "ensure_runtime_dirs", lambda: paths) + monkeypatch.setattr(service_manager, "supervisor_is_running", lambda _paths: next(states)) + monkeypatch.setattr(service_manager, "read_supervisor_status", lambda *_args, **_kwargs: _supervisor_status(payload)) + monkeypatch.setattr(service_manager, "request_stop", lambda **_kwargs: calls.append("/stop") or {"status": "stopping"}) + monkeypatch.setattr(service_manager, "cleanup_legacy_runtime_processes", lambda _paths, _console: calls.append("legacy")) + monkeypatch.setattr(service_manager, "cleanup_orphan_service_ports", lambda _config, _console, **_kwargs: calls.append("cleanup")) + monkeypatch.setattr(service_manager, "stop_all_browser_daemons", lambda: calls.append("browser")) + console = FakeConsole() service_manager.stop_all(console=console) - assert calls == ["WebUI", "后端", "browser"] - assert console.messages == [] + assert calls == ["/stop", "legacy", "cleanup", "browser"] + assert console.messages == [ + "[flocks] flocks 已停止(PID=111)。", + "[flocks] daemon 已停止。", + ] -def test_build_status_lines_reads_port_from_runtime_record(monkeypatch, tmp_path: Path) -> None: - paths = service_manager.RuntimePaths( - root=tmp_path, - run_dir=tmp_path / "run", - log_dir=tmp_path / "logs", - backend_pid=tmp_path / "run" / "backend.pid", - frontend_pid=tmp_path / "run" / "webui.pid", - backend_log=tmp_path / "logs" / "backend.log", - frontend_log=tmp_path / "logs" / "webui.log", - ) - paths.run_dir.mkdir(parents=True) - paths.log_dir.mkdir(parents=True) - service_manager.write_runtime_record(paths.backend_pid, service_manager.RuntimeRecord(pid=111, port=9995)) - service_manager.write_runtime_record(paths.frontend_pid, service_manager.RuntimeRecord(pid=222, port=9996)) +def test_stop_all_reports_when_supervisor_is_down(monkeypatch, tmp_path: Path) -> None: + paths = _make_runtime_paths(tmp_path) + console = DummyConsole() - monkeypatch.setattr(service_manager, "cleanup_stale_pid_file", lambda _path: None) - monkeypatch.setattr(service_manager, "port_owner_pids", lambda port: [port] if port in {9995, 9996} else []) - monkeypatch.setattr(service_manager, "pid_is_running", lambda _pid: False) + monkeypatch.setattr(service_manager, "ensure_runtime_dirs", lambda: paths) + monkeypatch.setattr(service_manager, "supervisor_is_running", lambda _paths: False) + monkeypatch.setattr(service_manager, "cleanup_legacy_runtime_processes", lambda _paths, _console: console.messages.append("legacy")) + monkeypatch.setattr( + service_manager, + "cleanup_orphan_service_ports", + lambda _config, _console, **_kwargs: console.messages.append("cleanup"), + ) + monkeypatch.setattr(service_manager, "stop_all_browser_daemons", lambda: console.messages.append("browser")) - lines = service_manager.build_status_lines(paths) + service_manager.stop_all(console) - assert "http://127.0.0.1:9995" in lines[0] - assert "http://127.0.0.1:9996" in lines[1] + assert console.messages == ["[flocks] Flocks daemon 未运行。", "legacy", "cleanup", "browser"] -def test_build_status_lines_uses_recorded_host(monkeypatch, tmp_path: Path) -> None: - paths = service_manager.RuntimePaths( - root=tmp_path, - run_dir=tmp_path / "run", - log_dir=tmp_path / "logs", - backend_pid=tmp_path / "run" / "backend.pid", - frontend_pid=tmp_path / "run" / "webui.pid", - backend_log=tmp_path / "logs" / "backend.log", - frontend_log=tmp_path / "logs" / "webui.log", - ) +def test_stop_all_uses_legacy_runtime_ports_for_orphan_cleanup(monkeypatch, tmp_path: Path) -> None: + paths = _make_runtime_paths(tmp_path) + console = DummyConsole() + captured: list[service_manager.ServiceConfig] = [] paths.run_dir.mkdir(parents=True) - paths.log_dir.mkdir(parents=True) - service_manager.write_runtime_record( + _write_legacy_runtime_record( paths.backend_pid, - service_manager.RuntimeRecord(pid=111, host="10.0.0.8", port=9000), + service_manager.RuntimeRecord(pid=111, host="0.0.0.0", port=9000), ) - service_manager.write_runtime_record( + _write_legacy_runtime_record( paths.frontend_pid, - service_manager.RuntimeRecord(pid=222, host="0.0.0.0", port=5174), + service_manager.RuntimeRecord(pid=222, host="0.0.0.0", port=5273), ) - monkeypatch.setattr(service_manager, "cleanup_stale_pid_file", lambda _path: None) - monkeypatch.setattr(service_manager, "port_owner_pids", lambda port: [111] if port == 9000 else [222]) - monkeypatch.setattr(service_manager, "pid_is_running", lambda _pid: False) + def fake_cleanup(config, _console, *, extra_configs=()): + captured.append(config) + captured.extend(extra_configs) - lines = service_manager.build_status_lines(paths) + monkeypatch.setattr(service_manager, "ensure_runtime_dirs", lambda: paths) + monkeypatch.setattr(service_manager, "supervisor_is_running", lambda _paths: False) + monkeypatch.setattr(service_manager, "cleanup_legacy_runtime_processes", lambda *_args, **_kwargs: None) + monkeypatch.setattr(service_manager, "cleanup_orphan_service_ports", fake_cleanup) + monkeypatch.setattr(service_manager, "stop_all_browser_daemons", lambda: None) - assert "http://10.0.0.8:9000" in lines[0] - assert "http://127.0.0.1:5174" in lines[1] + service_manager.stop_all(console) + assert [(config.backend_port, config.frontend_port) for config in captured] == [(5173, 5173), (9000, 5273)] -def test_build_status_lines_uses_unknown_pid_when_bind_fallback_detects_listener( - monkeypatch, tmp_path: Path -) -> None: - paths = service_manager.RuntimePaths( - root=tmp_path, - run_dir=tmp_path / "run", - log_dir=tmp_path / "logs", - backend_pid=tmp_path / "run" / "backend.pid", - frontend_pid=tmp_path / "run" / "webui.pid", - backend_log=tmp_path / "logs" / "backend.log", - frontend_log=tmp_path / "logs" / "webui.log", - ) - paths.run_dir.mkdir(parents=True) - paths.log_dir.mkdir(parents=True) - monkeypatch.setattr(service_manager, "cleanup_stale_pid_file", lambda _path: None) - monkeypatch.setattr(service_manager, "port_owner_pids", lambda _port: []) - monkeypatch.setattr(service_manager, "port_is_in_use", lambda _port, listeners=None: True) - monkeypatch.setattr(service_manager, "pid_is_running", lambda _pid: False) - monkeypatch.setattr(service_manager, "process_group_is_running", lambda _pgid: False) +def test_status_lines_include_control_api_errors(monkeypatch, tmp_path: Path) -> None: + paths = _make_runtime_paths(tmp_path) + payload = _supervisor_status_payload() + payload["backend"]["state"] = "degraded" + payload["backend"]["last_error"] = "health failed" + monkeypatch.setattr(service_manager, "read_supervisor_status", lambda *_args, **_kwargs: _supervisor_status(payload)) lines = service_manager.build_status_lines(paths) - assert "PID=unknown" in lines[0] - assert "PID=unknown" in lines[1] - - -def test_service_lock_prevents_concurrent_operations(monkeypatch, tmp_path: Path) -> None: - paths = service_manager.RuntimePaths( - root=tmp_path, - run_dir=tmp_path / "run", - log_dir=tmp_path / "logs", - backend_pid=tmp_path / "run" / "backend.pid", - frontend_pid=tmp_path / "run" / "webui.pid", - backend_log=tmp_path / "logs" / "backend.log", - frontend_log=tmp_path / "logs" / "webui.log", - ) - state = {"locked": False} - - class FakeFcntl: - LOCK_EX = 1 - LOCK_NB = 2 - LOCK_UN = 4 - - @staticmethod - def flock(_handle, operation): - if operation == FakeFcntl.LOCK_UN: - state["locked"] = False - return - if state["locked"]: - raise OSError("busy") - state["locked"] = True - - monkeypatch.setattr(service_manager.sys, "platform", "darwin") - monkeypatch.setattr(service_manager, "fcntl", FakeFcntl) - - with service_manager.service_lock(paths): - with pytest.raises(service_manager.ServiceError, match="另一个 flocks 命令正在执行"): - with service_manager.service_lock(paths): - raise AssertionError("should not acquire nested lock") - - -def test_service_lock_releases_on_completion(monkeypatch, tmp_path: Path) -> None: - paths = service_manager.RuntimePaths( - root=tmp_path, - run_dir=tmp_path / "run", - log_dir=tmp_path / "logs", - backend_pid=tmp_path / "run" / "backend.pid", - frontend_pid=tmp_path / "run" / "webui.pid", - backend_log=tmp_path / "logs" / "backend.log", - frontend_log=tmp_path / "logs" / "webui.log", - ) - operations: list[int] = [] - - class FakeFcntl: - LOCK_EX = 1 - LOCK_NB = 2 - LOCK_UN = 4 - - @staticmethod - def flock(_handle, operation): - operations.append(operation) - - monkeypatch.setattr(service_manager.sys, "platform", "darwin") - monkeypatch.setattr(service_manager, "fcntl", FakeFcntl) - - with service_manager.service_lock(paths): - pass + backend_line = next(line for line in lines if "flocks:" in line) + assert "state=degraded" in backend_line + assert "last_error=health failed" in backend_line - assert operations == [FakeFcntl.LOCK_EX | FakeFcntl.LOCK_NB, FakeFcntl.LOCK_UN] def test_log_startup_config_appends_to_log_file(tmp_path: Path) -> None: diff --git a/tests/cli/test_update_command.py b/tests/cli/test_update_command.py index 6c3104b78..76b5e971e 100644 --- a/tests/cli/test_update_command.py +++ b/tests/cli/test_update_command.py @@ -44,8 +44,45 @@ async def fake_update(*, check: bool, yes: bool, force: bool, region: str | None assert captured == {"check": False, "yes": True, "force": True, "region": "cn"} -def test_update_prompts_for_cn_mirror_before_upgrade_confirmation(monkeypatch) -> None: +def test_update_uses_install_profile_language_as_default_region(monkeypatch, tmp_path) -> None: output = StringIO() + monkeypatch.setenv("FLOCKS_CONFIG_DIR", str(tmp_path)) + (tmp_path / "install_profile.json").write_text('{"Language": "zh-CN"}', encoding="utf-8") + monkeypatch.setattr( + update_cmd, + "console", + Console(file=output, force_terminal=False, color_system=None, width=120), + ) + + check_regions: list[str | None] = [] + + async def fake_check_update(*, locale: str | None = None, region: str | None = None) -> VersionInfo: + check_regions.append(region) + return VersionInfo( + current_version="2026.4.1", + latest_version="2026.4.2", + has_update=True, + zipball_url="https://gitee.example.com/flocks.zip", + tarball_url="https://gitee.example.com/flocks.tar.gz", + deploy_mode="source", + update_allowed=True, + ) + + monkeypatch.setattr(updater_pkg, "check_update", fake_check_update) + monkeypatch.setattr(updater_pkg, "detect_deploy_mode", lambda: "source") + + import asyncio + + asyncio.run(update_cmd._update(check=True, yes=False, force=False, region=None)) + + assert check_regions == ["cn"] + assert "flocks update" in output.getvalue() + + +def test_update_prompts_for_cn_mirror_before_upgrade_confirmation(monkeypatch, tmp_path) -> None: + output = StringIO() + monkeypatch.setenv("FLOCKS_CONFIG_DIR", str(tmp_path)) + monkeypatch.delenv("FLOCKS_INSTALL_LANGUAGE", raising=False) monkeypatch.setattr( update_cmd, "console", @@ -81,6 +118,8 @@ async def fake_perform_update( *, zipball_url: str | None = None, tarball_url: str | None = None, + bundle_sha256: str | None = None, + bundle_format: str | None = None, restart: bool = True, locale: str | None = None, region: str | None = None, @@ -88,6 +127,8 @@ async def fake_perform_update( captured["latest_tag"] = latest_tag captured["zipball_url"] = zipball_url captured["tarball_url"] = tarball_url + captured["bundle_sha256"] = bundle_sha256 + captured["bundle_format"] = bundle_format captured["perform_region"] = region captured["restart"] = restart async for step in _fake_progress(): @@ -122,6 +163,8 @@ async def fake_build_updated_frontend(*, locale: str | None = None, region: str "latest_tag": "2026.4.2", "zipball_url": "https://gitee.example.com/flocks.zip", "tarball_url": "https://gitee.example.com/flocks.tar.gz", + "bundle_sha256": None, + "bundle_format": None, "perform_region": "cn", "restart": False, } @@ -210,8 +253,10 @@ async def fake_build_updated_frontend(*, locale: str | None = None, region: str assert "升级完成" in output.getvalue() -def test_update_executes_flocks_stop_before_upgrade(monkeypatch) -> None: +def test_update_executes_flocks_stop_before_upgrade(monkeypatch, tmp_path) -> None: output = StringIO() + monkeypatch.setenv("FLOCKS_CONFIG_DIR", str(tmp_path)) + monkeypatch.delenv("FLOCKS_INSTALL_LANGUAGE", raising=False) monkeypatch.setattr( update_cmd, "console", @@ -238,6 +283,8 @@ async def fake_perform_update( *, zipball_url: str | None = None, tarball_url: str | None = None, + bundle_sha256: str | None = None, + bundle_format: str | None = None, restart: bool = True, locale: str | None = None, region: str | None = None, @@ -272,8 +319,10 @@ async def fake_build_updated_frontend(*, locale: str | None = None, region: str assert "已执行 flocks stop" in output.getvalue() -def test_update_reports_frontend_build_failure_after_common_upgrade(monkeypatch) -> None: +def test_update_reports_frontend_build_failure_after_common_upgrade(monkeypatch, tmp_path) -> None: output = StringIO() + monkeypatch.setenv("FLOCKS_CONFIG_DIR", str(tmp_path)) + monkeypatch.delenv("FLOCKS_INSTALL_LANGUAGE", raising=False) monkeypatch.setattr( update_cmd, "console", @@ -296,6 +345,8 @@ async def fake_perform_update( *, zipball_url: str | None = None, tarball_url: str | None = None, + bundle_sha256: str | None = None, + bundle_format: str | None = None, restart: bool = True, locale: str | None = None, region: str | None = None, diff --git a/tests/config/test_config.py b/tests/config/test_config.py index ed21568f6..eb99c29e1 100644 --- a/tests/config/test_config.py +++ b/tests/config/test_config.py @@ -111,6 +111,33 @@ def test_legacy_todo_tool_flags_migrate_to_todo_permission(): assert worker.permission["bash"] == PermissionAction.ALLOW +def test_ui_config_normalizes_and_dumps_aliases(): + config = ConfigInfo.model_validate({ + "ui": { + "displayName": " Acme SOC ", + "faviconPath": "assets/favicon.png", + } + }) + + assert config.ui is not None + assert config.ui.display_name == "Acme SOC" + assert config.ui.favicon_path == "assets/favicon.png" + assert config.model_dump(by_alias=True, exclude_none=True)["ui"] == { + "displayName": "Acme SOC", + "faviconPath": "assets/favicon.png", + } + + +def test_ui_display_name_rejects_control_characters(): + with pytest.raises(ValueError): + ConfigInfo.model_validate({"ui": {"displayName": "Acme\nSOC"}}) + + +def test_ui_favicon_path_rejects_unsafe_paths(): + with pytest.raises(ValueError): + ConfigInfo.model_validate({"ui": {"faviconPath": "../favicon.svg"}}) + + @pytest.mark.asyncio async def test_config_file_loading(tmp_path): """Test loading configuration from file""" diff --git a/tests/console/test_console_login_heartbeat.py b/tests/console/test_console_login_heartbeat.py new file mode 100644 index 000000000..a1e1eb67d --- /dev/null +++ b/tests/console/test_console_login_heartbeat.py @@ -0,0 +1,323 @@ +import asyncio +import json + +from flocks.console import login as login_mod +from flocks.console.login import ConsoleLoginService + + +def test_heartbeat_payload_reports_oss_for_core_only_install(tmp_path, monkeypatch): + monkeypatch.setenv("FLOCKS_ROOT", str(tmp_path)) + monkeypatch.setenv("FLOCKS_EDITION", "flockspro") + monkeypatch.setattr(ConsoleLoginService, "_runtime_version", staticmethod(lambda: "2026.7.3.3")) + + payload = ConsoleLoginService.heartbeat_payload( + { + "console_session_token": "cs_heartbeat", + "fingerprint": "fp_heartbeat", + "install_id": "inst_heartbeat", + }, + ) + + assert payload["edition"] == "oss" + assert payload["core_version"] == "2026.7.3.3" + assert "version" not in payload + assert "bundle_version" not in payload + assert "flockspro_component_version" not in payload + assert "version_info" not in payload + + +def test_heartbeat_payload_includes_pro_runtime_versions(tmp_path, monkeypatch): + monkeypatch.setenv("FLOCKS_ROOT", str(tmp_path)) + monkeypatch.setattr(ConsoleLoginService, "_runtime_version", staticmethod(lambda: "2026.7.3")) + + marker = tmp_path / "run" / "pro-bundle-installed.json" + marker.parent.mkdir(parents=True) + marker.write_text( + json.dumps( + { + "bundle_version": "v2026.7.3", + "core_version": "v2026.7.3", + "flockspro_component_version": "2026.7.3.1", + } + ), + encoding="utf-8", + ) + + payload = ConsoleLoginService.heartbeat_payload( + { + "console_session_token": "cs_heartbeat", + "fingerprint": "fp_heartbeat", + "install_id": "inst_heartbeat", + }, + status="poc", + license_id="lic_heartbeat", + pro_component_version="2026.7.3.1", + ) + + assert payload["fingerprint"] == "fp_heartbeat" + assert payload["install_id"] == "inst_heartbeat" + assert payload["status"] == "poc" + assert payload["license_id"] == "lic_heartbeat" + assert payload["edition"] == "flockspro" + assert payload["bundle_version"] == "v2026.7.3" + assert payload["core_version"] == "v2026.7.3" + assert payload["flockspro_component_version"] == "2026.7.3.1" + assert "version" not in payload + assert "version_info" not in payload + + +def test_heartbeat_payload_keeps_sending_with_incomplete_pro_marker(tmp_path, monkeypatch): + monkeypatch.setenv("FLOCKS_ROOT", str(tmp_path)) + monkeypatch.setattr(ConsoleLoginService, "_runtime_version", staticmethod(lambda: "2026.7.5")) + marker = tmp_path / "run" / "pro-bundle-installed.json" + marker.parent.mkdir(parents=True) + marker.write_text( + json.dumps( + { + "bundle_version": "v2026.7.5", + "flockspro_component_version": "v2026.7.4", + } + ), + encoding="utf-8", + ) + + payload = ConsoleLoginService.heartbeat_payload( + { + "console_session_token": "cs_heartbeat", + "fingerprint": "fp_heartbeat", + "install_id": "inst_heartbeat", + }, + ) + + assert payload["edition"] == "flockspro" + assert payload["bundle_version"] == "v2026.7.5" + assert payload["core_version"] == "" + assert payload["flockspro_component_version"] == "v2026.7.4" + + +def test_send_heartbeat_uses_local_pro_license_and_applies_response(tmp_path, monkeypatch): + monkeypatch.setenv("FLOCKS_ROOT", str(tmp_path)) + monkeypatch.setenv("FLOCKS_CONSOLE_BASE_URL", "https://console.example.com") + monkeypatch.setattr(ConsoleLoginService, "_runtime_version", staticmethod(lambda: "2026.7.3")) + + license_path = tmp_path / "flockspro" / "license.json" + license_path.parent.mkdir(parents=True) + license_path.write_text( + json.dumps( + { + "license_id": "lic_core", + "payload": {"license_id": "lic_core", "status": "poc"}, + "patches": [], + } + ), + encoding="utf-8", + ) + marker = tmp_path / "run" / "pro-bundle-installed.json" + marker.parent.mkdir(parents=True) + marker.write_text( + json.dumps( + { + "bundle_version": "v2026.7.3", + "core_version": "v2026.7.3", + "flockspro_component_version": "2026.7.3.1", + } + ), + encoding="utf-8", + ) + + async def _require_session(cls): + return { + "console_session_token": "cs_core", + "fingerprint": "fp_core", + "install_id": "inst_core", + } + + captured: dict[str, object] = {} + + class _Response: + status_code = 200 + + def raise_for_status(self): + return None + + def json(self): + return { + "license_patch": "patch_token_1", + "revoked_license_ids": ["lic_revoked"], + } + + class _Client: + def __init__(self, *_args, **_kwargs): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def post(self, url, json=None, headers=None): + captured["url"] = url + captured["json"] = json + captured["headers"] = headers + return _Response() + + monkeypatch.setattr(ConsoleLoginService, "_require_session", classmethod(_require_session)) + monkeypatch.setattr(login_mod.httpx, "AsyncClient", _Client) + + asyncio.run(ConsoleLoginService.send_heartbeat()) + + assert captured["url"] == "https://console.example.com/v1/heartbeats" + assert captured["headers"] == {"Authorization": "Bearer cs_core"} + payload = captured["json"] + assert payload["status"] == "poc" + assert payload["license_id"] == "lic_core" + assert payload["bundle_version"] == "v2026.7.3" + assert payload["core_version"] == "v2026.7.3" + assert payload["flockspro_component_version"] == "2026.7.3.1" + assert "version" not in payload + assert "version_info" not in payload + + updated = json.loads(license_path.read_text(encoding="utf-8")) + assert updated["patches"] == ["patch_token_1"] + assert updated["last_sync_at"] + revocation = json.loads((tmp_path / "flockspro" / "revocation.json").read_text(encoding="utf-8")) + assert revocation == {"revoked_license_ids": ["lic_revoked"]} + + +def test_report_pending_pro_bundle_install_receipt_posts_and_deletes(tmp_path, monkeypatch): + monkeypatch.setenv("FLOCKS_ROOT", str(tmp_path)) + monkeypatch.delenv("FLOCKS_CONSOLE_BASE_URL", raising=False) + + license_path = tmp_path / "flockspro" / "license.json" + license_path.parent.mkdir(parents=True) + license_path.write_text(json.dumps({"license_id": "lic_pending"}), encoding="utf-8") + pending_path = tmp_path / "run" / "pro-bundle-install-receipt-pending.json" + pending_path.parent.mkdir(parents=True) + pending_path.write_text( + json.dumps( + { + "release_id": "rel_pending", + "bundle_release_id": "rel_pending", + "bundle_version": "2026.7.3.5", + "core_version": "2026.7.3.5", + "flockspro_component_version": "2026.7.3.3", + "build_id": "job_pending", + "install_result": "success", + } + ), + encoding="utf-8", + ) + + async def _require_session(cls): + return { + "console_session_token": "cs_pending", + "fingerprint": "fp_pending", + "install_id": "inst_pending", + "console_base_url": "http://127.0.0.1:18001", + } + + captured: dict[str, object] = {} + + class _Response: + status_code = 200 + + class _Client: + def __init__(self, *_args, **_kwargs): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def post(self, url, json=None, headers=None): + captured["url"] = url + captured["json"] = json + captured["headers"] = headers + return _Response() + + monkeypatch.setattr(ConsoleLoginService, "_require_session", classmethod(_require_session)) + monkeypatch.setattr(login_mod.httpx, "AsyncClient", _Client) + + reported = asyncio.run(ConsoleLoginService.report_pending_pro_bundle_install_receipt()) + + assert reported is True + assert not pending_path.exists() + assert captured["url"] == "http://127.0.0.1:18001/v1/pro-bundles/installations" + assert captured["headers"] == {"Authorization": "Bearer cs_pending"} + payload = captured["json"] + assert payload["fingerprint"] == "fp_pending" + assert payload["install_id"] == "inst_pending" + assert payload["license_id"] == "lic_pending" + assert payload["bundle_version"] == "2026.7.3.5" + + +def test_report_pending_pro_bundle_downgrade_receipt_posts_and_deletes(tmp_path, monkeypatch): + monkeypatch.setenv("FLOCKS_ROOT", str(tmp_path)) + monkeypatch.delenv("FLOCKS_CONSOLE_BASE_URL", raising=False) + + pending_path = tmp_path / "run" / "pro-bundle-downgrade-receipt-pending.json" + pending_path.parent.mkdir(parents=True) + pending_path.write_text( + json.dumps( + { + "request_id": "req_downgrade_pending", + "release_id": "rel_downgrade_pending", + "bundle_release_id": "rel_downgrade_pending", + "license_id": "lic_downgrade_pending", + "bundle_version": "2026.7.3.5", + "core_version": "2026.7.3.5", + "flockspro_component_version": "2026.7.3.3", + "install_result": "downgraded", + "runtime_edition": "oss", + } + ), + encoding="utf-8", + ) + + async def _require_session(cls): + return { + "console_session_token": "cs_pending", + "fingerprint": "fp_pending", + "install_id": "inst_pending", + "console_base_url": "http://127.0.0.1:18001", + } + + captured: dict[str, object] = {} + + class _Response: + status_code = 200 + + class _Client: + def __init__(self, *_args, **_kwargs): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def post(self, url, json=None, headers=None): + captured["url"] = url + captured["json"] = json + captured["headers"] = headers + return _Response() + + monkeypatch.setattr(ConsoleLoginService, "_require_session", classmethod(_require_session)) + monkeypatch.setattr(login_mod.httpx, "AsyncClient", _Client) + + reported = asyncio.run(ConsoleLoginService.report_pending_pro_bundle_install_receipt()) + + assert reported is True + assert not pending_path.exists() + assert captured["url"] == "http://127.0.0.1:18001/v1/pro-bundles/installations" + assert captured["headers"] == {"Authorization": "Bearer cs_pending"} + payload = captured["json"] + assert payload["fingerprint"] == "fp_pending" + assert payload["install_id"] == "inst_pending" + assert payload["license_id"] == "lic_downgrade_pending" + assert payload["install_result"] == "downgraded" + assert payload["runtime_edition"] == "oss" diff --git a/tests/console/test_console_sync_scheduler.py b/tests/console/test_console_sync_scheduler.py index e92a6a422..ca786c1b8 100644 --- a/tests/console/test_console_sync_scheduler.py +++ b/tests/console/test_console_sync_scheduler.py @@ -89,3 +89,31 @@ async def _refresh(): assert called["hb"] == 0 assert called["refresh"] == 0 assert called["sync"] == 0 + + +async def test_startup_heartbeat_ignores_last_heartbeat_interval(monkeypatch: pytest.MonkeyPatch): + now_ts = 1700002000 + storage_values = { + scheduler_mod._HEARTBEAT_TS_KEY: now_ts - 300, + } + called = {"hb": 0} + + async def _get(key: str): + return storage_values.get(key) + + async def _set(key: str, value, _type: str): + storage_values[key] = int(value) + + async def _heartbeat(): + called["hb"] += 1 + return {"ok": True} + + monkeypatch.setattr(scheduler_mod.Storage, "get", _get) + monkeypatch.setattr(scheduler_mod.Storage, "set", _set) + monkeypatch.setattr(scheduler_mod.ConsoleLoginService, "send_heartbeat", _heartbeat) + monkeypatch.setattr(scheduler_mod.time, "time", lambda: now_ts) + + await scheduler_mod.ConsoleSyncScheduler.send_startup_heartbeat() + + assert called["hb"] == 1 + assert storage_values[scheduler_mod._HEARTBEAT_TS_KEY] == now_ts diff --git a/tests/contracts/access/test_runtime.py b/tests/contracts/access/test_runtime.py index ed4d0fac8..4b432061b 100644 --- a/tests/contracts/access/test_runtime.py +++ b/tests/contracts/access/test_runtime.py @@ -53,16 +53,18 @@ def _write_contract_sqlite(db_path: Path, records: list[dict[str, Any]]) -> None CREATE TABLE records ( id TEXT PRIMARY KEY, record_date TEXT NOT NULL, + event_time INTEGER, record_json TEXT NOT NULL ) """ ) connection.executemany( - "INSERT INTO records (id, record_date, record_json) VALUES (?, ?, ?)", + "INSERT INTO records (id, record_date, event_time, record_json) VALUES (?, ?, ?, ?)", [ ( str(record.get("id") or index), str(record.get("record_date") or "2026-06-25"), + int(record.get("event_time") or record.get("time") or 0), json.dumps(record, ensure_ascii=False), ) for index, record in enumerate(records, start=1) @@ -308,6 +310,46 @@ def test_query_can_use_sqlite_json_driver(tmp_path: Path, monkeypatch: pytest.Mo assert response.body["items"][0]["entityId"] == "record:allowed" +def test_query_can_filter_sqlite_json_driver_by_event_time(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + store = _store(tmp_path, monkeypatch) + db_path = tmp_path / "contract_records.db" + _write_contract_sqlite( + db_path, + [ + _contract_record(id="early", time=1000), + _contract_record(id="middle", time=2000), + _contract_record(id="late", time=3000), + ], + ) + runtime = OperationRuntime( + plugins=( + _plugin( + store, + adapter_kind="builtin-sqlite-json", + source_root=db_path, + driver_options={ + "table": "records", + "recordColumn": "record_json", + "dateColumn": "record_date", + "eventTimeColumn": "event_time", + }, + ), + ), + ) + + response = runtime.execute( + page_id=PAGE_ID, + contract_id=CONTRACT_ID, + operation_name="list", + payload={"params": {"startTime": 1500, "endTime": 2500, "limit": 10}}, + principal=AuthUser(id="u1", username="alice", role="admin"), + ) + + assert response.body["summary"]["totalRaw"] == 1 + assert response.body["summary"]["filteredUnique"] == 1 + assert [item["id"] for item in response.body["items"]] == ["middle"] + + def test_query_rejects_page_supplied_binding_or_idempotency_key(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): store = _store(tmp_path, monkeypatch) _write_contract_assets(store, [_contract_record()]) diff --git a/tests/docker/test_dockerfile_runtime_requirements.py b/tests/docker/test_dockerfile_runtime_requirements.py index 1e2d5ccc1..bbb61e1c8 100644 --- a/tests/docker/test_dockerfile_runtime_requirements.py +++ b/tests/docker/test_dockerfile_runtime_requirements.py @@ -5,14 +5,6 @@ DOCKERFILE = REPO_ROOT / "docker" / "Dockerfile" -def test_runtime_image_installs_required_cli_tools() -> None: - dockerfile = DOCKERFILE.read_text(encoding="utf-8") - - assert "npm install --global agent-browser" in dockerfile - assert "agent-browser install --with-deps" in dockerfile - assert "curl -LsSf https://astral.sh/uv/install.sh | sh" in dockerfile - - def test_runtime_image_no_longer_bundles_system_chromium() -> None: dockerfile = DOCKERFILE.read_text(encoding="utf-8") diff --git a/tests/helpers/__init__.py b/tests/helpers/__init__.py new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/tests/helpers/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/helpers/service_supervisor.py b/tests/helpers/service_supervisor.py new file mode 100644 index 000000000..f51b19352 --- /dev/null +++ b/tests/helpers/service_supervisor.py @@ -0,0 +1,99 @@ +"""Helpers for service supervisor integration-style tests.""" + +from __future__ import annotations + +import subprocess +import sys +import tempfile +import threading +import time +from pathlib import Path + +from flocks.cli import service_control, service_manager, service_process, service_supervisor + + +class SleeperProcessAdapter: + """Process adapter that starts a real, lightweight child process.""" + + def __init__(self) -> None: + self.started: list[subprocess.Popen] = [] + self.stopped: list[int] = [] + + def start(self, _config, _paths, *, built_once: bool = False) -> subprocess.Popen: + del built_once + process = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(60)"]) + self.started.append(process) + return process + + def stop(self, process: subprocess.Popen | None) -> None: + if process is None: + return + self.stopped.append(process.pid) + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=5) + + def probe(self, process: subprocess.Popen | None, _host: str, _port: int) -> service_process.ServiceProbeResult: + if process is None: + return service_process.ServiceProbeResult(healthy=False, reason="stopped") + if process.poll() is not None: + return service_process.ServiceProbeResult(healthy=False, reason="process exited", restart=True) + return service_process.ServiceProbeResult(healthy=True) + + +def make_short_runtime_root(prefix: str) -> Path: + """Create a short runtime root so Unix domain socket paths fit on macOS.""" + return Path(tempfile.mkdtemp(prefix=prefix, dir="/tmp")) + + +def make_runtime_paths(root: Path) -> service_manager.RuntimePaths: + return service_manager.RuntimePaths( + root=root, + run_dir=root / "run", + log_dir=root / "logs", + backend_pid=root / "run" / "backend.pid", + frontend_pid=root / "run" / "webui.pid", + backend_log=root / "logs" / "backend.log", + frontend_log=root / "logs" / "webui.log", + ) + + +def wait_for_process_exit(process: subprocess.Popen, timeout: float = 5.0) -> None: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if process.poll() is not None: + return + time.sleep(0.05) + raise AssertionError(f"process {process.pid} did not exit") + + +def wait_for_supervisor(paths: service_manager.RuntimePaths, *, running: bool, timeout: float = 5.0) -> None: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if service_control.supervisor_is_running(paths) is running: + return + time.sleep(0.05) + raise AssertionError(f"supervisor running={running} was not observed") + + +def start_supervisor( + config: service_manager.ServiceConfig, +) -> tuple[service_supervisor.SupervisorDaemon, threading.Thread]: + daemon = service_supervisor.SupervisorDaemon( + config, + interval=0.05, + backend_adapter=SleeperProcessAdapter(), + ) + thread = threading.Thread(target=daemon.run, daemon=True) + thread.start() + return daemon, thread + + +def stop_supervisor(daemon: service_supervisor.SupervisorDaemon, thread: threading.Thread) -> None: + daemon.request_stop() + thread.join(timeout=5) + daemon.shutdown_children() + daemon._stop_control_server() diff --git a/tests/hub/test_bundled_tools.py b/tests/hub/test_bundled_tools.py index e704e4848..e2fe2c483 100644 --- a/tests/hub/test_bundled_tools.py +++ b/tests/hub/test_bundled_tools.py @@ -28,7 +28,7 @@ install_plugin, uninstall_plugin, ) -from flocks.hub.models import HubPluginManifest +from flocks.hub.models import HubPluginManifest, InstalledPluginRecord from flocks.hub.security import SKIP_NAMES, validate_package @@ -73,9 +73,7 @@ def _clear_hub_caches() -> None: # them populated against the test's tmp flockshub, sibling tests # in the same session see a phantom (empty) catalog. Reset before # AND after to insulate both directions. - hub_catalog.load_index.cache_clear() - hub_catalog.load_taxonomy.cache_clear() - hub_catalog._manifest_path_lookup.cache_clear() + hub_catalog.clear_catalog_caches() Config._global_config = None Config._cached_config = None @@ -161,6 +159,37 @@ async def test_tool_runtime_refresh_clears_device_template_cache(monkeypatch): assert calls == ["init", "refresh", "discover:True"] +@pytest.mark.asyncio +async def test_uninstall_missing_tool_record_clears_device_template_cache(isolated_hub, monkeypatch): + calls: list[str] = [] + local.save_installed_record( + InstalledPluginRecord( + id="ghost_tool", + type="tool", + version="1.0", + source="bundled", + installedAt=1, + installPath=str(isolated_hub["home"] / ".flocks" / "plugins" / "tools" / "api" / "ghost_tool"), + ) + ) + monkeypatch.setattr("flocks.hub.installer.clear_catalog_caches", lambda: calls.append("catalog")) + monkeypatch.setattr( + "flocks.hub.installer._clear_device_template_cache_if_needed", + lambda plugin_type: calls.append(f"device:{plugin_type}"), + ) + + async def fake_refresh_runtime(plugin_type): + calls.append(f"refresh:{plugin_type}") + + monkeypatch.setattr("flocks.hub.installer._refresh_runtime", fake_refresh_runtime) + + removed = await uninstall_plugin("tool", "ghost_tool") + + assert removed is True + assert local.get_record("tool", "ghost_tool") is None + assert calls == ["catalog", "device:tool", "refresh:tool"] + + class TestBundledToolRoots: def test_discovers_api_subdir_plugins(self, isolated_hub): _write_bundled_tool( @@ -199,6 +228,41 @@ def test_returns_empty_when_no_bundled_dir(self, isolated_hub, monkeypatch): monkeypatch.setenv("FLOCKS_HUB_ROOT", str(empty_hub)) assert catalog._bundled_tool_roots() == {} + def test_cache_refreshes_when_bundled_plugin_is_added(self, isolated_hub): + assert catalog._bundled_tool_roots() == {} + + _write_bundled_tool( + isolated_hub["bundled"], + plugin_id="late_tool", + service_id="late_tool_api", + version="1.0", + ) + + assert ("tool", "late_tool") in catalog._bundled_tool_roots() + + def test_cache_refreshes_when_project_plugin_is_added(self, isolated_hub): + assert catalog._system_plugin_roots() == {} + + project_tool = ( + isolated_hub["project"] + / ".flocks" + / "plugins" + / "tools" + / "api" + / "project_tool" + ) + project_tool.mkdir(parents=True) + (project_tool / "_provider.yaml").write_text( + "name: Project Tool\nservice_id: project_tool\nversion: '1.0'\n", + encoding="utf-8", + ) + (project_tool / "project_tool_query.yaml").write_text( + "name: project_tool_query\ndescription: Project query\nhandler:\n type: http\n method: GET\n url: https://example/query\nparameters: []\n", + encoding="utf-8", + ) + + assert ("tool", "project_tool") in catalog._system_plugin_roots() + # --------------------------------------------------------------------------- # _entry_from_bundled_tool / list_catalog state transitions diff --git a/tests/hub/test_hub_catalog.py b/tests/hub/test_hub_catalog.py index ae4911a1d..4921d8930 100644 --- a/tests/hub/test_hub_catalog.py +++ b/tests/hub/test_hub_catalog.py @@ -1,4 +1,5 @@ import json +import sqlite3 from pathlib import Path import pytest @@ -9,6 +10,7 @@ from flocks.hub.catalog import list_catalog, load_manifest, load_taxonomy from flocks.hub.files import file_tree, read_file_content from flocks.hub.installer import install_plugin, uninstall_plugin +from flocks.plugin.loader import PluginLoader @pytest.fixture() @@ -33,17 +35,59 @@ def isolated_hub_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): Config._global_config = None Config._cached_config = None Skill.clear_cache() - yield {"home": home, "config_dir": config_dir, "data_dir": data_dir} + yield {"home": home, "config_dir": config_dir, "data_dir": data_dir, "project_dir": project_dir} Skill.clear_cache() +def _patch_webui_bundle_build(monkeypatch: pytest.MonkeyPatch) -> list[str]: + from flocks.contracts.webui.models import WebUIPageBuildMeta + + built_pages: list[str] = [] + + def fake_build(self, page_id: str): + page_dir = self._store.writable_page_dir(page_id) + bundle_path = page_dir / "dist" / "page.js" + bundle_path.parent.mkdir(parents=True, exist_ok=True) + bundle_path.write_text(f"// built during hub install: {page_id}\n", encoding="utf-8") + meta = WebUIPageBuildMeta( + hash=f"fake-{page_id}", + builtAt=1, + status="ready", + error=None, + runtime="webui_page", + runtimeVersion=1, + sdkImport="@flocks/webui-contract-sdk", + ) + self._store.write_build_meta(page_id, meta) + built_pages.append(page_id) + return meta + + monkeypatch.setattr("flocks.contracts.webui.builder.WebUIPageBuilder.build", fake_build) + return built_pages + + def test_bundled_hub_catalog_loads(): entries = list_catalog() assert entries # ``device`` is a first-class Hub type alongside skill/agent/tool/workflow: # entries with ``integration_type: device`` in ``_provider.yaml`` surface # under ``type=device`` instead of ``type=tool``. - assert {entry.type for entry in entries} >= {"skill", "agent", "tool", "device", "workflow"} + assert {entry.type for entry in entries} >= {"skill", "agent", "tool", "device", "workflow", "webui", "component"} + + +def test_workflow_catalog_exposes_chinese_names(): + entries = {entry.id: entry for entry in list_catalog(plugin_type="workflow")} + + assert entries["stream_alert_denoise"].nameCn == "流式HTTP降噪工作流" + assert entries["stream_alert_triage"].nameCn == "HTTP研判工作流" + assert entries["loop_host_forensics_fast"].nameCn == "批量主机快速巡检工作流" + assert entries["tdp_alert_triage"].nameCn == "TDP 告警调查工作流" + + +def test_soc_workspace_component_exposes_chinese_name(): + entries = {entry.id: entry for entry in list_catalog(plugin_type="component")} + + assert entries["soc-workspace"].nameCn == "SOC 工作区场景套件" def test_pentest_agents_are_listed_in_agent_catalog(): @@ -145,6 +189,392 @@ async def test_hub_installs_pentest_subagent(isolated_hub_env): assert not agent_dir.exists() +async def test_hub_installs_soc_webui_package(isolated_hub_env, monkeypatch: pytest.MonkeyPatch): + async def noop_refresh(_plugin_type): + return None + + monkeypatch.setattr("flocks.hub.installer._refresh_runtime", noop_refresh) + built_pages = _patch_webui_bundle_build(monkeypatch) + + record = await install_plugin("webui", "soc_ui") + home_plugins = isolated_hub_env["home"] / ".flocks" / "plugins" + webui_dir = home_plugins / "contracts" / "webui" / "soc_ui" + access_dir = home_plugins / "contracts" / "access" / "soc_ui" + dashboard_manifest = json.loads((webui_dir / "soc_dashboard" / "manifest.json").read_text(encoding="utf-8")) + + assert (webui_dir / "workspace.json").is_file() + assert (webui_dir / "soc_alerts" / "dist" / "page.js").is_file() + assert (webui_dir / "soc_alerts" / "dist" / "page.js").read_text(encoding="utf-8") == ( + "// built during hub install: soc-alerts\n" + ) + assert (access_dir / "soc_alerts_operations.py").is_file() + assert set(built_pages) == {"soc-alerts", "soc-dashboard", "soc-overview"} + assert dashboard_manifest["id"] == "soc-dashboard" + assert record.installPath == str(webui_dir) + + removed = await uninstall_plugin("webui", "soc_ui") + assert removed is True + assert not webui_dir.exists() + assert not access_dir.exists() + + +async def test_hub_webui_install_fails_when_bundle_build_fails( + isolated_hub_env, + monkeypatch: pytest.MonkeyPatch, +): + async def noop_refresh(_plugin_type): + return None + + def fail_build(_self, _page_id: str): + raise RuntimeError("esbuild is not available; install webui dependencies first") + + monkeypatch.setattr("flocks.hub.installer._refresh_runtime", noop_refresh) + monkeypatch.setattr("flocks.contracts.webui.builder.WebUIPageBuilder.build", fail_build) + + home_plugins = isolated_hub_env["home"] / ".flocks" / "plugins" + + with pytest.raises(RuntimeError, match="Failed to build WebUI page bundle for soc_ui/"): + await install_plugin("webui", "soc_ui") + + assert not (home_plugins / "contracts" / "webui" / "soc_ui").exists() + assert not (home_plugins / "contracts" / "access" / "soc_ui").exists() + assert local.get_record("webui", "soc_ui") is None + + +async def test_hub_installed_soc_webui_registers_alert_access_contract( + isolated_hub_env, + monkeypatch: pytest.MonkeyPatch, +): + async def noop_refresh(_plugin_type): + return None + + monkeypatch.setattr("flocks.hub.installer._refresh_runtime", noop_refresh) + _patch_webui_bundle_build(monkeypatch) + + await install_plugin("webui", "soc_ui") + home_plugins = isolated_hub_env["home"] / ".flocks" / "plugins" + monkeypatch.setattr(PluginLoader, "_plugin_root", home_plugins) + monkeypatch.setattr(PluginLoader, "_extension_points", dict(PluginLoader._extension_points)) + PluginLoader.clear_extension_points() + + from flocks.contracts.access.discovery import discover_contract_plugins + + plugins = discover_contract_plugins(project_dir=isolated_hub_env["project_dir"]) + + assert any( + contract.contract_id == "soc.alerts.operations" and contract.page_id == "soc-alerts" + for plugin in plugins + for contract in plugin.contracts + ) + + +async def test_hub_installed_soc_webui_serves_alert_access_operation( + isolated_hub_env, + monkeypatch: pytest.MonkeyPatch, +): + async def noop_refresh(_plugin_type): + return None + + monkeypatch.setattr("flocks.hub.installer._refresh_runtime", noop_refresh) + _patch_webui_bundle_build(monkeypatch) + + db_path = isolated_hub_env["data_dir"] / "soc.db" + db_path.parent.mkdir(parents=True, exist_ok=True) + record = { + "id": "alert-1", + "time": 1782888542, + "direction": "in", + "sip": "192.0.2.10", + "dip": "198.51.100.20", + "sport": 43123, + "dport": 80, + "net_type": "http", + "req_host": "example.test", + "req_http_url": "/login?id=1", + "rsp_status_code": 404, + "threat_rule_id": "D1181087257", + "threat_name": "SQL injection", + "threat_msg": "Detected SQL injection attempt.", + "threat_phase": "exploit", + "threat_type": "exploit", + "threat_result": "failed", + "_source_type": "tdp", + "is_duplicate": False, + } + with sqlite3.connect(db_path) as connection: + connection.execute( + """ + CREATE TABLE alert_records ( + row_id TEXT PRIMARY KEY, + record_id TEXT, + asset_date TEXT NOT NULL, + source_file TEXT NOT NULL, + line_number INTEGER NOT NULL, + event_time INTEGER, + source_type TEXT, + threat_name TEXT, + is_duplicate INTEGER NOT NULL DEFAULT 0, + record_json TEXT NOT NULL + ) + """ + ) + connection.execute( + """ + INSERT INTO alert_records ( + row_id, record_id, asset_date, source_file, line_number, + event_time, source_type, threat_name, is_duplicate, record_json + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + "row-1", + "alert-1", + "2026-07-01", + "sample.jsonl", + 1, + 1782888542, + "tdp", + "SQL injection", + 0, + json.dumps(record), + ), + ) + + monkeypatch.setenv("FLOCKS_SOC_ALERTS_SQLITE_DB", str(db_path)) + await install_plugin("webui", "soc_ui") + home_plugins = isolated_hub_env["home"] / ".flocks" / "plugins" + monkeypatch.setattr(PluginLoader, "_plugin_root", home_plugins) + monkeypatch.setattr(PluginLoader, "_extension_points", dict(PluginLoader._extension_points)) + PluginLoader.clear_extension_points() + + from flocks.auth.context import AuthUser + from flocks.contracts.access.discovery import discover_contract_plugins + from flocks.contracts.access.runtime import OperationRuntime + + runtime = OperationRuntime(plugins=discover_contract_plugins(project_dir=isolated_hub_env["project_dir"])) + response = runtime.execute( + page_id="soc-alerts", + contract_id="soc.alerts.operations", + operation_name="list", + payload={"params": {"limit": 10}}, + principal=AuthUser(id="u1", username="admin", role="admin"), + ) + + assert response.status_code == 200 + assert response.body["summary"]["totalRaw"] == 1 + assert response.body["summary"]["attackFailed"] == 1 + assert response.body["incidents"][0]["id"] == "alert-1" + assert response.body["incidents"][0]["tableCells"]["_source_type"]["value"] == "tdp" + + +async def test_hub_installs_soc_workspace_component_children(isolated_hub_env, monkeypatch: pytest.MonkeyPatch): + async def noop_refresh(_plugin_type): + return None + + monkeypatch.setattr("flocks.hub.installer._refresh_runtime", noop_refresh) + built_pages = _patch_webui_bundle_build(monkeypatch) + + record = await install_plugin("component", "soc-workspace") + home_plugins = isolated_hub_env["home"] / ".flocks" / "plugins" + + assert (home_plugins / "components" / "soc-workspace" / "component.json").is_file() + assert (home_plugins / "contracts" / "webui" / "soc_ui" / "workspace.json").is_file() + assert (home_plugins / "contracts" / "access" / "soc_ui" / "soc_alerts_operations.py").is_file() + assert "soc-alerts" in built_pages + assert (home_plugins / "tools" / "python" / "soc_workspace_query" / "soc_workspace_query.py").is_file() + assert (home_plugins / "workflows" / "stream_alert_denoise" / "guide.md").is_file() + assert (home_plugins / "workflows" / "stream_alert_triage" / "config.json").is_file() + assert record.id == "soc-workspace" + + removed = await uninstall_plugin("component", "soc-workspace") + assert removed is True + assert not (home_plugins / "components" / "soc-workspace").exists() + assert not (home_plugins / "contracts" / "webui" / "soc_ui").exists() + assert not (home_plugins / "contracts" / "access" / "soc_ui").exists() + assert not (home_plugins / "tools" / "python" / "soc_workspace_query").exists() + assert not (home_plugins / "workflows" / "stream_alert_denoise").exists() + assert not (home_plugins / "workflows" / "stream_alert_triage").exists() + + +async def test_hub_component_uninstall_preserves_existing_children(isolated_hub_env, monkeypatch: pytest.MonkeyPatch): + async def noop_refresh(_plugin_type): + return None + + monkeypatch.setattr("flocks.hub.installer._refresh_runtime", noop_refresh) + _patch_webui_bundle_build(monkeypatch) + + home_plugins = isolated_hub_env["home"] / ".flocks" / "plugins" + webui_dir = home_plugins / "contracts" / "webui" / "soc_ui" + triage_dir = home_plugins / "workflows" / "stream_alert_triage" + + await install_plugin("webui", "soc_ui") + assert (webui_dir / "workspace.json").is_file() + assert local.get_record("webui", "soc_ui").installedBy is None + + await install_plugin("component", "soc-workspace") + assert (home_plugins / "components" / "soc-workspace" / "component.json").is_file() + assert (triage_dir / "config.json").is_file() + assert local.get_record("workflow", "stream_alert_triage").installedBy == "component:soc-workspace" + + removed = await uninstall_plugin("component", "soc-workspace") + assert removed is True + assert not (home_plugins / "components" / "soc-workspace").exists() + assert (webui_dir / "workspace.json").is_file() + assert not triage_dir.exists() + + +async def test_hub_component_adopts_existing_soc_workspace_tool(isolated_hub_env, monkeypatch: pytest.MonkeyPatch): + async def noop_refresh(_plugin_type): + return None + + monkeypatch.setattr("flocks.hub.installer._refresh_runtime", noop_refresh) + _patch_webui_bundle_build(monkeypatch) + + home_plugins = isolated_hub_env["home"] / ".flocks" / "plugins" + tool_dir = home_plugins / "tools" / "python" / "soc_workspace_query" + + await install_plugin("tool", "soc_workspace_query") + assert (tool_dir / "soc_workspace_query.py").is_file() + assert local.get_record("tool", "soc_workspace_query").installedBy is None + + await install_plugin("component", "soc-workspace") + + assert local.get_record("tool", "soc_workspace_query").installedBy == "component:soc-workspace" + + removed = await uninstall_plugin("component", "soc-workspace") + + assert removed is True + assert local.get_record("tool", "soc_workspace_query") is None + assert not tool_dir.exists() + + +async def test_hub_component_uninstall_cleans_adoptable_legacy_tool_record(isolated_hub_env, monkeypatch: pytest.MonkeyPatch): + async def noop_refresh(_plugin_type): + return None + + monkeypatch.setattr("flocks.hub.installer._refresh_runtime", noop_refresh) + _patch_webui_bundle_build(monkeypatch) + + await install_plugin("component", "soc-workspace") + + home_plugins = isolated_hub_env["home"] / ".flocks" / "plugins" + tool_dir = home_plugins / "tools" / "python" / "soc_workspace_query" + tool_record = local.get_record("tool", "soc_workspace_query") + assert tool_record is not None + assert tool_record.installedBy == "component:soc-workspace" + local.save_installed_record(tool_record.model_copy(update={"installedBy": None})) + + removed = await uninstall_plugin("component", "soc-workspace") + + assert removed is True + assert local.get_record("tool", "soc_workspace_query") is None + assert not tool_dir.exists() + + +async def test_hub_component_uninstall_cleans_legacy_unrecorded_webui(isolated_hub_env, monkeypatch: pytest.MonkeyPatch): + async def noop_refresh(_plugin_type): + return None + + monkeypatch.setattr("flocks.hub.installer._refresh_runtime", noop_refresh) + _patch_webui_bundle_build(monkeypatch) + + home_plugins = isolated_hub_env["home"] / ".flocks" / "plugins" + webui_dir = home_plugins / "contracts" / "webui" / "soc_ui" + webui_access_dir = home_plugins / "contracts" / "access" / "soc_ui" + + await install_plugin("webui", "soc_ui") + local.remove_installed_record("webui", "soc_ui") + assert (webui_dir / "workspace.json").is_file() + assert local.get_record("webui", "soc_ui") is None + + await install_plugin("component", "soc-workspace") + removed = await uninstall_plugin("component", "soc-workspace") + + assert removed is True + assert not (home_plugins / "components" / "soc-workspace").exists() + assert not webui_dir.exists() + assert not webui_access_dir.exists() + + +async def test_hub_component_install_failure_rolls_back_children(isolated_hub_env, monkeypatch: pytest.MonkeyPatch): + async def fail_tool_refresh(plugin_type): + if plugin_type == "tool": + raise RuntimeError("tool refresh failed") + return None + + monkeypatch.setattr("flocks.hub.installer._refresh_runtime", fail_tool_refresh) + _patch_webui_bundle_build(monkeypatch) + + home_plugins = isolated_hub_env["home"] / ".flocks" / "plugins" + webui_dir = home_plugins / "contracts" / "webui" / "soc_ui" + webui_access_dir = home_plugins / "contracts" / "access" / "soc_ui" + tool_dir = home_plugins / "tools" / "python" / "soc_workspace_query" + component_dir = home_plugins / "components" / "soc-workspace" + + with pytest.raises(RuntimeError, match="tool refresh failed"): + await install_plugin("component", "soc-workspace") + + assert not component_dir.exists() + assert not webui_dir.exists() + assert not webui_access_dir.exists() + assert not tool_dir.exists() + assert local.get_record("component", "soc-workspace") is None + assert local.get_record("webui", "soc_ui") is None + assert local.get_record("tool", "soc_workspace_query") is None + + +async def test_hub_component_uninstall_cleans_orphan_children(isolated_hub_env, monkeypatch: pytest.MonkeyPatch): + async def noop_refresh(_plugin_type): + return None + + monkeypatch.setattr("flocks.hub.installer._refresh_runtime", noop_refresh) + _patch_webui_bundle_build(monkeypatch) + + component_key = "component:soc-workspace" + await install_plugin("webui", "soc_ui", installed_by=component_key) + await install_plugin("tool", "soc_workspace_query", installed_by=component_key) + await install_plugin("workflow", "stream_alert_denoise", installed_by=component_key) + await install_plugin("workflow", "stream_alert_triage", installed_by=component_key) + + home_plugins = isolated_hub_env["home"] / ".flocks" / "plugins" + component_dir = home_plugins / "components" / "soc-workspace" + + assert not component_dir.exists() + assert local.get_record("component", "soc-workspace") is None + assert local.get_record("webui", "soc_ui").installedBy == component_key + + removed = await uninstall_plugin("component", "soc-workspace") + + assert removed is True + assert local.get_record("webui", "soc_ui") is None + assert local.get_record("tool", "soc_workspace_query") is None + assert local.get_record("workflow", "stream_alert_denoise") is None + assert local.get_record("workflow", "stream_alert_triage") is None + assert not (home_plugins / "contracts" / "webui" / "soc_ui").exists() + assert not (home_plugins / "contracts" / "access" / "soc_ui").exists() + assert not (home_plugins / "tools" / "python" / "soc_workspace_query").exists() + assert not (home_plugins / "workflows" / "stream_alert_denoise").exists() + assert not (home_plugins / "workflows" / "stream_alert_triage").exists() + + +async def test_hub_uninstalls_python_tool_without_record(isolated_hub_env, monkeypatch: pytest.MonkeyPatch): + async def noop_refresh(_plugin_type): + return None + + monkeypatch.setattr("flocks.hub.installer._refresh_runtime", noop_refresh) + + record = await install_plugin("tool", "soc_workspace_query") + tool_dir = isolated_hub_env["home"] / ".flocks" / "plugins" / "tools" / "python" / "soc_workspace_query" + + assert record.installPath == str(tool_dir) + assert (tool_dir / "soc_workspace_query.py").is_file() + local.remove_installed_record("tool", "soc_workspace_query") + + removed = await uninstall_plugin("tool", "soc_workspace_query") + assert removed is True + assert not tool_dir.exists() + + 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" @@ -201,6 +631,62 @@ def test_hub_routes_cover_catalog_files_install_and_uninstall(isolated_hub_env): assert any(item["id"] == "ndr-alert-analysis" for item in available_catalog) +def test_hub_refresh_clears_catalog_and_device_template_caches(monkeypatch): + from flocks.server.routes import hub as hub_routes + + calls: list[str] = [] + monkeypatch.setattr(hub_routes, "clear_catalog_caches", lambda: calls.append("catalog")) + monkeypatch.setattr( + "flocks.tool.device.plugin_index.clear_device_template_cache", + lambda: calls.append("device"), + ) + + hub_routes._clear_hub_runtime_caches() + + assert calls == ["catalog", "device"] + + +def test_hub_component_install_stream_reports_child_progress(isolated_hub_env, monkeypatch: pytest.MonkeyPatch): + from flocks.server.routes.hub import router + + async def noop_refresh(_plugin_type): + return None + + monkeypatch.setattr("flocks.hub.installer._refresh_runtime", noop_refresh) + _patch_webui_bundle_build(monkeypatch) + + app = FastAPI() + app.include_router(router, prefix="/api") + client = TestClient(app, raise_server_exceptions=True) + + response = client.post("/api/hub/plugins/component/soc-workspace/install/stream", json={"scope": "global"}) + + assert response.status_code == 200 + frames = [ + json.loads(line.removeprefix("data: ")) + for line in response.text.splitlines() + if line.startswith("data: ") + ] + assert frames[0]["event"] == "start" + assert frames[0]["type"] == "component" + assert frames[0]["id"] == "soc-workspace" + assert [item["status"] for item in frames[0]["items"]] == ["pending", "pending", "pending", "pending"] + + installed_children = { + (frame["item"]["type"], frame["item"]["id"]) + for frame in frames + if frame["event"] == "item" and frame["item"]["status"] == "installed" + } + assert installed_children == { + ("webui", "soc_ui"), + ("tool", "soc_workspace_query"), + ("workflow", "stream_alert_denoise"), + ("workflow", "stream_alert_triage"), + } + assert frames[-1]["event"] == "complete" + assert frames[-1]["record"]["id"] == "soc-workspace" + + def test_hub_routes_legacy_removed_plugins_return_gone(isolated_hub_env): from flocks.server.routes.hub import router diff --git a/tests/mcp/test_mcp_server.py b/tests/mcp/test_mcp_server.py index a913f77eb..1ea0264ea 100644 --- a/tests/mcp/test_mcp_server.py +++ b/tests/mcp/test_mcp_server.py @@ -1,5 +1,8 @@ from __future__ import annotations +import asyncio +from types import SimpleNamespace + import pytest from flocks.mcp.server import McpServerManager @@ -63,3 +66,52 @@ def __init__(self, **kwargs) -> None: assert captured["env"] == {"DEMO_TOKEN": "secret"} assert manager._status["legacy-demo"].status == McpStatus.CONNECTED + + +@pytest.mark.asyncio +async def test_init_is_serialized_for_concurrent_callers(monkeypatch: pytest.MonkeyPatch): + manager = McpServerManager() + config = SimpleNamespace( + mcp={ + "demo": { + "type": "remote", + "url": "https://example.invalid/mcp", + "enabled": True, + } + } + ) + + get_calls = 0 + connect_calls = 0 + retry_started = 0 + + async def fake_get_config(): + nonlocal get_calls + get_calls += 1 + await asyncio.sleep(0) + return config + + async def fake_connect_and_register(name, server_config): + nonlocal connect_calls + connect_calls += 1 + await asyncio.sleep(0.01) + raise RuntimeError("connect boom") + + async def fake_retry_failed_servers(): + nonlocal retry_started + retry_started += 1 + await asyncio.sleep(60) + + monkeypatch.setattr("flocks.mcp.server.Config.get", fake_get_config) + monkeypatch.setattr(manager, "_connect_and_register", fake_connect_and_register) + monkeypatch.setattr(manager, "_retry_failed_servers", fake_retry_failed_servers) + + try: + await asyncio.gather(manager.init(), manager.init()) + await asyncio.sleep(0) + + assert get_calls == 1 + assert connect_calls == 1 + assert retry_started == 1 + finally: + await manager.shutdown() diff --git a/tests/provider/test_api_service_management.py b/tests/provider/test_api_service_management.py index 768978cad..1b90da15e 100644 --- a/tests/provider/test_api_service_management.py +++ b/tests/provider/test_api_service_management.py @@ -51,11 +51,12 @@ async def test_list_api_services_returns_enabled_state_and_bilingual_description side_effect=lambda service_id: [object(), object()] if service_id == "threatbook_api" else [object()], ), ): - mock_tool_registry.init = MagicMock() + mock_tool_registry.init_async = AsyncMock() mock_tool_registry.get_api_service_ids.return_value = {"fofa"} result = await list_api_services() + mock_tool_registry.init_async.assert_awaited_once() assert [item.id for item in result] == ["fofa", "threatbook_api"] assert result[0].enabled is False assert result[0].status == "disabled" @@ -98,6 +99,10 @@ async def test_update_api_service_persists_enabled_flag_and_updates_status_cache "flocks.server.routes.provider._build_api_service_summary", return_value=expected_summary, ), + patch( + "flocks.tool.registry.ToolRegistry.init_async", + new=AsyncMock(), + ), ): result = await update_api_service( "threatbook_api", diff --git a/tests/provider/test_openai_base_provider.py b/tests/provider/test_openai_base_provider.py index f4beeea4a..5c2202411 100644 --- a/tests/provider/test_openai_base_provider.py +++ b/tests/provider/test_openai_base_provider.py @@ -48,6 +48,12 @@ def __init__(self): super().__init__(provider_id="custom-provider", name="Custom Provider") +class MockProviderPrefersCompletionTokens(MockProviderWithoutCatalog): + """Mock OpenAI-compatible provider that opts into newer token naming.""" + + PREFER_MAX_COMPLETION_TOKENS = True + + class TestOpenAIBaseProviderGetModels: """Test suite for get_models() method.""" @@ -400,6 +406,13 @@ def _build_provider_with_client(self): provider._client.chat.completions.create = create return provider, create + def _build_completion_preferring_provider_with_client(self): + provider = MockProviderPrefersCompletionTokens() + create = AsyncMock() + provider._client = MagicMock() + provider._client.chat.completions.create = create + return provider, create + @staticmethod def _mock_chat_response(content: str = "Paris"): response = MagicMock() @@ -413,7 +426,7 @@ def _mock_chat_response(content: str = "Paris"): return response @pytest.mark.asyncio - async def test_chat_omits_temperature_when_not_provided(self): + async def test_chat_uses_max_tokens_by_default_when_max_tokens_provided(self): provider, create = self._build_provider_with_client() create.return_value = self._mock_chat_response() @@ -429,6 +442,24 @@ async def test_chat_omits_temperature_when_not_provided(self): assert "temperature" not in kwargs assert kwargs["model"] == "kimi-k2.5" assert kwargs["max_tokens"] == 20 + assert "max_completion_tokens" not in kwargs + + @pytest.mark.asyncio + async def test_chat_prefers_max_completion_tokens_when_provider_opts_in(self): + provider, create = self._build_completion_preferring_provider_with_client() + create.return_value = self._mock_chat_response() + + from flocks.provider.provider import ChatMessage + + await provider.chat( + "gpt-5.4", + [ChatMessage(role="user", content="hello")], + max_completion_tokens=20, + ) + + kwargs = create.await_args.kwargs + assert kwargs["max_completion_tokens"] == 20 + assert "max_tokens" not in kwargs @pytest.mark.asyncio async def test_chat_passes_explicit_temperature(self): @@ -446,6 +477,79 @@ async def test_chat_passes_explicit_temperature(self): kwargs = create.await_args.kwargs assert kwargs["temperature"] == 1.0 + @pytest.mark.asyncio + async def test_chat_accepts_direct_max_completion_tokens_kwarg(self): + provider, create = self._build_provider_with_client() + create.return_value = self._mock_chat_response() + + from flocks.provider.provider import ChatMessage + + await provider.chat( + "gpt-5.4", + [ChatMessage(role="user", content="hello")], + max_completion_tokens=33, + ) + + kwargs = create.await_args.kwargs + assert kwargs["max_completion_tokens"] == 33 + assert "max_tokens" not in kwargs + + @pytest.mark.asyncio + async def test_chat_treats_explicit_max_completion_tokens_as_authoritative(self): + provider, create = self._build_provider_with_client() + create.return_value = self._mock_chat_response() + + from flocks.provider.provider import ChatMessage + + await provider.chat( + "gpt-5.4", + [ChatMessage(role="user", content="hello")], + max_tokens=20, + max_completion_tokens=33, + ) + + kwargs = create.await_args.kwargs + assert kwargs["max_completion_tokens"] == 33 + assert "max_tokens" not in kwargs + + @pytest.mark.asyncio + async def test_chat_falls_back_to_max_tokens_when_completion_variant_is_rejected(self): + provider, create = self._build_provider_with_client() + create.side_effect = [ + ValueError("unsupported parameter: max_completion_tokens"), + self._mock_chat_response(), + ] + + from flocks.provider.provider import ChatMessage + + await provider.chat( + "gpt-5.4", + [ChatMessage(role="user", content="hello")], + max_completion_tokens=20, + ) + + assert create.await_count == 2 + assert create.await_args_list[0].kwargs["max_completion_tokens"] == 20 + assert "max_tokens" not in create.await_args_list[0].kwargs + assert create.await_args_list[1].kwargs["max_tokens"] == 20 + assert "max_completion_tokens" not in create.await_args_list[1].kwargs + + @pytest.mark.asyncio + async def test_chat_does_not_fallback_for_completion_token_value_errors(self): + provider, create = self._build_provider_with_client() + create.side_effect = ValueError("max_completion_tokens must be <= 4096") + + from flocks.provider.provider import ChatMessage + + with pytest.raises(ValueError, match="max_completion_tokens must be <= 4096"): + await provider.chat( + "gpt-5.4", + [ChatMessage(role="user", content="hello")], + max_completion_tokens=200000, + ) + + assert create.await_count == 1 + def test_summarise_messages_compacts_long_history(self): summary = openai_base_module._summarise_messages( [ @@ -562,6 +666,14 @@ def _build_provider_with_stream(): provider._client.chat.completions.create = create return provider, create + @staticmethod + def _build_completion_preferring_provider_with_stream(): + provider = MockProviderPrefersCompletionTokens() + create = AsyncMock() + provider._client = MagicMock() + provider._client.chat.completions.create = create + return provider, create + @pytest.mark.asyncio async def test_chat_stream_includes_usage_and_attaches_to_terminal_chunk(self): provider, create = self._build_provider_with_stream() @@ -682,6 +794,91 @@ async def test_chat_stream_retries_without_stream_options_when_unsupported(self) "total_tokens": 8, } + @pytest.mark.asyncio + async def test_chat_stream_falls_back_to_max_tokens_when_completion_variant_is_rejected(self): + provider, create = self._build_completion_preferring_provider_with_stream() + + create.side_effect = [ + ValueError("unsupported parameter: max_completion_tokens"), + _stream_from_chunks( + SimpleNamespace( + choices=[ + SimpleNamespace( + delta=SimpleNamespace(content="hello", tool_calls=None), + finish_reason="stop", + ) + ], + usage=SimpleNamespace(prompt_tokens=5, completion_tokens=3, total_tokens=8), + ) + ), + ] + + from flocks.provider.provider import ChatMessage + + chunks = [ + chunk + async for chunk in provider.chat_stream( + "gpt-5.4", + [ChatMessage(role="user", content="hello")], + max_tokens=20, + ) + ] + + assert create.await_count == 2 + assert create.await_args_list[0].kwargs["max_completion_tokens"] == 20 + assert "max_tokens" not in create.await_args_list[0].kwargs + assert create.await_args_list[1].kwargs["max_tokens"] == 20 + assert "max_completion_tokens" not in create.await_args_list[1].kwargs + assert chunks[-1].usage == { + "prompt_tokens": 5, + "completion_tokens": 3, + "total_tokens": 8, + } + + @pytest.mark.asyncio + async def test_chat_stream_chains_completion_and_usage_fallbacks(self): + provider, create = self._build_completion_preferring_provider_with_stream() + + create.side_effect = [ + ValueError("unsupported parameter: max_completion_tokens"), + ValueError("unsupported parameter: include_usage"), + _stream_from_chunks( + SimpleNamespace( + choices=[ + SimpleNamespace( + delta=SimpleNamespace(content="hello", tool_calls=None), + finish_reason="stop", + ) + ], + usage=SimpleNamespace(prompt_tokens=5, completion_tokens=3, total_tokens=8), + ) + ), + ] + + from flocks.provider.provider import ChatMessage + + chunks = [ + chunk + async for chunk in provider.chat_stream( + "gpt-5.4", + [ChatMessage(role="user", content="hello")], + max_tokens=20, + ) + ] + + assert create.await_count == 3 + assert create.await_args_list[0].kwargs["max_completion_tokens"] == 20 + assert "stream_options" in create.await_args_list[0].kwargs + assert create.await_args_list[1].kwargs["max_tokens"] == 20 + assert "stream_options" in create.await_args_list[1].kwargs + assert create.await_args_list[2].kwargs["max_tokens"] == 20 + assert "stream_options" not in create.await_args_list[2].kwargs + assert chunks[-1].usage == { + "prompt_tokens": 5, + "completion_tokens": 3, + "total_tokens": 8, + } + if __name__ == "__main__": pytest.main([__file__, "-v"]) diff --git a/tests/provider/test_openai_compatible_provider.py b/tests/provider/test_openai_compatible_provider.py index 80a16f1d9..ee8389fe6 100644 --- a/tests/provider/test_openai_compatible_provider.py +++ b/tests/provider/test_openai_compatible_provider.py @@ -56,8 +56,8 @@ def test_get_client_respects_verify_ssl_false(self, mock_async_openai, mock_http assert kwargs["trust_env"] is True timeout_arg = kwargs["timeout"] assert getattr(timeout_arg, "connect", None) == 30.0 - assert getattr(timeout_arg, "read", None) == 600.0 - assert getattr(timeout_arg, "write", None) == 600.0 + assert getattr(timeout_arg, "read", None) == 180.0 + assert getattr(timeout_arg, "write", None) == 1800.0 mock_async_openai.assert_called_once_with( api_key="test-api-key", @@ -88,7 +88,7 @@ async def _stream_from_chunks(*chunks): class TestOpenAICompatibleProviderTemperature: @pytest.mark.asyncio - async def test_chat_omits_temperature_when_not_provided(self): + async def test_chat_prefers_max_completion_tokens_when_max_tokens_provided(self): provider, create = _build_provider_with_client() create.return_value = _mock_chat_response() @@ -101,7 +101,8 @@ async def test_chat_omits_temperature_when_not_provided(self): kwargs = create.await_args.kwargs assert "temperature" not in kwargs assert kwargs["model"] == "kimi-k2.5" - assert kwargs["max_tokens"] == 20 + assert kwargs["max_completion_tokens"] == 20 + assert "max_tokens" not in kwargs @pytest.mark.asyncio async def test_chat_passes_explicit_temperature(self): @@ -117,6 +118,71 @@ async def test_chat_passes_explicit_temperature(self): kwargs = create.await_args.kwargs assert kwargs["temperature"] == 1.0 + @pytest.mark.asyncio + async def test_chat_accepts_direct_max_completion_tokens_kwarg(self): + provider, create = _build_provider_with_client() + create.return_value = _mock_chat_response() + + await provider.chat( + "gpt-5.4", + [ChatMessage(role="user", content="hello")], + max_completion_tokens=33, + ) + + kwargs = create.await_args.kwargs + assert kwargs["max_completion_tokens"] == 33 + assert "max_tokens" not in kwargs + + @pytest.mark.asyncio + async def test_chat_treats_explicit_max_completion_tokens_as_authoritative(self): + provider, create = _build_provider_with_client() + create.return_value = _mock_chat_response() + + await provider.chat( + "gpt-5.4", + [ChatMessage(role="user", content="hello")], + max_tokens=20, + max_completion_tokens=33, + ) + + kwargs = create.await_args.kwargs + assert kwargs["max_completion_tokens"] == 33 + assert "max_tokens" not in kwargs + + @pytest.mark.asyncio + async def test_chat_falls_back_to_max_tokens_when_completion_variant_is_rejected(self): + provider, create = _build_provider_with_client() + create.side_effect = [ + ValueError("unsupported parameter: max_completion_tokens"), + _mock_chat_response(), + ] + + await provider.chat( + "gpt-5.4", + [ChatMessage(role="user", content="hello")], + max_tokens=20, + ) + + assert create.await_count == 2 + assert create.await_args_list[0].kwargs["max_completion_tokens"] == 20 + assert "max_tokens" not in create.await_args_list[0].kwargs + assert create.await_args_list[1].kwargs["max_tokens"] == 20 + assert "max_completion_tokens" not in create.await_args_list[1].kwargs + + @pytest.mark.asyncio + async def test_chat_does_not_fallback_for_completion_token_value_errors(self): + provider, create = _build_provider_with_client() + create.side_effect = ValueError("max_completion_tokens must be <= 4096") + + with pytest.raises(ValueError, match="max_completion_tokens must be <= 4096"): + await provider.chat( + "gpt-5.4", + [ChatMessage(role="user", content="hello")], + max_completion_tokens=200000, + ) + + assert create.await_count == 1 + class TestOpenAICompatibleProviderMiniMaxFallback: def test_is_minimax_empty_response_target_matches_all_minimax_aliases(self): @@ -320,3 +386,82 @@ async def test_chat_stream_retries_without_stream_options_when_unsupported(self) "completion_tokens": 2, "total_tokens": 7, } + + @pytest.mark.asyncio + async def test_chat_stream_falls_back_to_max_tokens_when_completion_variant_is_rejected(self): + provider, create = _build_provider_with_client() + create.side_effect = [ + ValueError("unsupported parameter: max_completion_tokens"), + _stream_from_chunks( + SimpleNamespace( + choices=[ + SimpleNamespace( + delta=SimpleNamespace(content="hello", tool_calls=None), + finish_reason="stop", + ) + ], + usage=SimpleNamespace(prompt_tokens=5, completion_tokens=2, total_tokens=7), + ) + ), + ] + + chunks = [ + chunk + async for chunk in provider.chat_stream( + "gpt-5.4", + [ChatMessage(role="user", content="hello")], + max_tokens=20, + ) + ] + + assert create.await_count == 2 + assert create.await_args_list[0].kwargs["max_completion_tokens"] == 20 + assert "max_tokens" not in create.await_args_list[0].kwargs + assert create.await_args_list[1].kwargs["max_tokens"] == 20 + assert "max_completion_tokens" not in create.await_args_list[1].kwargs + assert chunks[-1].usage == { + "prompt_tokens": 5, + "completion_tokens": 2, + "total_tokens": 7, + } + + @pytest.mark.asyncio + async def test_chat_stream_chains_completion_and_usage_fallbacks(self): + provider, create = _build_provider_with_client() + create.side_effect = [ + ValueError("unsupported parameter: max_completion_tokens"), + ValueError("unsupported parameter: include_usage"), + _stream_from_chunks( + SimpleNamespace( + choices=[ + SimpleNamespace( + delta=SimpleNamespace(content="hello", tool_calls=None), + finish_reason="stop", + ) + ], + usage=SimpleNamespace(prompt_tokens=5, completion_tokens=2, total_tokens=7), + ) + ), + ] + + chunks = [ + chunk + async for chunk in provider.chat_stream( + "gpt-5.4", + [ChatMessage(role="user", content="hello")], + max_tokens=20, + ) + ] + + assert create.await_count == 3 + assert create.await_args_list[0].kwargs["max_completion_tokens"] == 20 + assert "stream_options" in create.await_args_list[0].kwargs + assert create.await_args_list[1].kwargs["max_tokens"] == 20 + assert "stream_options" in create.await_args_list[1].kwargs + assert create.await_args_list[2].kwargs["max_tokens"] == 20 + assert "stream_options" not in create.await_args_list[2].kwargs + assert chunks[-1].usage == { + "prompt_tokens": 5, + "completion_tokens": 2, + "total_tokens": 7, + } diff --git a/tests/provider/test_provider.py b/tests/provider/test_provider.py index c56f46b1c..6309bb141 100644 --- a/tests/provider/test_provider.py +++ b/tests/provider/test_provider.py @@ -132,6 +132,31 @@ def test_resolve_model_prefers_provider_specific_runtime_model(monkeypatch): assert resolved.capabilities.interleaved["field"] == "reasoning_content" +def test_dynamic_openai_compatible_provider_prefers_max_completion_tokens(monkeypatch): + monkeypatch.setattr(Provider, "_providers", {}) + monkeypatch.setattr(Provider, "_models", {}) + + monkeypatch.setattr( + "flocks.config.config_writer.ConfigWriter.get_all_providers", + lambda: { + "custom-gpt5": { + "name": "Custom GPT5", + "npm": "@ai-sdk/openai-compatible", + "options": {"baseURL": "https://example.test/v1"}, + } + }, + ) + monkeypatch.setattr( + "flocks.provider.credential.get_api_key", + lambda _provider_id: "test-key", + ) + + Provider._load_dynamic_providers() + + provider = Provider._providers["custom-gpt5"] + assert provider.PREFER_MAX_COMPLETION_TOKENS is True + + def test_resolve_model_infers_interleaved_for_runtime_discovered_reasoning_model(monkeypatch): provider_model = SimpleNamespace( id="qwen3-max", diff --git a/tests/scripts/test_install_script_sources.py b/tests/scripts/test_install_script_sources.py index 18bb7f9b5..cc58186e1 100644 --- a/tests/scripts/test_install_script_sources.py +++ b/tests/scripts/test_install_script_sources.py @@ -155,6 +155,10 @@ def test_main_bash_installer_uses_configured_default_sources_without_probing() - assert 'npm_config_registry="$NPM_REGISTRY" "$NPM_CMD" install' in script assert 'npm_config_registry="$NPM_REGISTRY" "$NPX_CMD" --yes @puppeteer/browsers install chrome@stable --path "$browser_dir"' in script assert 'npm_config_registry="$NPM_REGISTRY" "$NPM_CMD" install --global agent-browser' in script + assert 'write_install_profile()' in script + assert '"Language": "%s"' in script + assert 'write_install_profile' in script + assert "Failed to write install profile. Continuing with the default installer behavior." in script assert "FLOCKS_NODEJS_MANUAL_DOWNLOAD_URL" in script assert "https://nodejs.org/en/download" in script assert "nodejs_manual_download_hint" in script @@ -201,6 +205,10 @@ def test_main_powershell_installer_uses_configured_default_sources_and_admin_pre assert "irm '$script:UvInstallPs1SecondaryFallbackUrl' | iex" in script assert 'function Assert-Administrator' in script assert 'Assert-Administrator' in script + assert 'function Write-InstallProfile' in script + assert '[ordered]@{ Language = $language }' in script + assert 'Write-InstallProfile' in script + assert "Failed to write install profile. Continuing with the default installer behavior." in script def test_windows_bootstrap_installers_detect_system32_and_fall_back_to_home() -> None: diff --git a/tests/server/routes/test_agent_routes.py b/tests/server/routes/test_agent_routes.py index d222ec0d3..4a22e83d7 100644 --- a/tests/server/routes/test_agent_routes.py +++ b/tests/server/routes/test_agent_routes.py @@ -15,6 +15,7 @@ import json from pathlib import Path +from types import SimpleNamespace import pytest from fastapi import status @@ -56,6 +57,25 @@ def _isolated_delegatable_settings(tmp_path: Path, monkeypatch: pytest.MonkeyPat # =========================================================================== class TestAgentList: + @pytest.mark.asyncio + async def test_tool_name_lookup_uses_async_registry_init(self, monkeypatch: pytest.MonkeyPatch): + from flocks.server.routes import agent as agent_routes + from flocks.tool.registry import ToolRegistry + + calls: list[str] = [] + + async def fake_init_async(cls): + calls.append("init_async") + + monkeypatch.setattr(ToolRegistry, "init_async", classmethod(fake_init_async)) + monkeypatch.setattr( + ToolRegistry, + "list_tools", + classmethod(lambda cls: [SimpleNamespace(name="demo_tool")]), + ) + + assert await agent_routes._get_all_tool_names_async() == ["demo_tool"] + assert calls == ["init_async"] @pytest.mark.asyncio async def test_list_agents_returns_array(self, client: AsyncClient): diff --git a/tests/server/routes/test_auth_audit_routes.py b/tests/server/routes/test_auth_audit_routes.py index 00f3bf908..c01396eb9 100644 --- a/tests/server/routes/test_auth_audit_routes.py +++ b/tests/server/routes/test_auth_audit_routes.py @@ -74,6 +74,75 @@ async def _emit(event_type: str, payload: dict): assert emitted[0][1]["username"] == "chenjie" +async def test_login_rate_limits_repeated_failures(monkeypatch: pytest.MonkeyPatch): + from flocks.server.routes import auth as auth_routes + + auth_routes._login_rate_limiter.reset() + calls = {"login": 0} + + async def _login(_username: str, _password: str): + calls["login"] += 1 + raise ValueError("用户名或密码错误") + + async def _emit(_event_type: str, _payload: dict): + return None + + monkeypatch.setattr(auth_routes.AuthService, "login", _login) + monkeypatch.setattr(auth_routes, "_emit_auth_audit", _emit) + + request = SimpleNamespace(client=SimpleNamespace(host="127.0.0.1")) + response = Response() + payload = auth_routes.LoginRequest(username="chenjie", password="bad") + try: + for _ in range(auth_routes._LOGIN_MAX_FAILURES_PER_USER_AND_IP): + with pytest.raises(HTTPException) as exc_info: + await auth_routes.login(payload, response, request) + assert exc_info.value.status_code == 400 + + with pytest.raises(HTTPException) as exc_info: + await auth_routes.login(payload, response, request) + assert exc_info.value.status_code == 429 + assert exc_info.value.headers["Retry-After"] + + with pytest.raises(HTTPException) as exc_info: + await auth_routes.login(payload, response, request) + assert exc_info.value.status_code == 429 + assert calls["login"] == auth_routes._LOGIN_MAX_FAILURES_PER_USER_AND_IP + 1 + finally: + auth_routes._login_rate_limiter.reset() + + +async def test_login_rate_limiter_prunes_expired_buckets(): + from flocks.server.routes import auth as auth_routes + + limiter = auth_routes._LoginRateLimiter() + now = auth_routes.time.monotonic() + stale_key = ("user_ip", "stale@127.0.0.1") + limiter._failures[stale_key] = [now - auth_routes._LOGIN_FAILURE_WINDOW_SECONDS - 1] + limiter._locked_until[stale_key] = now - 1 + limiter._last_pruned_at = now - auth_routes._LOGIN_PRUNE_INTERVAL_SECONDS - 1 + + limiter.record_failure(username="chenjie", ip="127.0.0.1") + + assert stale_key not in limiter._failures + assert stale_key not in limiter._locked_until + + +async def test_login_rate_limiter_caps_tracked_buckets(monkeypatch: pytest.MonkeyPatch): + from flocks.server.routes import auth as auth_routes + + monkeypatch.setattr(auth_routes, "_LOGIN_MAX_TRACKED_BUCKETS", 4) + monkeypatch.setattr(auth_routes, "_LOGIN_PRUNE_INTERVAL_SECONDS", 0) + limiter = auth_routes._LoginRateLimiter() + + for index in range(10): + limiter.record_failure(username=f"user{index}", ip="127.0.0.1") + + assert limiter._tracked_bucket_count() <= auth_routes._LOGIN_MAX_TRACKED_BUCKETS + assert ("user_ip", "user9@127.0.0.1") in limiter._failures + assert ("ip", "127.0.0.1") in limiter._failures + + async def test_logout_emits_audit_event(monkeypatch: pytest.MonkeyPatch): from flocks.server.routes import auth as auth_routes diff --git a/tests/server/routes/test_channel_routes.py b/tests/server/routes/test_channel_routes.py new file mode 100644 index 000000000..157383512 --- /dev/null +++ b/tests/server/routes/test_channel_routes.py @@ -0,0 +1,86 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest + +from flocks.channel.base import DeliveryResult +from flocks.server.routes.channel import SessionSendRequest, channel_session_send +from fastapi import HTTPException + + +@pytest.mark.asyncio +async def test_channel_session_send_falls_back_to_latest_channel_binding() -> None: + latest_binding = SimpleNamespace( + session_id="ses_new", + channel_id="wecom", + account_id="default", + chat_id="room_1", + ) + svc = SimpleNamespace( + list_bindings=AsyncMock(return_value=[latest_binding]), + latest_active_user_binding=AsyncMock(return_value=latest_binding), + ) + deliver_result = DeliveryResult( + channel_id="wecom", + message_id="msg_new", + chat_id="room_1", + ) + + with patch( + "flocks.channel.inbound.session_binding.SessionBindingService", + return_value=svc, + ), patch( + "flocks.channel.outbound.deliver.OutboundDelivery.deliver", + AsyncMock(return_value=[deliver_result]), + ) as deliver: + result = await channel_session_send( + SessionSendRequest( + session_id="ses_old", + text="hello", + channel_type="wecom", + ) + ) + + assert result["ok"] is True + assert result["session_id"] == "ses_new" + assert result["message_ids"] == ["msg_new"] + svc.latest_active_user_binding.assert_awaited_once_with( + channel_id="wecom", + account_id=None, + chat_id=None, + ) + deliver.assert_awaited_once() + assert deliver.await_args.kwargs["session_id"] == "ses_new" + + +@pytest.mark.asyncio +async def test_channel_session_send_returns_404_when_channel_binding_is_ambiguous() -> None: + svc = SimpleNamespace( + list_bindings=AsyncMock(return_value=[]), + latest_active_user_binding=AsyncMock(return_value=None), + ) + + with patch( + "flocks.channel.inbound.session_binding.SessionBindingService", + return_value=svc, + ), patch( + "flocks.channel.outbound.deliver.OutboundDelivery.deliver", + AsyncMock(), + ) as deliver: + with pytest.raises(HTTPException) as exc_info: + await channel_session_send( + SessionSendRequest( + session_id="ses_old", + text="hello", + channel_type="wecom", + ) + ) + + assert exc_info.value.status_code == 404 + assert "im_send_message(resolve_only=true)" in str(exc_info.value.detail) + svc.latest_active_user_binding.assert_awaited_once_with( + channel_id="wecom", + account_id=None, + chat_id=None, + ) + deliver.assert_not_awaited() diff --git a/tests/server/routes/test_console_upgrade_routes.py b/tests/server/routes/test_console_upgrade_routes.py index 575e1e10e..d4dc0b55e 100644 --- a/tests/server/routes/test_console_upgrade_routes.py +++ b/tests/server/routes/test_console_upgrade_routes.py @@ -248,7 +248,7 @@ async def test_pro_package_status_reports_installed_marker( console_routes, "_read_pro_bundle_install_marker", lambda: { - "installed_version": "pro-v2026-05-13-3", + "bundle_version": "pro-v2026-05-13-3", "flockspro_component_version": "1.2.3", "build_id": "build_1", "installed_at": "2026-05-15T12:00:00+00:00", @@ -276,7 +276,7 @@ async def test_pro_package_status_treats_install_marker_as_installed( console_routes, "_read_pro_bundle_install_marker", lambda: { - "installed_version": "pro-v2026.6.23", + "bundle_version": "pro-v2026.6.23", "flockspro_component_version": "2026.6.23", "installed_at": "2026-06-29T04:00:00+00:00", }, @@ -292,6 +292,404 @@ async def test_pro_package_status_treats_install_marker_as_installed( assert payload["inactive_reason"] == "flockspro_not_installed" +async def test_downgrade_pro_package_reports_console_and_preserves_request( + client: AsyncClient, + monkeypatch: pytest.MonkeyPatch, +): + from flocks.server.routes import console_upgrade as console_routes + from flocks.storage.storage import Storage + from flocks.updater.models import UpdateProgress + + monkeypatch.setenv("FLOCKS_CONSOLE_BASE_URL", "https://console.example.com") + monkeypatch.setattr(console_routes, "require_admin", lambda _req: _mock_admin()) + await _set_bound_console_session() + request_id = "req_downgrade_001" + await Storage.set("console:upgrade_request_ids", [request_id], "json") + await Storage.set( + f"console:upgrade_request:{request_id}", + { + "request_id": request_id, + "status": "activated", + "previous_request_id": None, + "reason": None, + "suggestion": None, + "activate_key": "key_downgrade", + "license_id": "lic_downgrade", + "license_status": "poc", + "manifest_url": "https://manifest.example.com/v1/manifest/latest", + "details": { + "license_id": "lic_downgrade", + "console_account_name": "alice", + "passport_uid": "pass_1", + "auto_install_pro_version": "v2026.6.24", + }, + "created_at": "2026-05-08T08:00:00+00:00", + "updated_at": "2026-05-08T08:00:00+00:00", + }, + "json", + ) + + posted_payloads: list[dict] = [] + local_downgrade_started = False + + class _Response: + def raise_for_status(self): + return None + + def json(self) -> dict: + return {"id": "instrec_downgrade", "ok": True} + + class _Client: + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def post(self, url, json=None, headers=None): + assert url == "https://console.example.com/v1/pro-bundles/installations" + assert headers == {"Authorization": "Bearer token_abc"} + posted_payloads.append(json) + return _Response() + + async def _fake_downgrade(*, restart: bool, reason: str | None = None, after_uninstall=None): + nonlocal local_downgrade_started + assert restart is True + assert reason == "user_requested" + assert posted_payloads == [] + assert after_uninstall is not None + yield UpdateProgress(stage="downgrading", message="Removing Flocks Pro component...", success=None) + local_downgrade_started = True + yield UpdateProgress(stage="reporting", message="Reporting OSS downgrade to Console...", success=None) + await after_uninstall() + assert posted_payloads, "Console must be synced after local downgrade" + yield UpdateProgress(stage="done", message="Downgraded to OSS edition.", success=True) + + monkeypatch.setattr(console_routes.httpx, "AsyncClient", lambda timeout=10: _Client()) + monkeypatch.setattr(console_routes, "perform_pro_bundle_downgrade", _fake_downgrade) + monkeypatch.setattr(console_routes, "_is_pro_component_installed", lambda: True) + monkeypatch.setattr( + console_routes, + "_read_pro_bundle_install_marker", + lambda: { + "release_id": "rel_downgrade", + "bundle_release_id": "rel_downgrade", + "installed_version": "v2026.6.24", + "core_version": "v2026.6.21", + "flockspro_component_version": "v2026.6.24", + "build_id": "job_downgrade", + }, + ) + monkeypatch.setattr( + console_routes, + "_get_pro_capability_status", + lambda: {"pro_enabled": True, "active": True, "license_id": "lic_downgrade"}, + ) + + resp = await client.post("/api/console/pro-package/downgrade", json={"reason": "user_requested"}) + + assert resp.status_code == status.HTTP_200_OK + assert "Reporting OSS downgrade to Console" in resp.text + assert local_downgrade_started is True + assert posted_payloads[0]["install_result"] == "downgraded" + assert posted_payloads[0]["runtime_edition"] == "oss" + assert posted_payloads[0]["request_id"] == request_id + assert posted_payloads[0]["license_id"] == "lic_downgrade" + assert posted_payloads[0]["bundle_version"] == "v2026.6.24" + assert "installed_version" not in posted_payloads[0] + assert "oss_version" not in posted_payloads[0] + stored = await Storage.get(f"console:upgrade_request:{request_id}") + assert stored["status"] == "activated" + assert stored["details"]["local_downgrade_reported_at"] + assert stored["details"]["local_downgrade_result"] == "done" + assert stored["details"]["local_downgrade_installation_id"] == "instrec_downgrade" + + +async def test_report_pro_bundle_downgrade_uses_request_target_when_marker_missing( + monkeypatch: pytest.MonkeyPatch, +): + from flocks.server.routes import console_upgrade as console_routes + + monkeypatch.setenv("FLOCKS_CONSOLE_BASE_URL", "https://console.example.com") + await _set_bound_console_session() + posted_payloads: list[dict] = [] + + class _Response: + def raise_for_status(self): + return None + + def json(self) -> dict: + return {"id": "instrec_fallback", "ok": True} + + class _Client: + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def post(self, url, json=None, headers=None): + posted_payloads.append(json) + return _Response() + + monkeypatch.setattr(console_routes.httpx, "AsyncClient", lambda timeout=10: _Client()) + monkeypatch.setattr(console_routes, "_read_pro_bundle_install_marker", lambda: {}) + monkeypatch.setattr(console_routes, "_get_pro_capability_status", lambda: {"license_id": "lic_fallback"}) + + record = { + "request_id": "req_fallback", + "license_id": "lic_fallback", + "approved_bundle_release_id": "rel_fallback", + "details": { + "bundle_release_id": "rel_fallback", + "bundle_version_update_to": "v2026.7.8", + "core_version_update_to": "v2026.7.1", + "flockspro_component_version_update_to": "v2026.7.8-pro", + "target_build_id": "build_fallback", + }, + } + + await console_routes._report_pro_bundle_downgrade(record, reason="retry_after_marker_missing") + + assert posted_payloads[0]["request_id"] == "req_fallback" + assert posted_payloads[0]["release_id"] == "rel_fallback" + assert posted_payloads[0]["bundle_release_id"] == "rel_fallback" + assert posted_payloads[0]["license_id"] == "lic_fallback" + assert posted_payloads[0]["bundle_version"] == "v2026.7.8" + assert posted_payloads[0]["core_version"] == "v2026.7.1" + assert posted_payloads[0]["flockspro_component_version"] == "v2026.7.8-pro" + assert posted_payloads[0]["build_id"] == "build_fallback" + + +async def test_downgrade_pro_package_does_not_report_when_local_downgrade_fails( + client: AsyncClient, + monkeypatch: pytest.MonkeyPatch, +): + from flocks.server.routes import console_upgrade as console_routes + from flocks.updater.models import UpdateProgress + + monkeypatch.setenv("FLOCKS_CONSOLE_BASE_URL", "https://console.example.com") + monkeypatch.setattr(console_routes, "require_admin", lambda _req: _mock_admin()) + await _set_bound_console_session() + posted_payloads: list[dict] = [] + + class _Client: + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def post(self, url, json=None, headers=None): + posted_payloads.append(json) + raise AssertionError("Console downgrade receipt must not be sent before local downgrade succeeds") + + async def _fake_downgrade(*, restart: bool, reason: str | None = None, after_uninstall=None): + assert after_uninstall is not None + yield UpdateProgress(stage="downgrading", message="Removing Flocks Pro component...", success=None) + yield UpdateProgress(stage="error", message="local uninstall failed", success=False) + + monkeypatch.setattr(console_routes.httpx, "AsyncClient", lambda timeout=10: _Client()) + monkeypatch.setattr(console_routes, "perform_pro_bundle_downgrade", _fake_downgrade) + monkeypatch.setattr(console_routes, "_is_pro_component_installed", lambda: True) + monkeypatch.setattr(console_routes, "_read_pro_bundle_install_marker", lambda: {"bundle_version": "v2026.6.24"}) + + resp = await client.post("/api/console/pro-package/downgrade", json={"reason": "user_requested"}) + + assert resp.status_code == status.HTTP_200_OK + assert "local uninstall failed" in resp.text + assert posted_payloads == [] + + +async def test_downgrade_pro_package_reports_console_failure_after_local_downgrade( + client: AsyncClient, + monkeypatch: pytest.MonkeyPatch, + tmp_path, +): + from flocks.server.routes import console_upgrade as console_routes + from flocks.storage.storage import Storage + from flocks.updater.models import UpdateProgress + + monkeypatch.setenv("FLOCKS_ROOT", str(tmp_path)) + monkeypatch.setenv("FLOCKS_CONSOLE_BASE_URL", "https://console.example.com") + monkeypatch.setattr(console_routes, "require_admin", lambda _req: _mock_admin()) + await _set_bound_console_session() + request_id = "req_downgrade_report_failed" + await Storage.set("console:upgrade_request_ids", [request_id], "json") + await Storage.set( + f"console:upgrade_request:{request_id}", + { + "request_id": request_id, + "status": "activated", + "previous_request_id": None, + "reason": None, + "suggestion": None, + "activate_key": "key_report_failed", + "license_id": "lic_report_failed", + "license_status": "poc", + "manifest_url": "https://manifest.example.com/v1/manifest/latest", + "details": { + "license_id": "lic_report_failed", + "console_account_name": "alice", + "passport_uid": "pass_1", + "auto_install_pro_version": "v2026.6.24", + }, + "created_at": "2026-05-08T08:00:00+00:00", + "updated_at": "2026-05-08T08:00:00+00:00", + }, + "json", + ) + + class _Client: + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def post(self, url, json=None, headers=None): + request = httpx.Request("POST", url) + response = httpx.Response(status.HTTP_503_SERVICE_UNAVAILABLE, request=request, json={"message": "console down"}) + raise httpx.HTTPStatusError("console down", request=request, response=response) + + called = False + + async def _fake_downgrade(*, restart: bool, reason: str | None = None, after_uninstall=None): + nonlocal called + called = True + assert after_uninstall is not None + yield UpdateProgress(stage="downgrading", message="Removing Flocks Pro component...", success=None) + yield UpdateProgress(stage="reporting", message="Reporting OSS downgrade to Console...", success=None) + await after_uninstall() + yield UpdateProgress(stage="done", message="Downgraded to OSS edition.", success=True) + + monkeypatch.setattr(console_routes.httpx, "AsyncClient", lambda timeout=10: _Client()) + monkeypatch.setattr(console_routes, "perform_pro_bundle_downgrade", _fake_downgrade) + monkeypatch.setattr(console_routes, "_is_pro_component_installed", lambda: True) + monkeypatch.setattr(console_routes, "_read_pro_bundle_install_marker", lambda: {"installed_version": "v2026.6.24"}) + + resp = await client.post("/api/console/pro-package/downgrade", json={"reason": "user_requested"}) + + assert resp.status_code == status.HTTP_200_OK + assert "Downgraded to OSS edition." in resp.text + assert called is True + stored = await Storage.get(f"console:upgrade_request:{request_id}") + assert stored["details"]["local_downgrade_result"] == "done" + assert stored["details"]["local_downgrade_report_result"] == "pending" + assert stored["details"]["local_downgrade_report_error"] == "console down" + pending = json.loads((tmp_path / "run" / "pro-bundle-downgrade-receipt-pending.json").read_text(encoding="utf-8")) + assert pending["install_result"] == "downgraded" + assert pending["runtime_edition"] == "oss" + assert pending["request_id"] == request_id + assert pending["license_id"] == "lic_report_failed" + assert pending["last_report_error"] == "console down" + + +async def test_downgrade_pro_package_allows_local_downgrade_without_console_login( + client: AsyncClient, + monkeypatch: pytest.MonkeyPatch, + tmp_path, +): + from flocks.server.routes import console_upgrade as console_routes + from flocks.updater.models import UpdateProgress + + monkeypatch.setenv("FLOCKS_ROOT", str(tmp_path)) + monkeypatch.setenv("FLOCKS_CONSOLE_BASE_URL", "https://console.example.com") + monkeypatch.setattr(console_routes, "require_admin", lambda _req: _mock_admin()) + monkeypatch.setattr(console_routes.ConsoleLoginService, "require_console_session", staticmethod(lambda: (_ for _ in ()).throw(ValueError("云账号未登录")))) + + called = False + + async def _fake_downgrade(*, restart: bool, reason: str | None = None, after_uninstall=None): + nonlocal called + called = True + assert restart is True + assert after_uninstall is not None + yield UpdateProgress(stage="downgrading", message="Removing Flocks Pro component...", success=None) + yield UpdateProgress(stage="reporting", message="Reporting OSS downgrade to Console...", success=None) + await after_uninstall() + yield UpdateProgress(stage="done", message="Downgraded to OSS edition.", success=True) + + monkeypatch.setattr(console_routes, "perform_pro_bundle_downgrade", _fake_downgrade) + monkeypatch.setattr(console_routes, "_is_pro_component_installed", lambda: True) + monkeypatch.setattr( + console_routes, + "_read_pro_bundle_install_marker", + lambda: { + "release_id": "rel_no_login", + "bundle_version": "v2026.6.24", + "core_version": "v2026.6.21", + "flockspro_component_version": "v2026.6.24", + }, + ) + + resp = await client.post("/api/console/pro-package/downgrade", json={"reason": "user_requested"}) + + assert resp.status_code == status.HTTP_200_OK + assert "Downgraded to OSS edition." in resp.text + assert called is True + pending = json.loads((tmp_path / "run" / "pro-bundle-downgrade-receipt-pending.json").read_text(encoding="utf-8")) + assert pending["install_result"] == "downgraded" + assert pending["release_id"] == "rel_no_login" + assert pending["bundle_version"] == "v2026.6.24" + assert pending["last_report_error"] == "云账号未登录" + + +async def test_downgrade_pro_package_allows_local_downgrade_without_console_base_url( + client: AsyncClient, + monkeypatch: pytest.MonkeyPatch, + tmp_path, +): + from flocks.server.routes import console_upgrade as console_routes + from flocks.storage.storage import Storage + from flocks.updater.models import UpdateProgress + + monkeypatch.setenv("FLOCKS_ROOT", str(tmp_path)) + monkeypatch.delenv("FLOCKS_CONSOLE_BASE_URL", raising=False) + monkeypatch.setattr(console_routes, "require_admin", lambda _req: _mock_admin()) + await _set_bound_console_session() + request_id = "req_downgrade_no_base" + await Storage.set("console:upgrade_request_ids", [request_id], "json") + await Storage.set( + f"console:upgrade_request:{request_id}", + { + "request_id": request_id, + "status": "activated", + "activate_key": "key_no_base", + "license_id": "lic_no_base", + "license_status": "poc", + "details": {"license_id": "lic_no_base", "console_account_name": "alice"}, + "created_at": "2026-05-08T08:00:00+00:00", + "updated_at": "2026-05-08T08:00:00+00:00", + }, + "json", + ) + + async def _fake_downgrade(*, restart: bool, reason: str | None = None, after_uninstall=None): + assert restart is True + assert after_uninstall is not None + yield UpdateProgress(stage="downgrading", message="Removing Flocks Pro component...", success=None) + yield UpdateProgress(stage="reporting", message="Reporting OSS downgrade to Console...", success=None) + await after_uninstall() + yield UpdateProgress(stage="done", message="Downgraded to OSS edition.", success=True) + + monkeypatch.setattr(console_routes, "perform_pro_bundle_downgrade", _fake_downgrade) + monkeypatch.setattr(console_routes, "_is_pro_component_installed", lambda: True) + monkeypatch.setattr(console_routes, "_read_pro_bundle_install_marker", lambda: {"bundle_version": "v2026.6.24"}) + + resp = await client.post("/api/console/pro-package/downgrade", json={"reason": "user_requested"}) + + assert resp.status_code == status.HTTP_200_OK + assert "Downgraded to OSS edition." in resp.text + stored = await Storage.get(f"console:upgrade_request:{request_id}") + assert stored["details"]["local_downgrade_result"] == "done" + assert stored["details"]["local_downgrade_report_result"] == "pending" + assert stored["details"]["local_downgrade_report_error"] == "FLOCKS_CONSOLE_BASE_URL 未配置,无法同步降级状态" + pending = json.loads((tmp_path / "run" / "pro-bundle-downgrade-receipt-pending.json").read_text(encoding="utf-8")) + assert pending["request_id"] == request_id + assert pending["license_id"] == "lic_no_base" + + async def test_flockspro_license_status_fallback_reports_uninstalled(monkeypatch: pytest.MonkeyPatch): from flocks.server.routes import flockspro_license as license_routes @@ -337,6 +735,50 @@ async def test_flockspro_license_status_delegates_to_pro_runtime(monkeypatch: py assert payload["license_id"] == "lic_1" +async def test_flockspro_license_refresh_sends_heartbeat_from_core(monkeypatch: pytest.MonkeyPatch): + from flocks.server.routes import flockspro_license as license_routes + + app = FastAPI() + app.include_router(license_routes.router, prefix="/api/flockspro/license") + monkeypatch.setattr(license_routes, "_is_pro_component_installed", lambda: True) + monkeypatch.setattr( + license_routes, + "_get_pro_capability_status", + lambda: {"active": True, "pro_enabled": True, "license_status": "poc", "license_id": "lic_1"}, + ) + monkeypatch.setattr(license_routes, "require_user", lambda _req: _mock_admin()) + + heartbeat_calls: list[str] = [] + refresh_calls: list[str] = [] + + async def _send_heartbeat(): + heartbeat_calls.append("sent") + return {"ok": True} + + class _Checker: + async def refresh(self): + refresh_calls.append("refreshed") + return {"active": True} + + runtime_module = ModuleType("flockspro.license.runtime") + runtime_module.get_license_checker = lambda: _Checker() + license_module = ModuleType("flockspro.license") + flockspro_module = ModuleType("flockspro") + monkeypatch.setitem(__import__("sys").modules, "flockspro", flockspro_module) + monkeypatch.setitem(__import__("sys").modules, "flockspro.license", license_module) + monkeypatch.setitem(__import__("sys").modules, "flockspro.license.runtime", runtime_module) + monkeypatch.setattr(license_routes.ConsoleLoginService, "send_heartbeat", _send_heartbeat) + + transport = httpx.ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as local_client: + resp = await local_client.post("/api/flockspro/license/refresh") + + assert resp.status_code == status.HTTP_200_OK + assert heartbeat_calls == ["sent"] + assert refresh_calls == ["refreshed"] + assert resp.json()["license_id"] == "lic_1" + + async def test_create_upgrade_request_does_not_link_previous_request_when_omitted( client: AsyncClient, monkeypatch: pytest.MonkeyPatch, @@ -768,7 +1210,7 @@ async def _fake_report(record: dict, *, install_result: str, error_message: str console_routes, "_read_pro_bundle_install_marker", lambda: { - "installed_version": "v2026.6.5", + "bundle_version": "v2026.6.5", "flockspro_component_version": "v2026.6.5", }, ) @@ -812,7 +1254,7 @@ async def test_restarting_request_reports_receipt_after_service_restart( "approved_bundle_release_id": "rel_restart", "latest_pro_bundle": { "release_id": "rel_restart", - "display_version": "v2026.6.24", + "bundle_version": "v2026.6.24", "core_version": "v2026.6.21", "flockspro_component_version": "v2026.6.24", "build_id": "job_restart", @@ -844,7 +1286,7 @@ async def _fake_report(record: dict, *, install_result: str, error_message: str lambda: { "release_id": "rel_restart", "bundle_release_id": "rel_restart", - "installed_version": "v2026.6.24", + "bundle_version": "v2026.6.24", "core_version": "v2026.6.21", "flockspro_component_version": "v2026.6.24", "build_id": "job_restart", @@ -857,7 +1299,7 @@ async def _fake_report(record: dict, *, install_result: str, error_message: str payload = resp.json() assert payload["status"] == "activated" assert payload["details"]["auto_install_result"] == "done" - assert payload["details"]["auto_install_version"] == "v2026.6.24" + assert payload["details"]["auto_install_bundle_version"] == "v2026.6.24" assert reported == [("success", None)] @@ -977,7 +1419,7 @@ async def _fake_report(record: dict, *, install_result: str, error_message: str monkeypatch.setattr( console_routes, "_read_pro_bundle_install_marker", - lambda: {"installed_version": "v2026.5.9"} if installed else {}, + lambda: {"bundle_version": "v2026.5.9"} if installed else {}, ) resp = await client.post(f"/api/console/upgrade-requests/{request_id}/start") @@ -987,7 +1429,7 @@ async def _fake_report(record: dict, *, install_result: str, error_message: str stored = await Storage.get(f"console:upgrade_request:{request_id}") assert stored["status"] == "activated" assert stored["details"]["auto_install_result"] == "done" - assert stored["details"]["auto_install_version"] == "v2026.5.9" + assert stored["details"]["auto_install_bundle_version"] == "v2026.5.9" async def test_start_revoked_request_does_not_reinstall( @@ -1042,7 +1484,7 @@ async def _noop(_record: dict, **_kwargs): monkeypatch.setattr( console_routes, "_read_pro_bundle_install_marker", - lambda: {"installed_version": "v2026.5.9"}, + lambda: {"bundle_version": "v2026.5.9"}, ) record = { @@ -1057,7 +1499,7 @@ async def _noop(_record: dict, **_kwargs): payload = await console_routes._maybe_auto_activate_upgrade(record) assert payload["status"] == "activated" assert payload["details"]["auto_install_result"] == "already_latest" - assert payload["details"]["auto_install_version"] == "v2026.5.9" + assert payload["details"]["auto_install_bundle_version"] == "v2026.5.9" assert reported == [("success", None)] @@ -1071,7 +1513,7 @@ async def test_auto_activate_reinstalls_when_existing_pro_marker_is_not_target_b "payload": { "release_id": "rel_20260601", "bundle_release_id": "rel_20260601", - "installed_version": "v2026.6.1", + "bundle_version": "v2026.6.1", "flockspro_component_version": "v2026.6.1", "build_id": "job_20260601", } @@ -1084,7 +1526,7 @@ async def _fake_perform_pro_bundle_install(*args, **kwargs): marker_state["payload"] = { "release_id": "rel_20260605", "bundle_release_id": "rel_20260605", - "installed_version": "v2026.6.5", + "bundle_version": "v2026.6.5", "flockspro_component_version": "v2026.6.5", "build_id": "job_20260605", } @@ -1113,7 +1555,7 @@ async def _noop(_record: dict, **_kwargs): "approved_bundle_release_id": "rel_20260605", "latest_pro_bundle": { "release_id": "rel_20260605", - "display_version": "v2026.6.5", + "bundle_version": "v2026.6.5", "flockspro_component_version": "v2026.6.5", "build_id": "job_20260605", }, @@ -1127,7 +1569,7 @@ async def _noop(_record: dict, **_kwargs): assert payload["status"] == "activated" assert payload["details"]["auto_install_result"] == "done" assert payload["details"]["auto_install_release_id"] == "rel_20260605" - assert payload["details"]["auto_install_version"] == "v2026.6.5" + assert payload["details"]["auto_install_bundle_version"] == "v2026.6.5" assert reported == [("success", None)] @@ -1153,7 +1595,7 @@ async def _noop(_record: dict, **_kwargs): "_get_pro_capability_status", lambda: {"pro_enabled": False, "active": False, "license_status": "expired", "inactive_reason": "expired"}, ) - monkeypatch.setattr(console_routes, "_read_pro_bundle_install_marker", lambda: {"installed_version": "v2026.5.9"}) + monkeypatch.setattr(console_routes, "_read_pro_bundle_install_marker", lambda: {"bundle_version": "v2026.5.9"}) record = { "request_id": "req_auto_inactive", @@ -1205,7 +1647,7 @@ async def _noop(_record: dict, **_kwargs): monkeypatch.setattr( console_routes, "_read_pro_bundle_install_marker", - lambda: {"installed_version": "v2026.5.9"} if installed else {}, + lambda: {"bundle_version": "v2026.5.9"} if installed else {}, ) record = { @@ -1220,7 +1662,7 @@ async def _noop(_record: dict, **_kwargs): payload = await console_routes._maybe_auto_activate_upgrade(record) assert payload["status"] == "activated" assert payload["details"]["auto_install_result"] == "done" - assert payload["details"]["auto_install_version"] == "v2026.5.9" + assert payload["details"]["auto_install_bundle_version"] == "v2026.5.9" async def test_report_pro_bundle_installation_uses_license_id( @@ -1253,7 +1695,7 @@ async def post(self, url, json=None, headers=None): monkeypatch.setattr( console_routes, "_read_pro_bundle_install_marker", - lambda: {"installed_version": "v2026.5.9"}, + lambda: {"bundle_version": "v2026.5.9"}, ) record = { @@ -1266,7 +1708,7 @@ async def post(self, url, json=None, headers=None): "approved_bundle_release_id": "rel_receipt", "latest_pro_bundle": { "release_id": "rel_receipt", - "display_version": "v2026.6.5", + "bundle_version": "v2026.6.5", "core_version": "v2026.6.1", "flockspro_component_version": "v2026.6.5", "build_id": "job_receipt", @@ -1281,7 +1723,7 @@ async def post(self, url, json=None, headers=None): assert posted_payloads[0]["release_id"] == "rel_receipt" assert posted_payloads[0]["bundle_release_id"] == "rel_receipt" assert posted_payloads[0]["core_version"] == "v2026.6.1" - assert posted_payloads[0]["oss_version"] == "v2026.6.1" + assert "oss_version" not in posted_payloads[0] assert posted_payloads[0]["build_id"] == "job_receipt" @@ -1317,7 +1759,7 @@ async def post(self, url, json=None, headers=None): lambda: { "release_id": "rel_old", "bundle_release_id": "rel_old", - "installed_version": "v2026.6.1", + "bundle_version": "v2026.6.1", "flockspro_component_version": "v2026.6.1", "build_id": "job_old", }, @@ -1333,8 +1775,8 @@ async def post(self, url, json=None, headers=None): "approved_bundle_release_id": "rel_new", "latest_pro_bundle": { "release_id": "rel_new", - "display_version": "v2026.6.5", - "oss_version": "v2026.6.5", + "bundle_version": "v2026.6.5", + "core_version": "v2026.6.5", "flockspro_component_version": "v2026.6.5", "build_id": "job_new", }, @@ -1349,6 +1791,6 @@ async def post(self, url, json=None, headers=None): assert posted_payloads[0]["release_id"] == "rel_new" assert posted_payloads[0]["bundle_release_id"] == "rel_new" - assert posted_payloads[0]["installed_version"] == "v2026.6.5" + assert posted_payloads[0]["bundle_version"] == "v2026.6.5" assert posted_payloads[0]["build_id"] == "job_new" assert posted_payloads[0]["install_result"] == "failed" diff --git a/tests/server/routes/test_remaining_routes.py b/tests/server/routes/test_remaining_routes.py index f36aa98b3..feb8bf695 100644 --- a/tests/server/routes/test_remaining_routes.py +++ b/tests/server/routes/test_remaining_routes.py @@ -508,6 +508,132 @@ async def test_config_has_expected_top_level_keys(self, client: AsyncClient): f"No expected keys found. Got: {list(data.keys())}" ) + @pytest.mark.asyncio + async def test_ui_display_defaults_and_updates( + self, + client: AsyncClient, + tmp_path, + monkeypatch, + ): + """UI display-name endpoints expose only the visible product name.""" + from flocks.config.config import Config + from flocks.server.routes import config as config_routes + + monkeypatch.setenv("FLOCKS_CONFIG_DIR", str(tmp_path / "config")) + monkeypatch.setattr(config_routes, "_is_flockspro_enabled", lambda: False) + Config._global_config = None + Config._cached_config = None + + resp = await client.get("/api/config/ui-display") + assert resp.status_code == status.HTTP_200_OK + assert resp.json() == { + "displayName": "Flocks", + "configuredDisplayName": None, + "faviconUrl": None, + } + + resp = await client.patch("/api/config/ui", json={"displayName": " Acme SOC "}) + assert resp.status_code == status.HTTP_200_OK + assert resp.json() == { + "displayName": "Acme SOC", + "configuredDisplayName": "Acme SOC", + "faviconUrl": None, + } + + resp = await client.get("/api/config/ui-display") + assert resp.status_code == status.HTTP_200_OK + assert resp.json()["displayName"] == "Acme SOC" + + svg = b'' + resp = await client.post( + "/api/config/ui/favicon", + files={"file": ("favicon.svg", svg, "image/svg+xml")}, + ) + assert resp.status_code == status.HTTP_200_OK, resp.text + data = resp.json() + assert data["displayName"] == "Acme SOC" + assert data["faviconUrl"].startswith("/api/config/ui-favicon?v=") + + favicon_resp = await client.get(data["faviconUrl"]) + assert favicon_resp.status_code == status.HTTP_200_OK + assert favicon_resp.content == svg + assert (tmp_path / "config" / "assets" / "favicon.svg").is_file() + + resp = await client.delete("/api/config/ui/favicon") + assert resp.status_code == status.HTTP_200_OK + assert resp.json()["faviconUrl"] is None + + @pytest.mark.asyncio + async def test_ui_display_defaults_to_flockspro_when_pro_is_enabled( + self, + client: AsyncClient, + tmp_path, + monkeypatch, + ): + """Empty display-name config falls back to the active product edition.""" + from flocks.config.config import Config + from flocks.server.routes import config as config_routes + + monkeypatch.setenv("FLOCKS_CONFIG_DIR", str(tmp_path / "config")) + monkeypatch.setattr(config_routes, "_is_flockspro_enabled", lambda: True) + Config._global_config = None + Config._cached_config = None + + resp = await client.get("/api/config/ui-display") + assert resp.status_code == status.HTTP_200_OK + assert resp.json() == { + "displayName": "Flocks Pro", + "configuredDisplayName": None, + "faviconUrl": None, + } + + resp = await client.patch("/api/config/ui", json={"displayName": " Acme Pro "}) + assert resp.status_code == status.HTTP_200_OK + assert resp.json()["displayName"] == "Acme Pro" + assert resp.json()["configuredDisplayName"] == "Acme Pro" + + resp = await client.patch("/api/config/ui", json={"displayName": ""}) + assert resp.status_code == status.HTTP_200_OK + assert resp.json()["displayName"] == "Flocks Pro" + assert resp.json()["configuredDisplayName"] is None + + @pytest.mark.parametrize( + "svg", + [ + b'', + b'', + b'', + b'', + ''.encode("utf-16"), + ], + ) + @pytest.mark.asyncio + async def test_ui_favicon_rejects_unsafe_svg( + self, + client: AsyncClient, + tmp_path, + monkeypatch, + svg: bytes, + ): + """SVG favicons are accepted only when they fit the safe favicon subset.""" + from flocks.config.config import Config + + monkeypatch.setenv("FLOCKS_CONFIG_DIR", str(tmp_path / "config")) + Config._global_config = None + Config._cached_config = None + + resp = await client.post( + "/api/config/ui/favicon", + files={"file": ("favicon.svg", svg, "image/svg+xml")}, + ) + + assert resp.status_code == status.HTTP_400_BAD_REQUEST + assert not (tmp_path / "config" / "assets" / "favicon.svg").exists() + + display_resp = await client.get("/api/config/ui-display") + assert display_resp.status_code == status.HTTP_200_OK + assert display_resp.json()["faviconUrl"] is None + # =========================================================================== # Permission routes diff --git a/tests/server/routes/test_update_routes.py b/tests/server/routes/test_update_routes.py index f673fca72..8be953356 100644 --- a/tests/server/routes/test_update_routes.py +++ b/tests/server/routes/test_update_routes.py @@ -1,5 +1,8 @@ from __future__ import annotations +import os +from urllib.parse import quote, unquote, urlparse + import pytest from fastapi import HTTPException, status from starlette.requests import Request @@ -7,11 +10,80 @@ pytestmark = pytest.mark.asyncio +_MANUAL_REAL_UPGRADE_ENV = "FLOCKS_RUN_REAL_WEBUI_UPGRADE_TEST" +_MANUAL_REAL_UPGRADE_BRANCH_ENV = "FLOCKS_REAL_WEBUI_UPGRADE_BRANCH" +_MANUAL_REAL_UPGRADE_TARGET_BRANCH = "" + def _request() -> Request: return Request({"type": "http", "method": "GET", "path": "/api/update/check", "headers": []}) +def _manual_real_upgrade_branch() -> str: + branch_input = ( + os.environ.get(_MANUAL_REAL_UPGRADE_BRANCH_ENV, "").strip() + or _MANUAL_REAL_UPGRADE_TARGET_BRANCH.strip() + ) + try: + if not branch_input: + branch_input = input("Target branch for the real WebUI upgrade test: ").strip() + if not branch_input: + pytest.skip("No target branch was provided for the real WebUI upgrade test") + + confirmation = input( + "This will trigger a real upgrade and may replace the current install tree. " + f"Type the branch name again to confirm ({branch_input}): " + ).strip() + except OSError as exc: + pytest.skip(f"Interactive confirmation is required: {exc}") + + if confirmation != branch_input: + pytest.skip("Real WebUI upgrade test was not confirmed") + return _normalize_manual_branch_target(branch_input) + + +def _normalize_manual_branch_target(target: str) -> str: + branch = target.strip() + parsed = urlparse(branch) + if parsed.scheme in {"http", "https"}: + path = parsed.path + github_marker = "/archive/refs/heads/" + gitee_marker = "/repository/archive/" + if github_marker in path: + branch = path.split(github_marker, 1)[1] + elif gitee_marker in path: + branch = path.split(gitee_marker, 1)[1] + branch = branch.removesuffix(".tar.gz").removesuffix(".zip") + branch = unquote(branch) + + for prefix in ("refs/heads/",): + if branch.startswith(prefix): + branch = branch[len(prefix):] + break + + if not branch: + pytest.skip("No target branch was provided for the real WebUI upgrade test") + return branch + + +def _manual_branch_version_label(branch: str) -> str: + return "branch-" + branch.replace("/", "-") + + +def _github_branch_archive_url(branch: str, extension: str) -> str: + encoded_branch = quote(branch, safe="/") + return f"https://github.com/AgentFlocks/flocks/archive/refs/heads/{encoded_branch}.{extension}" + + +async def test_normalize_manual_branch_target_accepts_archive_urls(): + assert _normalize_manual_branch_target( + "https://gitee.com/flocks/flocks/repository/archive/refactor/supervisor-control-adapters.zip" + ) == "refactor/supervisor-control-adapters" + assert _normalize_manual_branch_target( + "https://github.com/AgentFlocks/flocks/archive/refs/heads/fix/session-mixed-parts-read-merge.zip" + ) == "fix/session-mixed-parts-read-merge" + + async def test_check_version_requires_admin_for_flockspro(monkeypatch: pytest.MonkeyPatch): from flocks.server.routes import update as update_routes @@ -52,3 +124,50 @@ async def _fake_check_update(**kwargs): info = await update_routes.check_version(_request(), locale="zh-CN", edition="flocks") assert info.current_version == "v2026.5.9" + + +@pytest.mark.skipif( + os.environ.get(_MANUAL_REAL_UPGRADE_ENV) != "1", + reason=f"manual real upgrade test; set {_MANUAL_REAL_UPGRADE_ENV}=1 to enable", +) +async def test_manual_webui_apply_update_upgrades_to_confirmed_branch( + client, + monkeypatch: pytest.MonkeyPatch, +): + from flocks.config.config import UpdaterConfig + from flocks.server.routes import update as update_routes + from flocks.updater import updater as updater_module + from flocks.updater.models import VersionInfo + + branch = _manual_real_upgrade_branch() + version_label = _manual_branch_version_label(branch) + + async def _manual_github_updater_config(): + return UpdaterConfig( + repo="AgentFlocks/flocks", + gitee_repo=None, + sources=["github"], + archive_format="zip", + ) + + async def _manual_branch_update_info(**kwargs): + assert kwargs["force_console_manifest"] is False + return VersionInfo( + current_version="manual-real-upgrade-test", + latest_version=version_label, + has_update=True, + release_url=f"https://github.com/AgentFlocks/flocks/tree/{quote(branch, safe='/')}", + zipball_url=_github_branch_archive_url(branch, "zip"), + tarball_url=_github_branch_archive_url(branch, "tar.gz"), + ) + + monkeypatch.setattr(update_routes, "check_update", _manual_branch_update_info) + monkeypatch.setattr(updater_module, "_get_updater_config", _manual_github_updater_config) + + response = await client.post( + "/api/update/apply", + params={"edition": "flocks"}, + ) + + assert response.status_code == 200, response.text + assert '"stage":"error"' not in response.text diff --git a/tests/server/routes/test_workflow_trigger_routes.py b/tests/server/routes/test_workflow_trigger_routes.py index bcd8d08e2..5d3e5e9c9 100644 --- a/tests/server/routes/test_workflow_trigger_routes.py +++ b/tests/server/routes/test_workflow_trigger_routes.py @@ -690,6 +690,7 @@ async def _fake_status(workflow_id: str, trigger: Any) -> dict[str, Any]: "type": "custom_webhook", "enabled": True, "source": {"path": "/alerts/demo", "method": "POST"}, + "auth": {"type": "api_key", "apiKey": "demo-secret"}, "mapping": {"payload": "$.body"}, }, ) @@ -874,6 +875,86 @@ async def _fake_dispatch_event(**kwargs: Any) -> dict[str, Any]: assert "result" not in body +@pytest.mark.asyncio +async def test_webhook_route_rejects_trigger_without_auth( + client: AsyncClient, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + workflow_routes, + "_read_workflow_from_fs", + lambda workflow_id: { + "id": workflow_id, + "workflowJson": { + "start": "n1", + "nodes": [{"id": "n1", "type": "python", "code": "outputs['ok'] = True"}], + "edges": [], + "triggers": [ + { + "id": "hook-default", + "type": "custom_webhook", + "enabled": True, + "auth": {"type": "none"}, + } + ], + }, + }, + ) + dispatched = False + + async def _fake_dispatch_event(**_kwargs: Any) -> dict[str, Any]: + nonlocal dispatched + dispatched = True + return {"matched": True, "executed": True} + + monkeypatch.setattr( + workflow_routes, + "default_trigger_runtime", + SimpleNamespace(dispatch_event=_fake_dispatch_event), + ) + + response = await client.post( + "/webhook/workflows/wf-1/hook-default", + json={"severity": "high"}, + ) + + assert response.status_code == 401, response.text + assert dispatched is False + + +@pytest.mark.asyncio +async def test_create_webhook_trigger_requires_real_auth( + client: AsyncClient, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + workflow_routes, + "_read_workflow_from_fs", + lambda workflow_id: { + "id": workflow_id, + "workflowJson": { + "start": "n1", + "nodes": [{"id": "n1", "type": "python", "code": "outputs['ok'] = True"}], + "edges": [], + "triggers": [], + }, + }, + ) + + response = await client.post( + "/api/workflow/wf-1/triggers", + json={ + "id": "hook-default", + "type": "custom_webhook", + "enabled": True, + "auth": {"type": "none"}, + }, + ) + + assert response.status_code == 400, response.text + assert "authentication" in response.text + + @pytest.mark.asyncio async def test_webhook_route_rejects_disabled_trigger( client: AsyncClient, diff --git a/tests/server/test_auth_compat.py b/tests/server/test_auth_compat.py index d4014e2da..b92191bc9 100644 --- a/tests/server/test_auth_compat.py +++ b/tests/server/test_auth_compat.py @@ -295,6 +295,9 @@ def test_static_prefix_is_exempt(self): def test_protected_path_is_not_exempt(self): assert auth_module.auth_middleware_exempt("/api/session") is False assert auth_module.auth_middleware_exempt("/api/admin/users") is False + assert auth_module.auth_middleware_exempt("/docs") is False + assert auth_module.auth_middleware_exempt("/redoc") is False + assert auth_module.auth_middleware_exempt("/openapi.json") is False def test_channel_webhook_is_exempt_via_regex(self): # /api/channel/{channel_id}/webhook is the public callback entry for diff --git a/tests/server/test_lifespan.py b/tests/server/test_lifespan.py index 2e505cf6b..de6677698 100644 --- a/tests/server/test_lifespan.py +++ b/tests/server/test_lifespan.py @@ -112,7 +112,7 @@ async def fake_async_noop(*_args, **_kwargs) -> None: monkeypatch.setitem( sys.modules, "flocks.tool.registry", - types.SimpleNamespace(ToolRegistry=types.SimpleNamespace(start_watcher=lambda: None)), + types.SimpleNamespace(ToolRegistry=types.SimpleNamespace(init=lambda: None, start_watcher=lambda: None)), ) monkeypatch.setitem( sys.modules, diff --git a/tests/server/test_server.py b/tests/server/test_server.py index 12ea20652..d326a7b69 100644 --- a/tests/server/test_server.py +++ b/tests/server/test_server.py @@ -53,8 +53,8 @@ async def test_health_check(client): assert data["status"] == "healthy" assert isinstance(data["version"], str) and data["version"] assert "timestamp" in data - assert "config_dir" in data - assert "data_dir" in data + assert "config_dir" not in data + assert "data_dir" not in data assert "task_manager_started" not in data assert "task_scheduler_running" not in data assert "task_scheduler_available" not in data @@ -66,6 +66,17 @@ async def test_health_check(client): assert "task_oldest_running_seconds" not in data +@pytest.mark.asyncio +async def test_security_headers_present(client): + """Baseline browser security headers should be present on HTTP responses.""" + response = await client.get("/api/health") + + assert response.headers["x-content-type-options"] == "nosniff" + assert response.headers["referrer-policy"] == "no-referrer" + assert response.headers["content-security-policy"] == "frame-ancestors 'self'" + assert response.headers["permissions-policy"] == "camera=(), microphone=(), geolocation=()" + + @pytest.mark.asyncio async def test_task_queue_status_includes_diagnostics(client): response = await client.get("/api/task-system/queue/status") @@ -555,4 +566,3 @@ async def test_question_pending_route_lists_session_requests(client): finally: clear_request_state(req1["id"]) clear_request_state(req2["id"]) - diff --git a/tests/server/test_server_port_config.py b/tests/server/test_server_port_config.py index 059acca42..3964ff3d2 100644 --- a/tests/server/test_server_port_config.py +++ b/tests/server/test_server_port_config.py @@ -206,9 +206,74 @@ def fake_start_all(config, _console): assert result.exit_code == 0 assert captured["config"].backend_host == "0.0.0.0" - assert captured["config"].backend_port == 9000 + assert captured["config"].backend_port == 5174 assert captured["config"].frontend_host == "0.0.0.0" assert captured["config"].frontend_port == 5174 + assert captured["config"].legacy_backend_port == 9000 + assert captured["config"].server_port_migration_hint is True + + def test_start_accepts_public_host_and_port(self, monkeypatch): + """Test start command accepts the unified public host/port options.""" + captured = {} + + def fake_start_all(config, _console): + captured["config"] = config + + monkeypatch.setattr(cli_main, "start_all", fake_start_all) + + result = CliRunner().invoke( + cli_main.app, + [ + "start", + "--host", + "0.0.0.0", + "--port", + "8888", + ], + ) + + assert result.exit_code == 0 + assert captured["config"].backend_host == "0.0.0.0" + assert captured["config"].backend_port == 8888 + assert captured["config"].frontend_host == "0.0.0.0" + assert captured["config"].frontend_port == 8888 + assert captured["config"].legacy_backend_port == 8000 + + def test_public_host_and_port_override_legacy_options(self, monkeypatch): + """Test unified public host/port win over legacy server and WebUI options.""" + captured = {} + + def fake_start_all(config, _console): + captured["config"] = config + + monkeypatch.setattr(cli_main, "start_all", fake_start_all) + + result = CliRunner().invoke( + cli_main.app, + [ + "start", + "--host", + "0.0.0.0", + "--port", + "8888", + "--server-host", + "127.0.0.1", + "--server-port", + "9000", + "--webui-host", + "127.0.0.1", + "--webui-port", + "5174", + ], + ) + + assert result.exit_code == 0 + assert captured["config"].backend_host == "0.0.0.0" + assert captured["config"].backend_port == 8888 + assert captured["config"].frontend_host == "0.0.0.0" + assert captured["config"].frontend_port == 8888 + assert captured["config"].legacy_backend_host == "127.0.0.1" + assert captured["config"].legacy_backend_port == 9000 def test_restart_accepts_server_and_webui_options(self, monkeypatch): """Test restart command accepts explicit server and WebUI host/port options.""" @@ -218,6 +283,7 @@ def fake_restart_all(config, _console): captured["config"] = config monkeypatch.setattr(cli_main, "restart_all", fake_restart_all) + monkeypatch.setattr(cli_main, "read_supervisor_status", lambda **_kwargs: (_ for _ in ()).throw(RuntimeError("down"))) result = CliRunner().invoke( cli_main.app, @@ -236,45 +302,76 @@ def fake_restart_all(config, _console): assert result.exit_code == 0 assert captured["config"].backend_host == "127.0.0.1" - assert captured["config"].backend_port == 9100 + assert captured["config"].backend_port == 5273 assert captured["config"].frontend_host == "127.0.0.1" assert captured["config"].frontend_port == 5273 + assert captured["config"].legacy_backend_port == 9100 - def test_restart_reuses_runtime_recorded_host_and_port(self, monkeypatch, tmp_path: Path): - """Test restart reuses last runtime host/port when CLI and env omit them.""" + def test_restart_accepts_public_host_and_port(self, monkeypatch): + """Test restart command accepts the unified public host/port options.""" captured = {} - paths = SimpleNamespace( - backend_pid=tmp_path / "backend.pid", - frontend_pid=tmp_path / "webui.pid", + + def fake_restart_all(config, _console): + captured["config"] = config + + monkeypatch.setattr(cli_main, "restart_all", fake_restart_all) + monkeypatch.setattr(cli_main, "read_supervisor_status", lambda **_kwargs: (_ for _ in ()).throw(RuntimeError("down"))) + + result = CliRunner().invoke( + cli_main.app, + [ + "restart", + "--host", + "0.0.0.0", + "--port", + "8888", + ], ) - records = { - paths.backend_pid: SimpleNamespace(host="0.0.0.0", port=9000), - paths.frontend_pid: SimpleNamespace(host="0.0.0.0", port=5174), - } + + assert result.exit_code == 0 + assert captured["config"].backend_host == "0.0.0.0" + assert captured["config"].backend_port == 8888 + assert captured["config"].frontend_host == "0.0.0.0" + assert captured["config"].frontend_port == 8888 + assert captured["config"].legacy_backend_port == 8000 + + def test_restart_reuses_supervisor_recorded_host_and_port(self, monkeypatch, tmp_path: Path): + """Test restart reuses supervisor host/port when CLI and env omit them.""" + captured = {} + paths = SimpleNamespace(run_dir=tmp_path) def fake_restart_all(config, _console): captured["config"] = config monkeypatch.setattr(cli_main, "restart_all", fake_restart_all) monkeypatch.setattr(cli_main, "runtime_paths", lambda: paths) - monkeypatch.setattr(cli_main, "read_runtime_record", lambda path: records.get(path)) + monkeypatch.setattr( + cli_main, + "read_supervisor_status", + lambda **_kwargs: { + "config": { + "backend_host": "0.0.0.0", + "backend_port": 9000, + "frontend_host": "0.0.0.0", + "frontend_port": 5174, + } + }, + ) Config._global_config = None result = CliRunner().invoke(cli_main.app, ["restart"]) assert result.exit_code == 0 assert captured["config"].backend_host == "0.0.0.0" - assert captured["config"].backend_port == 9000 + assert captured["config"].backend_port == 5174 assert captured["config"].frontend_host == "0.0.0.0" assert captured["config"].frontend_port == 5174 + assert captured["config"].legacy_backend_port == 9000 - def test_restart_cli_options_override_runtime_record(self, monkeypatch, tmp_path: Path): - """Test explicit restart CLI options override runtime-recorded host/port.""" + def test_restart_cli_options_override_supervisor_record(self, monkeypatch, tmp_path: Path): + """Test explicit restart CLI options override supervisor host/port.""" captured = {} - paths = SimpleNamespace( - backend_pid=tmp_path / "backend.pid", - frontend_pid=tmp_path / "webui.pid", - ) + paths = SimpleNamespace(run_dir=tmp_path) def fake_restart_all(config, _console): captured["config"] = config @@ -283,11 +380,15 @@ def fake_restart_all(config, _console): monkeypatch.setattr(cli_main, "runtime_paths", lambda: paths) monkeypatch.setattr( cli_main, - "read_runtime_record", - lambda path: SimpleNamespace( - host="0.0.0.0", - port=9000 if Path(path) == paths.backend_pid else 5174, - ), + "read_supervisor_status", + lambda **_kwargs: { + "config": { + "backend_host": "0.0.0.0", + "backend_port": 9000, + "frontend_host": "0.0.0.0", + "frontend_port": 5174, + } + }, ) Config._global_config = None @@ -308,17 +409,15 @@ def fake_restart_all(config, _console): assert result.exit_code == 0 assert captured["config"].backend_host == "127.0.0.1" - assert captured["config"].backend_port == 9100 + assert captured["config"].backend_port == 5273 assert captured["config"].frontend_host == "127.0.0.1" assert captured["config"].frontend_port == 5273 + assert captured["config"].legacy_backend_port == 9100 - def test_restart_environment_overrides_runtime_record(self, monkeypatch, tmp_path: Path): - """Test restart environment variables still override runtime-recorded host/port.""" + def test_restart_environment_overrides_supervisor_record(self, monkeypatch, tmp_path: Path): + """Test restart environment variables still override supervisor host/port.""" captured = {} - paths = SimpleNamespace( - backend_pid=tmp_path / "backend.pid", - frontend_pid=tmp_path / "webui.pid", - ) + paths = SimpleNamespace(run_dir=tmp_path) def fake_restart_all(config, _console): captured["config"] = config @@ -327,11 +426,15 @@ def fake_restart_all(config, _console): monkeypatch.setattr(cli_main, "runtime_paths", lambda: paths) monkeypatch.setattr( cli_main, - "read_runtime_record", - lambda path: SimpleNamespace( - host="0.0.0.0", - port=9000 if Path(path) == paths.backend_pid else 5174, - ), + "read_supervisor_status", + lambda **_kwargs: { + "config": { + "backend_host": "0.0.0.0", + "backend_port": 9000, + "frontend_host": "0.0.0.0", + "frontend_port": 5174, + } + }, ) monkeypatch.setenv("FLOCKS_SERVER_HOST", "127.0.0.1") monkeypatch.setenv("FLOCKS_SERVER_PORT", "9101") @@ -343,9 +446,10 @@ def fake_restart_all(config, _console): assert result.exit_code == 0 assert captured["config"].backend_host == "127.0.0.1" - assert captured["config"].backend_port == 9101 + assert captured["config"].backend_port == 5275 assert captured["config"].frontend_host == "127.0.0.1" assert captured["config"].frontend_port == 5275 + assert captured["config"].legacy_backend_port == 9101 def test_service_config_prefers_cli_values(self, monkeypatch): """Test CLI values override environment and default values.""" @@ -362,10 +466,54 @@ def test_service_config_prefers_cli_values(self, monkeypatch): webui_port=5174, ) - assert config.backend_host == "0.0.0.0" - assert config.backend_port == 9000 + assert config.backend_host == "127.0.0.1" + assert config.backend_port == 5174 assert config.frontend_host == "127.0.0.1" assert config.frontend_port == 5174 + assert config.legacy_backend_host == "0.0.0.0" + assert config.legacy_backend_port == 9000 + + def test_service_config_default_public_port_is_webui_port(self, monkeypatch): + """Test service startup defaults to the public WebUI port.""" + monkeypatch.delenv("FLOCKS_HOST", raising=False) + monkeypatch.delenv("FLOCKS_PORT", raising=False) + monkeypatch.delenv("FLOCKS_PUBLIC_HOST", raising=False) + monkeypatch.delenv("FLOCKS_PUBLIC_PORT", raising=False) + monkeypatch.delenv("FLOCKS_SERVER_HOST", raising=False) + monkeypatch.delenv("FLOCKS_SERVER_PORT", raising=False) + monkeypatch.delenv("FLOCKS_WEBUI_HOST", raising=False) + monkeypatch.delenv("FLOCKS_WEBUI_PORT", raising=False) + Config._global_config = None + + config = cli_main._service_config() + + assert config.backend_host == "127.0.0.1" + assert config.backend_port == 5173 + assert config.frontend_host == "127.0.0.1" + assert config.frontend_port == 5173 + assert config.legacy_backend_port == 8000 + + def test_service_config_prefers_public_values(self, monkeypatch): + """Test unified public values override legacy CLI and environment values.""" + monkeypatch.setenv("FLOCKS_WEBUI_HOST", "10.0.0.2") + monkeypatch.setenv("FLOCKS_WEBUI_PORT", "5274") + Config._global_config = None + + config = cli_main._service_config( + host="0.0.0.0", + port=8888, + server_host="127.0.0.1", + server_port=9000, + webui_host="127.0.0.1", + webui_port=5174, + ) + + assert config.backend_host == "0.0.0.0" + assert config.backend_port == 8888 + assert config.frontend_host == "0.0.0.0" + assert config.frontend_port == 8888 + assert config.legacy_backend_host == "127.0.0.1" + assert config.legacy_backend_port == 9000 def test_service_config_uses_server_and_webui_environment(self, monkeypatch): """Test environment variables are used when CLI values are absent.""" @@ -378,9 +526,10 @@ def test_service_config_uses_server_and_webui_environment(self, monkeypatch): config = cli_main._service_config() assert config.backend_host == "0.0.0.0" - assert config.backend_port == 9001 + assert config.backend_port == 5175 assert config.frontend_host == "0.0.0.0" assert config.frontend_port == 5175 + assert config.legacy_backend_port == 9001 def test_service_config_keeps_legacy_env_fallbacks(self, monkeypatch): """Test legacy backend/frontend environment variables still work as fallback.""" @@ -397,9 +546,10 @@ def test_service_config_keeps_legacy_env_fallbacks(self, monkeypatch): config = cli_main._service_config() assert config.backend_host == "0.0.0.0" - assert config.backend_port == 9200 + assert config.backend_port == 5176 assert config.frontend_host == "0.0.0.0" assert config.frontend_port == 5176 + assert config.legacy_backend_port == 9200 def test_cli_tui_command_default_port(self): """Test that CLI tui command uses correct default port.""" @@ -575,13 +725,13 @@ def test_script_port_env_var(self): assert port == '7000' def test_script_port_env_var_default(self): - """Test FLOCKS_PORT defaults to 8000 when not set.""" + """Test FLOCKS_PORT defaults to the public service port when not set.""" # Temporarily remove the env var if it exists old_value = os.environ.pop('FLOCKS_PORT', None) try: - port = int(os.getenv('FLOCKS_PORT', '8000')) - assert port == 8000 + port = int(os.getenv('FLOCKS_PORT', '5173')) + assert port == 5173 finally: # Restore old value if it existed if old_value is not None: diff --git a/tests/server/test_static_webui.py b/tests/server/test_static_webui.py new file mode 100644 index 000000000..8e478c5ac --- /dev/null +++ b/tests/server/test_static_webui.py @@ -0,0 +1,120 @@ +from pathlib import Path + +import pytest +from fastapi import FastAPI +from httpx import ASGITransport, AsyncClient + +from flocks.server.static_webui import maybe_serve_static_webui + + +def _write_dist(root: Path) -> Path: + dist = root / "dist" + assets = dist / "assets" + assets.mkdir(parents=True) + (dist / "index.html").write_text("Flocks WebUI", encoding="utf-8") + (assets / "app.12345678.js").write_text("console.log('flocks');", encoding="utf-8") + return dist + + +def _app() -> FastAPI: + app = FastAPI() + + @app.middleware("http") + async def static_webui(request, call_next): + response = await maybe_serve_static_webui(request) + if response is not None: + return response + return await call_next(request) + + @app.get("/api/health") + async def health(): + return {"status": "healthy"} + + return app + + +@pytest.mark.asyncio +async def test_static_webui_serves_browser_root(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("FLOCKS_WEBUI_DIST_DIR", str(_write_dist(tmp_path))) + async with AsyncClient(transport=ASGITransport(app=_app()), base_url="http://test") as client: + response = await client.get("/", headers={"Accept": "text/html"}) + + assert response.status_code == 200 + assert "Flocks WebUI" in response.text + assert response.headers["Cache-Control"] == "no-store" + + +@pytest.mark.asyncio +async def test_static_webui_serves_assets_with_immutable_cache(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("FLOCKS_WEBUI_DIST_DIR", str(_write_dist(tmp_path))) + async with AsyncClient(transport=ASGITransport(app=_app()), base_url="http://test") as client: + response = await client.get("/assets/app.12345678.js") + + assert response.status_code == 200 + assert "console.log" in response.text + assert response.headers["Cache-Control"] == "public, max-age=31536000, immutable" + + +@pytest.mark.asyncio +async def test_static_webui_falls_back_for_browser_deep_link(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("FLOCKS_WEBUI_DIST_DIR", str(_write_dist(tmp_path))) + async with AsyncClient(transport=ASGITransport(app=_app()), base_url="http://test") as client: + response = await client.get("/session/abc", headers={"Accept": "text/html"}) + + assert response.status_code == 200 + assert "Flocks WebUI" in response.text + + +@pytest.mark.asyncio +async def test_static_webui_falls_back_before_full_app_auth(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("FLOCKS_WEBUI_DIST_DIR", str(_write_dist(tmp_path))) + from flocks.server.app import app + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + response = await client.get( + "/session/abc", + headers={ + "Accept": "text/html", + "User-Agent": "Mozilla/5.0", + }, + ) + + assert response.status_code == 200 + assert "Flocks WebUI" in response.text + + +@pytest.mark.asyncio +async def test_static_webui_does_not_bypass_full_app_api_auth(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("FLOCKS_WEBUI_DIST_DIR", str(_write_dist(tmp_path))) + from flocks.server.app import app + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + response = await client.get( + "/api/session/abc", + headers={ + "Accept": "text/html", + "User-Agent": "Mozilla/5.0", + }, + ) + + assert response.status_code == 401 + assert "Flocks WebUI" not in response.text + + +@pytest.mark.asyncio +async def test_static_webui_does_not_intercept_api(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("FLOCKS_WEBUI_DIST_DIR", str(_write_dist(tmp_path))) + async with AsyncClient(transport=ASGITransport(app=_app()), base_url="http://test") as client: + response = await client.get("/api/health", headers={"Accept": "text/html"}) + + assert response.status_code == 200 + assert response.json() == {"status": "healthy"} + + +@pytest.mark.asyncio +async def test_static_webui_does_not_fallback_for_non_get(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("FLOCKS_WEBUI_DIST_DIR", str(_write_dist(tmp_path))) + async with AsyncClient(transport=ASGITransport(app=_app()), base_url="http://test") as client: + response = await client.post("/session/abc", headers={"Accept": "text/html"}) + + assert response.status_code == 404 diff --git a/tests/session/test_retry.py b/tests/session/test_retry.py index fcc63b7ee..70b4b23d5 100644 --- a/tests/session/test_retry.py +++ b/tests/session/test_retry.py @@ -103,6 +103,20 @@ def test_string_message_connection_error_pattern(self): assert result is not None assert SessionRetry.is_connection_error(error) is True + def test_string_message_incomplete_chunked_read_is_connection_error(self): + error = { + "name": "RemoteProtocolError", + "data": { + "message": ( + "peer closed connection without sending complete message body " + "(incomplete chunked read)" + ) + }, + } + result = SessionRetry.retryable(error) + assert result == "Connection or Server Error" + assert SessionRetry.is_connection_error(error) is True + def test_empty_error_returns_none(self): assert SessionRetry.retryable({}) is None diff --git a/tests/session/test_runner_step.py b/tests/session/test_runner_step.py index cbfb5571b..9d67e6565 100644 --- a/tests/session/test_runner_step.py +++ b/tests/session/test_runner_step.py @@ -259,6 +259,17 @@ def test_connection_error_exception_is_retryable(self): assert result["data"]["isRetryable"] is True assert result["data"]["displayMessage"] == runner_mod.CONNECTION_ERROR_DISPLAY_MESSAGE + def test_incomplete_chunked_read_exception_is_retryable_connection_error(self): + runner = _make_runner() + exc = Exception( + "peer closed connection without sending complete message body " + "(incomplete chunked read)" + ) + result = runner._exception_to_error_dict(exc) + assert result["name"] == "APIError" + assert result["data"]["isRetryable"] is True + assert result["data"]["displayMessage"] == runner_mod.CONNECTION_ERROR_DISPLAY_MESSAGE + def test_exception_with_status_code_429(self): runner = _make_runner() exc = Exception("Rate limited") diff --git a/tests/skill/test_installer.py b/tests/skill/test_installer.py index 1420745e3..6b9c841fc 100644 --- a/tests/skill/test_installer.py +++ b/tests/skill/test_installer.py @@ -5,6 +5,8 @@ import asyncio import os import shutil +import signal +import subprocess import tempfile import io import zipfile @@ -76,6 +78,12 @@ def test_safeskill_scheme(self): assert r["kind"] == "safeskill" assert r["value"] == "ioc-lookup" + def test_safeskill_uri_scheme(self): + source = "safeskill://tbx/6ef3925b1f6245bcbd7da39f23c28652/onesig-use@1.0.0" + r = _resolve_source(source) + assert r["kind"] == "safeskill" + assert r["value"] == source + def test_clawhub_scheme(self): r = _resolve_source("clawhub:github") assert r["kind"] == "clawhub" @@ -281,6 +289,94 @@ def test_save_no_name_uses_hint(self, tmp_skills_dir: Path): # --------------------------------------------------------------------------- class TestInstallFromSource: + @pytest.mark.asyncio + async def test_run_subprocess_isolates_stdio_and_process_group(self): + proc = MagicMock() + proc.communicate = AsyncMock(return_value=(b"ok", b"")) + proc.returncode = 0 + + with patch( + "flocks.skill.installer.asyncio.create_subprocess_exec", + AsyncMock(return_value=proc), + ) as mock_exec: + returncode, stdout, stderr = await SkillInstaller._run_subprocess( + ["demo"], + timeout_sec=1, + ) + + assert returncode == 0 + assert stdout == "ok" + assert stderr == "" + kwargs = mock_exec.call_args.kwargs + assert kwargs["stdin"] == asyncio.subprocess.DEVNULL + assert kwargs["stdout"] == asyncio.subprocess.PIPE + assert kwargs["stderr"] == asyncio.subprocess.PIPE + if os.name != "nt": + assert kwargs["start_new_session"] is True + + def test_subprocess_stdio_kwargs_uses_windows_process_group(self): + with ( + patch("flocks.skill.installer.os.name", "nt"), + patch( + "flocks.skill.installer.subprocess.CREATE_NEW_PROCESS_GROUP", + 512, + create=True, + ), + ): + kwargs = SkillInstaller._subprocess_stdio_kwargs() + + assert kwargs["creationflags"] == 512 + assert "start_new_session" not in kwargs + + @pytest.mark.asyncio + async def test_run_subprocess_timeout_terminates_process_group(self): + proc = MagicMock() + proc.pid = 12345 + proc.communicate = AsyncMock(return_value=(b"", b"")) + proc.returncode = None + wait_calls = 0 + + async def fake_wait_for(awaitable, timeout): + nonlocal wait_calls + wait_calls += 1 + if wait_calls == 1: + awaitable.close() + raise asyncio.TimeoutError() + return await awaitable + + with ( + patch( + "flocks.skill.installer.asyncio.create_subprocess_exec", + AsyncMock(return_value=proc), + ), + patch("flocks.skill.installer.asyncio.wait_for", fake_wait_for), + patch("flocks.skill.installer.os.killpg") as mock_killpg, + ): + with pytest.raises(TimeoutError): + await SkillInstaller._run_subprocess(["demo"], timeout_sec=1) + + mock_killpg.assert_called_once_with(12345, signal.SIGTERM) + + def test_signal_process_tree_force_uses_windows_taskkill(self): + proc = MagicMock() + proc.pid = 12345 + + with ( + patch("flocks.skill.installer.os.name", "nt"), + patch("flocks.skill.installer.subprocess.run") as mock_run, + ): + mock_run.return_value.returncode = 0 + SkillInstaller._signal_process_tree(proc, signal.SIGTERM, force=True) + + mock_run.assert_called_once_with( + ["taskkill", "/PID", "12345", "/T", "/F"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + proc.kill.assert_not_called() + proc.terminate.assert_not_called() + @pytest.mark.asyncio async def test_skills_sh_cli_staging_imports_agent_skill(self, tmp_skills_dir): class Proc: @@ -352,6 +448,55 @@ async def test_safeskill_requires_npx(self, tmp_skills_dir): assert result.success is False assert "npx is required" in (result.error or "") + @pytest.mark.asyncio + async def test_safeskill_cli_uses_cn_region_and_flocks_agent(self, tmp_skills_dir): + source = "safeskill://tbx/6ef3925b1f6245bcbd7da39f23c28652/onesig-use@1.0.0" + captured = {} + + async def fake_run_subprocess(cmd, *, timeout_sec, cwd=None, env=None): + captured["cmd"] = cmd + captured["timeout_sec"] = timeout_sec + captured["cwd"] = cwd + captured["env"] = env + staged_skill = Path(env["HOME"]) / ".flocks" / "plugins" / "skills" / "onesig-use" + staged_skill.mkdir(parents=True) + (staged_skill / "SKILL.md").write_text( + "---\nname: onesig-use\ndescription: OneSig\n---\n", + encoding="utf-8", + ) + return 0, "✓ onesig-use (copied)", "" + + with ( + patch("flocks.skill.installer.shutil.which", return_value="/usr/bin/npx"), + patch("flocks.skill.installer._user_skills_root", return_value=tmp_skills_dir), + patch.object(SkillInstaller, "_run_subprocess", fake_run_subprocess), + ): + result = await SkillInstaller.install_from_source(source) + + assert result.success is True + assert result.skill_name == "onesig-use" + assert (tmp_skills_dir / "onesig-use" / "SKILL.md").exists() + assert captured["cmd"] == [ + "/usr/bin/npx", + "-y", + "@safeskill/cli", + "--region", + "cn", + "add", + source, + "--agent", + "flocks", + "--yes", + ] + assert captured["env"]["HOME"] == captured["cwd"] + assert captured["env"]["USERPROFILE"] == captured["cwd"] + staging = Path(captured["cwd"]) + assert captured["env"]["APPDATA"] == str(staging / "AppData" / "Roaming") + assert captured["env"]["LOCALAPPDATA"] == str(staging / "AppData" / "Local") + assert captured["env"]["XDG_CONFIG_HOME"] == str(staging / ".config") + assert captured["env"]["XDG_CACHE_HOME"] == str(staging / ".cache") + assert captured["env"]["NPM_CONFIG_CACHE"] == str(staging / ".npm") + @pytest.mark.asyncio async def test_local_file(self, tmp_path: Path, tmp_skills_dir: Path): skill_dir = tmp_path / "source-skill" diff --git a/tests/skill/test_workflow_config_guide_skill.py b/tests/skill/test_workflow_config_guide_skill.py index 46697a8cf..c633abe78 100644 --- a/tests/skill/test_workflow_config_guide_skill.py +++ b/tests/skill/test_workflow_config_guide_skill.py @@ -45,6 +45,30 @@ def test_workflow_config_guide_requires_free_text_question_input() -> None: assert "Custom value or notes" in content +def test_workflow_config_guide_requires_workflow_config_manage() -> None: + skill_file = ( + PROJECT_ROOT + / ".flocks" + / "plugins" + / "skills" + / "workflow-config-guide" + / "SKILL.md" + ) + + content = skill_file.read_text(encoding="utf-8") + + assert 'workflow_config_manage(action="get"' in content + assert 'workflow_config_manage(action="diff"' in content + assert 'workflow_config_manage(action="put"' in content + assert 'config_type="poller"' in content + assert 'config_type="syslog"' in content + assert 'config_type="kafka"' in content + assert "/api/workflow//config" not in content + assert "GET /api/workflow" not in content + assert "PUT /api/workflow" not in content + assert "use the runtime endpoint after template confirmation" not in content + + def test_workflow_builder_references_template_inside_skill() -> None: skill_file = ( PROJECT_ROOT @@ -61,6 +85,30 @@ def test_workflow_builder_references_template_inside_skill() -> None: assert ".flocks/plugins/workflows/workflow_template" not in content +def test_workflow_builder_template_requires_workflow_config_manage() -> None: + guide_file = ( + PROJECT_ROOT + / ".flocks" + / "plugins" + / "skills" + / "workflow-builder" + / "references" + / "workflow_template" + / "guide.md" + ) + + content = guide_file.read_text(encoding="utf-8") + + assert 'workflow_config_manage(action="get"' in content + assert 'workflow_config_manage(action="diff"' in content + assert 'workflow_config_manage(action="put"' in content + assert 'config_type="poller"' in content + assert 'config_type="syslog"' in content + assert 'config_type="kafka"' in content + assert "/api/workflow//config" not in content + assert "config/sync" not in content + + @pytest.mark.asyncio async def test_discover_workflow_config_guide_project_skill() -> None: skills = await Skill.refresh() @@ -69,6 +117,33 @@ async def test_discover_workflow_config_guide_project_skill() -> None: assert "workflow-config-guide" in skill_names +def test_stream_alert_guides_require_workflow_config_manage() -> None: + for workflow_id in ("stream_alert_denoise", "stream_alert_triage"): + guide_file = ( + PROJECT_ROOT + / ".flocks" + / "flockshub" + / "plugins" + / "workflows" + / workflow_id + / "guide.md" + ) + + content = guide_file.read_text(encoding="utf-8") + + assert f'workflow_config_manage(action="get", workflow_id="{workflow_id}")' in content + assert f'workflow_config_manage(action="diff", workflow_id="{workflow_id}"' in content + assert f'workflow_config_manage(action="put", workflow_id="{workflow_id}"' in content + assert f"/api/workflow/{workflow_id}/config" not in content + assert "config/sync" not in content + if workflow_id == "stream_alert_denoise": + assert 'config_type="syslog"' in content + assert f"/api/workflow/{workflow_id}/syslog-config" in content + if workflow_id == "stream_alert_triage": + assert 'config_type="poller"' in content + assert f"/api/workflow/{workflow_id}/poller-config" in content + + def test_workflow_template_no_longer_ships_integration_guide() -> None: old_workflow_template = ( PROJECT_ROOT diff --git a/tests/storage/test_storage.py b/tests/storage/test_storage.py index 5769e97aa..27c5fbdac 100644 --- a/tests/storage/test_storage.py +++ b/tests/storage/test_storage.py @@ -2,8 +2,10 @@ Tests for storage module """ +import asyncio from contextlib import asynccontextmanager import os +import shutil import sqlite3 import pytest from pathlib import Path @@ -12,7 +14,10 @@ from unittest.mock import AsyncMock, patch from pydantic import BaseModel +from flocks.project.instance import Instance from flocks.storage.storage import Storage +from flocks.task.store import TaskStore +from flocks.workflow.store import WorkflowStore class StorageTestModel(BaseModel): @@ -274,9 +279,8 @@ async def test_storage_init_recovers_when_pragma_reports_corruption(tmp_path): with patch.object(Storage, "_initialized", False), \ patch.object(Storage, "_db_path", None): await Storage.init(db_path) - - await Storage.set("hello", {"value": 2}) - assert await Storage.get("hello") == {"value": 2} + await Storage.set("hello", {"value": 2}) + assert await Storage.get("hello") == {"value": 2} assert db_path.exists() quarantined = [ @@ -286,6 +290,270 @@ async def test_storage_init_recovers_when_pragma_reports_corruption(tmp_path): assert quarantined, list(tmp_path.iterdir()) +@pytest.mark.asyncio +async def test_storage_get_recovers_corrupt_db_after_init(tmp_path): + """A request-time storage read should quarantine corruption and retry once.""" + db_path = tmp_path / "flocks.db" + + with patch.object(Storage, "_initialized", False), \ + patch.object(Storage, "_db_path", None): + await Storage.init(db_path) + await Storage.set("hello", {"value": "before"}) + + db_path.write_bytes(Storage._SQLITE_MAGIC + b"\xff" * 2048) + db_path.with_name("flocks.db-wal").write_bytes(b"fake wal payload") + db_path.with_name("flocks.db-shm").write_bytes(b"fake shm payload") + + assert await Storage.get("hello") is None + await Storage.set("hello", {"value": "after"}) + assert await Storage.get("hello") == {"value": "after"} + + siblings = sorted(p.name for p in tmp_path.iterdir()) + assert "flocks.db" in siblings + assert any(name.startswith("flocks.db.corrupt.") for name in siblings), siblings + + +def test_try_sqlite_recover_installs_recovered_db(tmp_path): + """The lightweight `.recover` path should install a readable recovered DB.""" + if shutil.which("sqlite3") is None: + pytest.skip("sqlite3 CLI is not available") + + quarantined = tmp_path / "flocks.db.corrupt.test" + target = tmp_path / "flocks.db" + conn = sqlite3.connect(quarantined) + try: + conn.execute( + """ + CREATE TABLE storage ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + type TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + """ + ) + conn.execute( + "INSERT INTO storage (key, value, type, created_at, updated_at) VALUES (?, ?, ?, ?, ?)", + ("hello", '{"value": "recovered"}', "json", "old", "old"), + ) + conn.commit() + finally: + conn.close() + + assert Storage._try_sqlite_recover_sync(quarantined, target) == target + recovered = sqlite3.connect(target) + try: + assert recovered.execute( + "SELECT value FROM storage WHERE key = ?", + ("hello",), + ).fetchone()[0] == '{"value": "recovered"}' + assert recovered.execute("PRAGMA integrity_check").fetchone()[0] == "ok" + finally: + recovered.close() + + +@pytest.mark.asyncio +async def test_storage_init_recovers_real_malformed_sqlite_file(tmp_path): + """Startup should recover a real DB that fails SQLite integrity checks.""" + if shutil.which("sqlite3") is None: + pytest.skip("sqlite3 CLI is not available") + + db_path = tmp_path / "flocks.db" + conn = sqlite3.connect(db_path) + try: + conn.execute("PRAGMA journal_mode=DELETE") + conn.execute("PRAGMA page_size=4096") + conn.execute("VACUUM") + conn.execute( + """ + CREATE TABLE storage ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + type TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + """ + ) + conn.execute("CREATE TABLE junk (id INTEGER PRIMARY KEY, payload BLOB NOT NULL)") + conn.execute( + "INSERT INTO storage (key, value, type, created_at, updated_at) VALUES (?, ?, ?, ?, ?)", + ("hello", '{"value": "survived"}', "json", "old", "old"), + ) + blob = os.urandom(3500) + for _ in range(350): + conn.execute("INSERT INTO junk (payload) VALUES (?)", (blob,)) + conn.commit() + finally: + conn.close() + + page_size = 4096 + page_count = db_path.stat().st_size // page_size + with db_path.open("r+b") as fh: + fh.seek((page_count - 3) * page_size) + fh.write(b"BROKEN_PAGE_FOR_RECOVERY_TEST" + b"\xff" * 256) + + ok, detail = Storage._integrity_check_sync(db_path) + assert ok is False + assert "malformed" in detail.lower() + + with patch.object(Storage, "_initialized", False), \ + patch.object(Storage, "_db_path", None), \ + patch.object(Storage, "_init_pid", None): + await Storage.init(db_path) + assert await Storage.get("hello") == {"value": "survived"} + await Storage.shutdown() + + assert Storage._integrity_check_sync(db_path) == (True, "ok") + siblings = sorted(p.name for p in tmp_path.iterdir()) + assert any(name.startswith("flocks.db.corrupt.") for name in siblings), siblings + assert "flocks.db.recover.sql" in siblings + + +@pytest.mark.asyncio +async def test_storage_get_uses_recovered_db_before_empty_rebuild(tmp_path, monkeypatch): + """A recovered DB should be installed before retrying the failed read.""" + db_path = tmp_path / "flocks.db" + + def fake_recover(quarantined_path: Path, target_path: Path): + assert quarantined_path.name.startswith("flocks.db.corrupt.") + conn = sqlite3.connect(target_path) + try: + conn.execute( + """ + CREATE TABLE storage ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + type TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + """ + ) + conn.execute( + "INSERT INTO storage (key, value, type, created_at, updated_at) VALUES (?, ?, ?, ?, ?)", + ("hello", '{"value": "recovered"}', "json", "old", "old"), + ) + conn.commit() + finally: + conn.close() + return target_path + + monkeypatch.setattr(Storage, "_try_sqlite_recover_sync", staticmethod(fake_recover)) + + with patch.object(Storage, "_initialized", False), \ + patch.object(Storage, "_db_path", None): + await Storage.init(db_path) + db_path.write_bytes(Storage._SQLITE_MAGIC + b"\xff" * 2048) + + assert await Storage.get("hello") == {"value": "recovered"} + + +@pytest.mark.asyncio +async def test_task_store_init_recovers_corrupt_tasks_db(tmp_path): + """TaskStore should quarantine tasks.db corruption and keep task center bootable.""" + db_path = tmp_path / "flocks.db" + tasks_db = tmp_path / "tasks.db" + tasks_db.write_bytes(Storage._SQLITE_MAGIC + b"\xff" * 2048) + + with patch.object(Storage, "_initialized", False), \ + patch.object(Storage, "_db_path", None), \ + patch.object(TaskStore, "_initialized", False), \ + patch.object(TaskStore, "_conn", None), \ + patch.object(TaskStore, "_init_pid", None): + await Storage.init(db_path) + await TaskStore.init() + await TaskStore.close() + + siblings = sorted(p.name for p in tmp_path.iterdir()) + assert "tasks.db" in siblings + assert any(name.startswith("tasks.db.corrupt.") for name in siblings), siblings + + +@pytest.mark.asyncio +async def test_task_store_init_recovers_corrupt_tasks_db_after_completed_migration(tmp_path, monkeypatch): + """A corrupt existing tasks.db should not be treated like a missing migrated DB.""" + db_path = tmp_path / "flocks.db" + tasks_db = tmp_path / "tasks.db" + tasks_db.write_bytes(Storage._SQLITE_MAGIC + b"\xff" * 2048) + + monkeypatch.setattr(Storage, "_try_sqlite_recover_sync", staticmethod(lambda *_args: None)) + + with patch.object(Storage, "_initialized", False), \ + patch.object(Storage, "_db_path", None), \ + patch.object(TaskStore, "_initialized", False), \ + patch.object(TaskStore, "_conn", None), \ + patch.object(TaskStore, "_init_pid", None): + await Storage.init(db_path) + await asyncio.to_thread( + Storage._write_multi_db_migration_marker_sync, + { + "version": 1, + "tasks_migrated": True, + "task_rows": 1, + }, + ) + await TaskStore.init() + await TaskStore.close() + + siblings = sorted(p.name for p in tmp_path.iterdir()) + assert "tasks.db" in siblings + assert any(name.startswith("tasks.db.corrupt.") for name in siblings), siblings + + +@pytest.mark.asyncio +async def test_workflow_store_init_recovers_corrupt_workflow_db(tmp_path): + """WorkflowStore should quarantine workflow.db corruption and rebuild tables.""" + db_path = tmp_path / "flocks.db" + workflow_db = tmp_path / "workflow.db" + workflow_db.write_bytes(Storage._SQLITE_MAGIC + b"\xff" * 2048) + + with patch.object(Storage, "_initialized", False), \ + patch.object(Storage, "_db_path", None), \ + patch.object(WorkflowStore, "_initialized", False), \ + patch.object(WorkflowStore, "_conn", None), \ + patch.object(WorkflowStore, "_init_pid", None), \ + patch.object(WorkflowStore, "_db_path", None): + await Storage.init(db_path) + await WorkflowStore.init() + await WorkflowStore.close() + + siblings = sorted(p.name for p in tmp_path.iterdir()) + assert "workflow.db" in siblings + assert any(name.startswith("workflow.db.corrupt.") for name in siblings), siblings + + +@pytest.mark.asyncio +async def test_instance_provide_drops_failed_context_task(monkeypatch): + """A failed project-context initialization must not poison later requests.""" + directory = "/tmp/flocks-instance-retry" + Instance._cache.pop(directory, None) + calls = {"count": 0} + + async def fake_from_directory(cls, requested_directory): + calls["count"] += 1 + if calls["count"] == 1: + raise sqlite3.DatabaseError("database disk image is malformed") + return { + "project": SimpleNamespace(id="project"), + "sandbox": requested_directory, + } + + monkeypatch.setattr( + "flocks.project.instance.Project.from_directory", + classmethod(fake_from_directory), + ) + + with pytest.raises(sqlite3.DatabaseError): + await Instance.provide(directory=directory, fn=lambda: "unreachable") + + assert directory not in Instance._cache + assert await Instance.provide(directory=directory, fn=lambda: "ok") == "ok" + assert calls["count"] == 2 + Instance._cache.pop(directory, None) + + def test_is_db_corruption_error_recognizes_known_messages(): """Both ``NotADBError`` and ``DatabaseError`` variants are flagged as corruption.""" not_a_db = sqlite3.DatabaseError("file is not a database") diff --git a/tests/test_install_profile.py b/tests/test_install_profile.py new file mode 100644 index 000000000..395658a08 --- /dev/null +++ b/tests/test_install_profile.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +import json + +from flocks.cli.install_profile import read_install_language + + +def test_install_profile_round_trips_language(monkeypatch, tmp_path) -> None: + monkeypatch.setenv("FLOCKS_CONFIG_DIR", str(tmp_path)) + + (tmp_path / "install_profile.json").write_text( + json.dumps({"Language": "zh-CN"}), + encoding="utf-8", + ) + + assert read_install_language() == "zh-CN" + + +def test_install_profile_falls_back_to_environment(monkeypatch, tmp_path) -> None: + monkeypatch.setenv("FLOCKS_CONFIG_DIR", str(tmp_path)) + monkeypatch.setenv("FLOCKS_INSTALL_LANGUAGE", "zh_CN") + + assert read_install_language() == "zh-CN" diff --git a/tests/tool/test_channel_message.py b/tests/tool/test_channel_message.py index a7dc1355c..add4aca2a 100644 --- a/tests/tool/test_channel_message.py +++ b/tests/tool/test_channel_message.py @@ -83,3 +83,85 @@ async def test_channel_message_exact_binding_filters_selected_chat_only() -> Non out_ctx = deliver.await_args.args[0] assert out_ctx.account_id == "acct_2" assert out_ctx.to == "chat_2" + + +@pytest.mark.asyncio +async def test_channel_message_falls_back_to_latest_channel_binding() -> None: + latest_binding = SimpleNamespace( + session_id="ses_new", + channel_id="wecom", + account_id="default", + chat_id="room_1", + ) + svc = SimpleNamespace( + list_bindings=AsyncMock(return_value=[latest_binding]), + latest_active_user_binding=AsyncMock(return_value=latest_binding), + ) + deliver_result = DeliveryResult( + channel_id="wecom", + message_id="msg_new", + chat_id="room_1", + ) + + with patch( + "flocks.tool.channel.channel_message._http_session_send", + AsyncMock(return_value=None), + ), patch( + "flocks.channel.inbound.session_binding.SessionBindingService", + return_value=svc, + ), patch( + "flocks.channel.outbound.deliver.OutboundDelivery.deliver", + AsyncMock(return_value=[deliver_result]), + ) as deliver: + result = await channel_message( + ToolContext(session_id="ses_task", message_id="msg_1"), + session_id="ses_old", + message="hello", + channel_type="wecom", + ) + + assert result.success is True + svc.latest_active_user_binding.assert_awaited_once_with( + channel_id="wecom", + account_id=None, + chat_id=None, + ) + deliver.assert_awaited_once() + assert deliver.await_args.kwargs["session_id"] == "ses_new" + out_ctx = deliver.await_args.args[0] + assert out_ctx.account_id == "default" + assert out_ctx.to == "room_1" + + +@pytest.mark.asyncio +async def test_channel_message_does_not_fallback_when_channel_binding_is_ambiguous() -> None: + svc = SimpleNamespace( + list_bindings=AsyncMock(return_value=[]), + latest_active_user_binding=AsyncMock(return_value=None), + ) + + with patch( + "flocks.tool.channel.channel_message._http_session_send", + AsyncMock(return_value=None), + ), patch( + "flocks.channel.inbound.session_binding.SessionBindingService", + return_value=svc, + ), patch( + "flocks.channel.outbound.deliver.OutboundDelivery.deliver", + AsyncMock(), + ) as deliver: + result = await channel_message( + ToolContext(session_id="ses_task", message_id="msg_1"), + session_id="ses_old", + message="hello", + channel_type="wecom", + ) + + assert result.success is False + assert "im_send_message(resolve_only=true)" in (result.error or "") + svc.latest_active_user_binding.assert_awaited_once_with( + channel_id="wecom", + account_id=None, + chat_id=None, + ) + deliver.assert_not_awaited() diff --git a/tests/tool/test_device_schema_migration.py b/tests/tool/test_device_schema_migration.py new file mode 100644 index 000000000..40bd9be68 --- /dev/null +++ b/tests/tool/test_device_schema_migration.py @@ -0,0 +1,94 @@ +"""Device integration schema migration tests.""" + +import sqlite3 +from pathlib import Path +from typing import Any + +import pytest + +from flocks.storage.storage import Storage +from flocks.tool.device import models as device_models + + +def _reset_storage_state() -> None: + Storage._initialized = False + Storage._init_pid = None + Storage._db_path = None + + +async def _shutdown_storage() -> None: + await Storage.shutdown() + _reset_storage_state() + + +async def _device_columns(db_path: Path) -> set[str]: + async with Storage.connect(db_path) as db: + cursor = await db.execute("PRAGMA table_info(device_integrations)") + return {str(row[1]) for row in await cursor.fetchall()} + + +async def _device_indexes(db_path: Path) -> set[str]: + async with Storage.connect(db_path) as db: + cursor = await db.execute("PRAGMA index_list(device_integrations)") + return {str(row[1]) for row in await cursor.fetchall()} + + +def _capture_storage_warnings(monkeypatch) -> list[tuple[Any, Any]]: + warnings: list[tuple[Any, Any]] = [] + monkeypatch.setattr(Storage._log, "warn", lambda message=None, extra=None: warnings.append((message, extra))) + return warnings + + +def _extension_ddl_warnings(warnings: list[tuple[Any, Any]]) -> list[tuple[Any, Any]]: + return [entry for entry in warnings if entry[0] == "storage.extension_ddl.failed"] + + +@pytest.mark.asyncio +async def test_device_schema_fresh_init_does_not_warn_duplicate_group_id(monkeypatch, tmp_path: Path) -> None: + warnings = _capture_storage_warnings(monkeypatch) + db_path = tmp_path / "fresh.db" + + _reset_storage_state() + try: + await Storage.init(db_path) + + assert device_models.DEFAULT_GROUP_ID == "default-room" + assert "group_id" 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_device_schema_old_integrations_table_gets_group_id(monkeypatch, tmp_path: Path) -> None: + warnings = _capture_storage_warnings(monkeypatch) + db_path = tmp_path / "old.db" + with sqlite3.connect(db_path) as db: + db.executescript(""" + CREATE TABLE device_integrations ( + id TEXT PRIMARY KEY, + 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 + ); + """) + + _reset_storage_state() + try: + await Storage.init(db_path) + + assert "group_id" 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() diff --git a/tests/tool/test_flocks_skills.py b/tests/tool/test_flocks_skills.py index 8b5d585da..d9f49f472 100644 --- a/tests/tool/test_flocks_skills.py +++ b/tests/tool/test_flocks_skills.py @@ -186,6 +186,33 @@ async def test_nonzero_exit_returns_failure(): ctx.ask.assert_called_once() +@pytest.mark.asyncio +async def test_install_forwards_raw_safeskill_uri_args(): + from flocks.tool.skill.flocks_skills import flocks_skills + from flocks.skill.installer import SkillInstallResult + + source = "safeskill://tbx/6ef3925b1f6245bcbd7da39f23c28652/onesig-use@1.0.0" + ctx = make_ctx() + installer = AsyncMock( + return_value=SkillInstallResult( + success=True, + skill_name="onesig-use", + location="/tmp/onesig-use/SKILL.md", + message="installed", + ) + ) + + with patch("flocks.skill.installer.SkillInstaller.install_from_source", installer): + result = await flocks_skills(ctx, subcommand="install", args=source) + + assert result.success is True + installer.assert_awaited_once_with(source, scope="global", yes=True) + ctx.ask.assert_called_once() + assert ctx.ask.call_args.kwargs["patterns"] == [ + f"flocks skills install {source} --scope global --yes" + ] + + @pytest.mark.asyncio async def test_install_timeout_returns_failure(): from flocks.tool.skill.flocks_skills import flocks_skills diff --git a/tests/tool/test_registry_lazy_init.py b/tests/tool/test_registry_lazy_init.py index 03224ba8c..af30bc8fc 100644 --- a/tests/tool/test_registry_lazy_init.py +++ b/tests/tool/test_registry_lazy_init.py @@ -47,6 +47,17 @@ def test_public_registry_reads_lazy_initialize_once(monkeypatch: pytest.MonkeyPa assert init_calls == ["init"] +@pytest.mark.asyncio +async def test_init_async_runs_lazy_initialization(monkeypatch: pytest.MonkeyPatch) -> None: + tool = _make_tool() + init_calls = _patch_lazy_init(monkeypatch, tool) + + await ToolRegistry.init_async() + + assert ToolRegistry.get(tool.info.name) is tool + assert init_calls == ["init"] + + @pytest.mark.asyncio async def test_execute_lazy_initializes_registry(monkeypatch: pytest.MonkeyPatch) -> None: tool = _make_tool() diff --git a/tests/tool/test_sangfor_atrust_handler.py b/tests/tool/test_sangfor_atrust_handler.py new file mode 100644 index 000000000..b89905060 --- /dev/null +++ b/tests/tool/test_sangfor_atrust_handler.py @@ -0,0 +1,106 @@ +"""Targeted tests for the Sangfor aTrust OpenAPI V3 handler.""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +import pytest +import yaml + + +_PLUGIN_DIR = ( + Path(__file__).resolve().parents[2] + / ".flocks" + / "flockshub" + / "plugins" + / "tools" + / "device" + / "sangfor_atrust_v3" +) +_HANDLER_PATH = _PLUGIN_DIR / "sangfor_atrust.handler.py" +_PROVIDER_PATH = _PLUGIN_DIR / "_provider.yaml" + + +def _load_handler_module(): + if not _HANDLER_PATH.exists(): + pytest.skip(f"Sangfor aTrust handler not present at {_HANDLER_PATH}") + spec = importlib.util.spec_from_file_location( + "_sangfor_atrust_handler_under_test", + str(_HANDLER_PATH), + ) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + previous = sys.dont_write_bytecode + sys.dont_write_bytecode = True + try: + spec.loader.exec_module(module) + finally: + sys.dont_write_bytecode = previous + return module + + +@pytest.fixture(scope="module") +def handler(): + return _load_handler_module() + + +def test_provider_version_derives_expected_storage_key(): + from flocks.config.api_versioning import derive_storage_key + + provider = yaml.safe_load(_PROVIDER_PATH.read_text(encoding="utf-8")) + + assert provider["version"] == "3" + assert provider["defaults"]["product_version"] == "3" + assert derive_storage_key(provider["service_id"], provider["version"]) == "sangfor_atrust_v3" + + +def test_signature_matches_documented_example(handler, monkeypatch): + monkeypatch.setattr(handler.time, "time", lambda: 1629527100) + monkeypatch.setattr(handler.uuid, "uuid4", lambda: "f5f0fe63-5b3e-4e44-908c-b95758b6d7e4") + + config = handler.RuntimeConfig( + base_url="https://1.1.1.1:4433", + app_id="8165305", + app_secret="aebd2e3c5ea2449aa2928c102f9db276", + verify_ssl=False, + timeout=30, + locale="zh-cn", + default_lang="zh-CN", + ) + query_string = handler._query_string({"username": "sf", "password": "123"}) + body_text = handler._body_text({"status": 1, "type": "test"}) + + headers = handler._signature_headers(config, "/api/v1/admin/login", query_string, body_text) + + assert query_string == "password=123&username=sf" + assert body_text == '{"status":1,"type":"test"}' + assert headers["X-Ca-Sign"] == "5eec2b22d4ad87daac420d9ef1476346da46ecabbfb2ed18a744d571cdde7756" + + +def test_query_signing_uses_raw_values_but_url_query_is_encoded(handler): + query = {"pageSize": 20, "pageIndex": 1, "groupName": "集团内部应用"} + + assert handler._query_string(query) == "groupName=集团内部应用&pageIndex=1&pageSize=20" + assert handler._url_query_string(query) == ( + "groupName=%E9%9B%86%E5%9B%A2%E5%86%85%E9%83%A8%E5%BA%94%E7%94%A8" + "&pageIndex=1&pageSize=20" + ) + + +def test_identity_v3_paths_default_lang_query(handler): + assert handler._with_default_query_params("/api/v3/user/queryAll", {}, "zh-CN") == { + "lang": "zh-CN" + } + assert handler._with_default_query_params("/api/v3/group/queryAll", {}, "zh-CN") == { + "lang": "zh-CN" + } + assert handler._with_default_query_params( + "/api/v3/user/queryById", + {"directoryDomain": "custom", "lang": "en-US"}, + "zh-CN", + ) == {"directoryDomain": "custom", "lang": "en-US"} + assert handler._with_default_query_params("/api/v3/group/queryByFullPath", {}, "zh-CN") == {} + assert handler._with_default_query_params("/api/v3/resource/queryAll", {}, "zh-CN") == {} diff --git a/tests/tool/test_workflow_config_manage.py b/tests/tool/test_workflow_config_manage.py new file mode 100644 index 000000000..d830bed2e --- /dev/null +++ b/tests/tool/test_workflow_config_manage.py @@ -0,0 +1,540 @@ +from __future__ import annotations + +import json +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +import pytest + +from flocks.server.routes import workflow as workflow_routes +from flocks.tool.registry import PermissionRequest, ToolContext, ToolRegistry + + +def _output_json(result) -> dict[str, Any]: + assert isinstance(result.output, str) + payload = json.loads(result.output) + assert isinstance(payload, dict) + return payload + + +@pytest.fixture +def workflow_config_route_fakes( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> dict[str, dict[str, Any]]: + workflow_id = "wf-1" + config_dir = tmp_path / workflow_id + config_dir.mkdir() + stored: dict[str, dict[str, Any]] = {} + + workflow_data = { + "id": workflow_id, + "name": "Demo Workflow", + "category": "default", + "source": "project", + "workflowJson": { + "start": "n1", + "nodes": [{"id": "n1", "type": "python", "code": "result = {'ok': True}"}], + "edges": [], + "triggers": [ + { + "id": "syslog-default", + "type": "syslog", + "enabled": True, + "source": {"host": "0.0.0.0", "port": 514, "protocol": "udp"}, + "mapping": {"syslog_message": "$.body"}, + } + ], + }, + } + + async def _fake_get_config( + requested_workflow_id: str, + *, + kind: str = "workflow.integration-config", + ) -> dict[str, Any] | None: + if kind != "workflow.integration-config": + return stored.get(f"{kind}/{requested_workflow_id}") + return stored.get(requested_workflow_id) + + async def _fake_put_config( + requested_workflow_id: str, + config: dict[str, Any], + *, + kind: str | None = None, + ) -> None: + assert kind in ( + None, + "workflow.integration-config", + "workflow_kafka_config", + "workflow_poller_config", + "workflow_syslog_config", + ) + key = requested_workflow_id if kind in (None, "workflow.integration-config") else f"{kind}/{requested_workflow_id}" + stored[key] = config + + async def _fake_kv_get(_key: Any) -> None: + return None + + async def _fake_statuses(_workflow_id: str, _workflow_json: dict[str, Any]) -> list[dict[str, Any]]: + return [] + + async def _fake_persist_workflow_triggers( + _workflow_id: str, + _workflow_data: dict[str, Any], + _triggers: list[Any], + ) -> None: + return None + + monkeypatch.setattr( + workflow_routes, + "_read_workflow_from_fs", + lambda requested_workflow_id: workflow_data if requested_workflow_id == workflow_id else None, + ) + monkeypatch.setattr( + workflow_routes, + "_workflow_config_dir", + lambda _workflow_id, _workflow_data=None: config_dir, + ) + monkeypatch.setattr(workflow_routes.WorkflowStore, "get_config", _fake_get_config) + monkeypatch.setattr(workflow_routes.WorkflowStore, "put_config", _fake_put_config) + monkeypatch.setattr(workflow_routes.WorkflowStore, "kv_get", _fake_kv_get) + monkeypatch.setattr(workflow_routes, "_persist_workflow_triggers", _fake_persist_workflow_triggers) + monkeypatch.setattr( + workflow_routes, + "default_trigger_runtime", + SimpleNamespace(get_workflow_trigger_statuses=_fake_statuses), + ) + + return stored + + +def test_workflow_config_manage_is_registered_as_builtin_tool() -> None: + ToolRegistry.init() + + tool = ToolRegistry.get("workflow_config_manage") + + assert tool is not None + assert tool.info.native is True + assert tool.info.category.value == "system" + assert "workflow_id" in tool.info.get_schema().properties + assert "config_type" in tool.info.get_schema().properties + + +@pytest.mark.asyncio +async def test_workflow_config_manage_get_reads_file_fallback_without_migration( + workflow_config_route_fakes: dict[str, dict[str, Any]], + tmp_path: Path, +) -> None: + config_path = tmp_path / "wf-1" / "config.json" + config_path.write_text( + json.dumps( + { + "version": 1, + "kind": "workflow.integration-config", + "workflow": {"id": "wf-1"}, + "publish": {"type": "api_service"}, + "triggers": [{"id": "api-default", "type": "api", "enabled": True}], + } + ), + encoding="utf-8", + ) + + result = await ToolRegistry.execute( + "workflow_config_manage", + action="get", + workflow_id="wf-1", + ) + + assert result.success is True, result.error + output = _output_json(result) + assert output["source"] == "file_fallback" + assert output["stored"] is False + assert output["config"]["triggers"][0]["id"] == "api-default" + assert workflow_config_route_fakes == {} + + +@pytest.mark.asyncio +async def test_workflow_config_manage_put_normalizes_and_masks_secrets( + workflow_config_route_fakes: dict[str, dict[str, Any]], +) -> None: + permissions: list[PermissionRequest] = [] + + async def _permission_callback(request: PermissionRequest) -> None: + permissions.append(request) + + ctx = ToolContext( + session_id="test-session", + message_id="test-message", + permission_callback=_permission_callback, + ) + + result = await ToolRegistry.execute( + "workflow_config_manage", + ctx=ctx, + action="put", + workflow_id="wf-1", + config={ + "version": 1, + "kind": "workflow.integration-config", + "workflow": {"id": "wf-1"}, + "publish": {"type": "api_service", "enabled": True, "apiKey": "secret-value"}, + "triggers": [ + { + "id": "api-default", + "type": "api", + "enabled": True, + "auth": {"type": "api_key", "apiKey": "trigger-secret"}, + } + ], + }, + ) + + assert result.success is True, result.error + assert permissions + assert permissions[0].permission == "workflow_config" + assert permissions[0].metadata["action"] == "put" + assert "diff" in permissions[0].metadata + + output = _output_json(result) + written = workflow_config_route_fakes["wf-1"] + assert written == output["config"] + assert written["workflow"]["name"] == "Demo Workflow" + assert written["publish"]["apiKeyConfigured"] is True + assert "apiKey" not in written["publish"] + assert written["triggers"][0]["auth"]["apiKeyConfigured"] is True + assert "apiKey" not in written["triggers"][0]["auth"] + assert output["source"] == "storage" + + +@pytest.mark.asyncio +async def test_workflow_config_manage_diff_does_not_write_config( + workflow_config_route_fakes: dict[str, dict[str, Any]], +) -> None: + result = await ToolRegistry.execute( + "workflow_config_manage", + action="diff", + workflow_id="wf-1", + config={ + "version": 1, + "kind": "workflow.integration-config", + "workflow": {"id": "wf-1"}, + "publish": {"type": "api_service"}, + "triggers": [{"id": "api-default", "type": "api", "enabled": True}], + }, + ) + + assert result.success is True, result.error + output = _output_json(result) + assert output["changed"] is True + assert "api-default" in output["diff"] + assert workflow_config_route_fakes == {} + + +@pytest.mark.asyncio +async def test_workflow_config_manage_sync_writes_missing_config( + workflow_config_route_fakes: dict[str, dict[str, Any]], +) -> None: + permissions: list[PermissionRequest] = [] + + async def _permission_callback(request: PermissionRequest) -> None: + permissions.append(request) + + ctx = ToolContext( + session_id="test-session", + message_id="test-message", + permission_callback=_permission_callback, + ) + + result = await ToolRegistry.execute( + "workflow_config_manage", + ctx=ctx, + action="sync", + workflow_id="wf-1", + ) + + assert result.success is True, result.error + assert permissions[0].permission == "workflow_config" + output = _output_json(result) + assert output["source"] == "storage" + assert workflow_config_route_fakes["wf-1"]["kind"] == "workflow.integration-config" + assert workflow_config_route_fakes["wf-1"]["triggers"][0]["type"] == "syslog" + + +@pytest.mark.asyncio +async def test_workflow_config_manage_get_reads_poller_config( + workflow_config_route_fakes: dict[str, dict[str, Any]], + monkeypatch: pytest.MonkeyPatch, +) -> None: + workflow_config_route_fakes["workflow_poller_config/wf-1"] = { + "workflowId": "wf-1", + "enabled": True, + "intervalSeconds": 180, + "cronExpression": None, + "timeoutSeconds": 7200, + "noOverlap": True, + "inputs": {"input_date": "2026-07-06"}, + } + monkeypatch.setattr( + "flocks.workflow.poller_manager.default_manager", + SimpleNamespace(get_status=lambda workflow_id: {"workflowId": workflow_id, "state": "running"}), + ) + + result = await ToolRegistry.execute( + "workflow_config_manage", + action="get", + workflow_id="wf-1", + config_type="poller", + ) + + assert result.success is True, result.error + output = _output_json(result) + assert output["configType"] == "poller" + assert output["storageKey"] == "workflow_poller_config/wf-1" + assert output["config"]["intervalSeconds"] == 180 + assert output["runtime"]["state"] == "running" + + +@pytest.mark.asyncio +async def test_workflow_config_manage_get_reads_syslog_trigger_fallback( + workflow_config_route_fakes: dict[str, dict[str, Any]], + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + "flocks.ingest.syslog.manager.default_manager", + SimpleNamespace(get_listener_status=lambda workflow_id: {"workflowId": workflow_id, "state": "listening"}), + ) + + result = await ToolRegistry.execute( + "workflow_config_manage", + action="get", + workflow_id="wf-1", + config_type="syslog", + ) + + assert result.success is True, result.error + output = _output_json(result) + assert output["configType"] == "syslog" + assert output["source"] == "trigger_fallback" + assert output["config"]["port"] == 514 + assert output["runtime"]["state"] == "listening" + + +@pytest.mark.asyncio +async def test_workflow_config_manage_poller_diff_does_not_write_config( + workflow_config_route_fakes: dict[str, dict[str, Any]], + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + "flocks.workflow.poller_manager.default_manager", + SimpleNamespace(get_status=lambda workflow_id: {"workflowId": workflow_id, "state": "stopped"}), + ) + + result = await ToolRegistry.execute( + "workflow_config_manage", + action="diff", + workflow_id="wf-1", + config_type="poller", + config={ + "workflowId": "wf-1", + "enabled": True, + "intervalSeconds": 180, + "timeoutSeconds": 7200, + "noOverlap": True, + "inputs": {"input_date": "2026-07-06"}, + }, + ) + + assert result.success is True, result.error + output = _output_json(result) + assert output["configType"] == "poller" + assert output["changed"] is True + assert "intervalSeconds" in output["diff"] + assert workflow_config_route_fakes == {} + + +@pytest.mark.asyncio +async def test_workflow_config_manage_put_poller_config_uses_tool_permission_and_route( + workflow_config_route_fakes: dict[str, dict[str, Any]], + monkeypatch: pytest.MonkeyPatch, +) -> None: + permissions: list[PermissionRequest] = [] + + async def _permission_callback(request: PermissionRequest) -> None: + permissions.append(request) + + async def _fake_restart(workflow_id: str) -> dict[str, Any]: + return {"workflowId": workflow_id, "state": "running"} + + monkeypatch.setattr( + "flocks.workflow.poller_manager.default_manager", + SimpleNamespace( + get_status=lambda workflow_id: {"workflowId": workflow_id, "state": "running"}, + restart_workflow=_fake_restart, + ), + ) + ctx = ToolContext( + session_id="test-session", + message_id="test-message", + permission_callback=_permission_callback, + ) + + result = await ToolRegistry.execute( + "workflow_config_manage", + ctx=ctx, + action="put", + workflow_id="wf-1", + config_type="poller", + config={ + "workflowId": "wf-1", + "enabled": True, + "intervalSeconds": 180, + "timeoutSeconds": 7200, + "noOverlap": True, + "inputs": {"input_date": "2026-07-06"}, + }, + ) + + assert result.success is True, result.error + assert permissions + assert permissions[0].permission == "workflow_config" + assert permissions[0].metadata["config_type"] == "poller" + written = workflow_config_route_fakes["workflow_poller_config/wf-1"] + assert written["workflowId"] == "wf-1" + assert written["enabled"] is True + assert written["intervalSeconds"] == 180 + output = _output_json(result) + assert output["configType"] == "poller" + assert output["config"]["intervalSeconds"] == 180 + + +@pytest.mark.asyncio +async def test_workflow_config_manage_put_poller_config_skips_noop_restart( + workflow_config_route_fakes: dict[str, dict[str, Any]], + monkeypatch: pytest.MonkeyPatch, +) -> None: + current_config = { + "workflowId": "wf-1", + "enabled": True, + "intervalSeconds": 180, + "cronExpression": None, + "timeoutSeconds": 7200, + "noOverlap": True, + "inputs": {"input_date": "2026-07-06"}, + } + workflow_config_route_fakes["workflow_poller_config/wf-1"] = dict(current_config) + permissions: list[PermissionRequest] = [] + restarts: list[str] = [] + + async def _permission_callback(request: PermissionRequest) -> None: + permissions.append(request) + + async def _fake_restart(workflow_id: str) -> dict[str, Any]: + restarts.append(workflow_id) + return {"workflowId": workflow_id, "state": "running"} + + monkeypatch.setattr( + "flocks.workflow.poller_manager.default_manager", + SimpleNamespace( + get_status=lambda workflow_id: {"workflowId": workflow_id, "state": "running"}, + restart_workflow=_fake_restart, + ), + ) + ctx = ToolContext( + session_id="test-session", + message_id="test-message", + permission_callback=_permission_callback, + ) + + result = await ToolRegistry.execute( + "workflow_config_manage", + ctx=ctx, + action="put", + workflow_id="wf-1", + config_type="poller", + config=current_config, + ) + + assert result.success is True, result.error + assert permissions == [] + assert restarts == [] + assert workflow_config_route_fakes["workflow_poller_config/wf-1"] == current_config + output = _output_json(result) + assert output["changed"] is False + assert output["applied"] is False + assert output["runtimeFailed"] is False + assert output["saveResult"]["skipped"] is True + + +@pytest.mark.asyncio +async def test_workflow_config_manage_put_reports_runtime_failure_after_config_write( + workflow_config_route_fakes: dict[str, dict[str, Any]], + monkeypatch: pytest.MonkeyPatch, +) -> None: + permissions: list[PermissionRequest] = [] + + async def _permission_callback(request: PermissionRequest) -> None: + permissions.append(request) + + async def _fake_restart(workflow_id: str) -> dict[str, Any]: + return {"workflowId": workflow_id, "state": "failed", "error": "boom"} + + monkeypatch.setattr( + "flocks.workflow.poller_manager.default_manager", + SimpleNamespace( + get_status=lambda workflow_id: {"workflowId": workflow_id, "state": "failed", "error": "boom"}, + restart_workflow=_fake_restart, + ), + ) + ctx = ToolContext( + session_id="test-session", + message_id="test-message", + permission_callback=_permission_callback, + ) + + result = await ToolRegistry.execute( + "workflow_config_manage", + ctx=ctx, + action="put", + workflow_id="wf-1", + config_type="poller", + config={ + "workflowId": "wf-1", + "enabled": True, + "intervalSeconds": 180, + "timeoutSeconds": 7200, + "noOverlap": True, + "inputs": {"input_date": "2026-07-06"}, + }, + ) + + assert result.success is True, result.error + assert permissions + written = workflow_config_route_fakes["workflow_poller_config/wf-1"] + assert written["intervalSeconds"] == 180 + output = _output_json(result) + assert output["applied"] is True + assert output["runtimeFailed"] is True + assert "boom" in output["runtimeError"] + assert output["saveResult"]["ok"] is False + + +@pytest.mark.asyncio +async def test_workflow_config_manage_rejects_mismatched_runtime_workflow_id( + workflow_config_route_fakes: dict[str, dict[str, Any]], +) -> None: + result = await ToolRegistry.execute( + "workflow_config_manage", + action="diff", + workflow_id="wf-1", + config_type="poller", + config={ + "workflowId": "wf-2", + "enabled": True, + }, + ) + + assert result.success is False + assert "workflowId does not match" in (result.error or "") + assert workflow_config_route_fakes == {} diff --git a/tests/updater/test_restart_handoff.py b/tests/updater/test_restart_handoff.py index 6f9ceaab2..e9df3cbb7 100644 --- a/tests/updater/test_restart_handoff.py +++ b/tests/updater/test_restart_handoff.py @@ -1,11 +1,17 @@ +import shutil +import sys from pathlib import Path from types import SimpleNamespace +import pytest + +from flocks.cli import service_manager from flocks.updater import restart_handoff +from tests.helpers.service_supervisor import make_short_runtime_root, start_supervisor, stop_supervisor, wait_for_supervisor -def _handoff_args(tmp_path: Path, restart_argv: list[str]) -> list[str]: - return [ +def _handoff_args(tmp_path: Path, restart_argv: list[str], *, prepare_handover: bool = False) -> list[str]: + args = [ "--parent-pid", "1234", "--backend-host", @@ -16,8 +22,6 @@ def _handoff_args(tmp_path: Path, restart_argv: list[str]) -> list[str]: "127.0.0.1", "--frontend-port", "5173", - "--backend-pid-file", - str(tmp_path / "backend.pid"), "--install-root", str(tmp_path), "--uv-path", @@ -28,9 +32,10 @@ def _handoff_args(tmp_path: Path, restart_argv: list[str]) -> list[str]: "2026.4.1", "--current-version", "2026.3.31", - "--", - *restart_argv, ] + if prepare_handover: + args.append("--prepare-handover") + return [*args, "--", *restart_argv] def test_run_waits_for_parent_and_backend_port_before_spawning( @@ -39,6 +44,22 @@ def test_run_waits_for_parent_and_backend_port_before_spawning( ) -> None: events: list[str] = [] restart_argv = ["python.exe", "-m", "flocks.cli.main", "serve", "--host", "127.0.0.1", "--port", "8000"] + expected_restart_argv = [ + "python.exe", + "-m", + "flocks.cli.main", + "start", + "--no-browser", + "--skip-webui-build", + "--host", + "127.0.0.1", + "--port", + "5173", + "--server-host", + "127.0.0.1", + "--server-port", + "8000", + ] monkeypatch.setattr(restart_handoff, "_record_handoff_log", lambda message: events.append(f"log:{message}")) monkeypatch.setattr( @@ -49,7 +70,7 @@ def test_run_waits_for_parent_and_backend_port_before_spawning( monkeypatch.setattr( restart_handoff, "_ensure_backend_port_free", - lambda backend_port, backend_pid_file: events.append(f"free-port:{backend_port}:{backend_pid_file.name}") or True, + lambda backend_port: events.append(f"free-port:{backend_port}") or True, ) monkeypatch.setattr( restart_handoff.subprocess, @@ -57,26 +78,171 @@ def test_run_waits_for_parent_and_backend_port_before_spawning( lambda argv, cwd=None, close_fds=False: events.append(f"spawn:{list(argv)}:{cwd}:{close_fds}") or SimpleNamespace(pid=4321), ) + monkeypatch.setattr(restart_handoff, "_run_upgrade_tasks", lambda args: events.append("tasks") or None) monkeypatch.setattr( restart_handoff, - "_record_backend_runtime_if_direct_serve", - lambda process, argv, **kwargs: events.append(f"record:{process.pid}:{list(argv)}:{kwargs['backend_port']}"), + "_stop_supervisor_before_restart", + lambda: events.append("stop-supervisor") or True, ) - monkeypatch.setattr(restart_handoff, "_run_upgrade_tasks", lambda args: events.append("tasks") or None) code = restart_handoff.run(_handoff_args(tmp_path, restart_argv)) + assert code == 0 + assert events == [ + f"log:legacy_serve_restart_migrated argv={expected_restart_argv}", + "log:started parent_pid=1234 backend=127.0.0.1:8000 frontend=127.0.0.1:5173", + "wait-parent:1234", + "free-port:8000", + "tasks", + "stop-supervisor", + f"spawn:{expected_restart_argv}:{tmp_path}:True", + "log:restart_spawned pid=4321", + ] + + +def test_run_keeps_current_start_restart_argv(monkeypatch, tmp_path: Path) -> None: + events: list[str] = [] + restart_argv = [ + "python.exe", + "-m", + "flocks.cli.main", + "start", + "--no-browser", + "--skip-webui-build", + "--host", + "127.0.0.1", + "--port", + "5173", + ] + + monkeypatch.setattr(restart_handoff, "_record_handoff_log", lambda message: events.append(f"log:{message}")) + monkeypatch.setattr(restart_handoff, "_wait_for_parent_exit", lambda parent_pid: True) + monkeypatch.setattr(restart_handoff, "_ensure_backend_port_free", lambda backend_port: True) + monkeypatch.setattr(restart_handoff, "_run_upgrade_tasks", lambda args: None) + monkeypatch.setattr(restart_handoff, "_stop_supervisor_before_restart", lambda: True) + monkeypatch.setattr( + restart_handoff.subprocess, + "Popen", + lambda argv, cwd=None, close_fds=False: events.append(f"spawn:{list(argv)}:{cwd}:{close_fds}") + or SimpleNamespace(pid=4321), + ) + + code = restart_handoff.run(_handoff_args(tmp_path, restart_argv)) + + assert code == 0 + assert f"spawn:{restart_argv}:{tmp_path}:True" in events + + +def test_run_accepts_legacy_backend_pid_file_argument(monkeypatch, tmp_path: Path) -> None: + events: list[str] = [] + restart_argv = ["python.exe", "-m", "flocks.cli.main", "start"] + args = _handoff_args(tmp_path, restart_argv) + args[args.index("--install-root"):args.index("--install-root")] = [ + "--backend-pid-file", + str(tmp_path / "backend.pid"), + ] + + monkeypatch.setattr(restart_handoff, "_record_handoff_log", lambda message: events.append(f"log:{message}")) + monkeypatch.setattr(restart_handoff, "_wait_for_parent_exit", lambda parent_pid: True) + monkeypatch.setattr(restart_handoff, "_ensure_backend_port_free", lambda backend_port: True) + monkeypatch.setattr(restart_handoff, "_run_upgrade_tasks", lambda args: None) + monkeypatch.setattr(restart_handoff, "_stop_supervisor_before_restart", lambda: True) + monkeypatch.setattr( + restart_handoff.subprocess, + "Popen", + lambda argv, cwd=None, close_fds=False: events.append(f"spawn:{list(argv)}:{cwd}:{close_fds}") + or SimpleNamespace(pid=4321), + ) + + code = restart_handoff.run(args) + + assert code == 0 + assert f"spawn:{restart_argv}:{tmp_path}:True" in events + + +def test_run_prepares_handover_after_parent_exit_without_waiting_for_page_port( + monkeypatch, + tmp_path: Path, +) -> None: + events: list[str] = [] + restart_argv = ["python.exe", "-m", "flocks.cli.main", "start"] + + monkeypatch.setattr(restart_handoff, "_record_handoff_log", lambda message: events.append(f"log:{message}")) + monkeypatch.setattr( + restart_handoff, + "_wait_for_parent_exit", + lambda parent_pid: events.append(f"wait-parent:{parent_pid}") or True, + ) + monkeypatch.setattr( + restart_handoff, + "_prepare_upgrade_handover", + lambda args: events.append(f"prepare:{args.version}") or True, + ) + monkeypatch.setattr( + restart_handoff, + "_ensure_backend_port_free", + lambda backend_port: events.append(f"free-port:{backend_port}") or True, + ) + monkeypatch.setattr(restart_handoff, "_run_upgrade_tasks", lambda args: events.append("tasks") or None) + monkeypatch.setattr( + restart_handoff, + "_stop_supervisor_before_restart", + lambda: events.append("stop-supervisor") or True, + ) + monkeypatch.setattr( + restart_handoff.subprocess, + "Popen", + lambda argv, cwd=None, close_fds=False: events.append(f"spawn:{list(argv)}:{cwd}:{close_fds}") + or SimpleNamespace(pid=4321), + ) + + code = restart_handoff.run(_handoff_args(tmp_path, restart_argv, prepare_handover=True)) + assert code == 0 assert events[1:] == [ "wait-parent:1234", - "free-port:8000:backend.pid", + "prepare:2026.4.1", "tasks", + "stop-supervisor", f"spawn:{restart_argv}:{tmp_path}:True", - f"record:4321:{restart_argv}:8000", "log:restart_spawned pid=4321", ] +def test_run_reports_pending_install_receipt_after_pro_bundle_tasks( + monkeypatch, + tmp_path: Path, +) -> None: + events: list[str] = [] + restart_argv = ["python.exe", "-m", "flocks.cli.main", "serve"] + manifest = tmp_path / "manifest.json" + manifest.write_text("{}", encoding="utf-8") + args = _handoff_args(tmp_path, restart_argv) + separator_index = args.index("--") + args[separator_index:separator_index] = ["--pro-bundle-manifest-path", str(manifest)] + + monkeypatch.setattr(restart_handoff, "_record_handoff_log", lambda message: events.append(f"log:{message}")) + monkeypatch.setattr(restart_handoff, "_wait_for_parent_exit", lambda parent_pid: True) + monkeypatch.setattr(restart_handoff, "_ensure_backend_port_free", lambda backend_port, backend_pid_file: True) + monkeypatch.setattr(restart_handoff, "_run_upgrade_tasks", lambda args: events.append("tasks") or None) + monkeypatch.setattr( + restart_handoff, + "_report_pending_pro_bundle_install_receipt", + lambda args: events.append("receipt"), + ) + monkeypatch.setattr( + restart_handoff.subprocess, + "Popen", + lambda argv, cwd=None, close_fds=False: events.append(f"spawn:{list(argv)}") or SimpleNamespace(pid=4321), + ) + monkeypatch.setattr(restart_handoff, "_record_backend_runtime_if_direct_serve", lambda *_args, **_kwargs: None) + + code = restart_handoff.run(args) + + assert code == 0 + assert events[1:4] == ["tasks", "receipt", f"spawn:{restart_argv}"] + + def test_run_does_not_spawn_when_parent_exit_times_out(monkeypatch, tmp_path: Path) -> None: events: list[str] = [] @@ -89,7 +255,7 @@ def test_run_does_not_spawn_when_parent_exit_times_out(monkeypatch, tmp_path: Pa ) monkeypatch.setattr(restart_handoff, "_run_upgrade_tasks", lambda args: events.append("tasks") or None) - code = restart_handoff.run(_handoff_args(tmp_path, ["python.exe", "-m", "flocks.cli.main", "serve"])) + code = restart_handoff.run(_handoff_args(tmp_path, ["python.exe", "-m", "flocks.cli.main", "start"])) assert code == 1 assert events == ["log:started parent_pid=1234 backend=127.0.0.1:8000 frontend=127.0.0.1:5173", "log:parent_exit_timeout parent_pid=1234"] @@ -101,7 +267,7 @@ def test_run_does_not_spawn_when_upgrade_tasks_fail(monkeypatch, tmp_path: Path) monkeypatch.setattr(restart_handoff, "_record_handoff_log", lambda message: events.append(f"log:{message}")) monkeypatch.setattr(restart_handoff, "_wait_for_parent_exit", lambda parent_pid: True) - monkeypatch.setattr(restart_handoff, "_ensure_backend_port_free", lambda backend_port, backend_pid_file: True) + monkeypatch.setattr(restart_handoff, "_ensure_backend_port_free", lambda backend_port: True) monkeypatch.setattr(restart_handoff, "_run_upgrade_tasks", lambda args: "sync failed") monkeypatch.setattr(restart_handoff, "_rollback_failed_upgrade", lambda args, error: events.append(f"rollback:{error}")) monkeypatch.setattr( @@ -132,7 +298,7 @@ def crash(_args): monkeypatch.setattr(restart_handoff, "_record_handoff_log", lambda message: events.append(f"log:{message}")) monkeypatch.setattr(restart_handoff, "_wait_for_parent_exit", lambda parent_pid: True) - monkeypatch.setattr(restart_handoff, "_ensure_backend_port_free", lambda backend_port, backend_pid_file: True) + monkeypatch.setattr(restart_handoff, "_ensure_backend_port_free", lambda backend_port: True) monkeypatch.setattr(restart_handoff, "_run_upgrade_tasks", crash) monkeypatch.setattr(restart_handoff, "_rollback_failed_upgrade", lambda args, error: events.append(f"rollback:{error}")) monkeypatch.setattr( @@ -149,10 +315,99 @@ def crash(_args): assert "spawn" not in events -def test_ensure_backend_port_free_stops_backend_after_wait_timeout(monkeypatch, tmp_path: Path) -> None: +def test_run_does_not_spawn_when_supervisor_stop_fails(monkeypatch, tmp_path: Path) -> None: + events: list[str] = [] + restart_argv = ["python.exe", "-m", "flocks.cli.main", "start"] + + monkeypatch.setattr(restart_handoff, "_record_handoff_log", lambda message: events.append(f"log:{message}")) + monkeypatch.setattr(restart_handoff, "_wait_for_parent_exit", lambda parent_pid: True) + monkeypatch.setattr(restart_handoff, "_ensure_backend_port_free", lambda backend_port: True) + monkeypatch.setattr(restart_handoff, "_run_upgrade_tasks", lambda args: None) + monkeypatch.setattr(restart_handoff, "_stop_supervisor_before_restart", lambda: False) + monkeypatch.setattr( + restart_handoff.subprocess, + "Popen", + lambda *_args, **_kwargs: events.append("spawn"), + ) + + code = restart_handoff.run(_handoff_args(tmp_path, restart_argv)) + + assert code == 1 + assert "log:supervisor_stop_timeout" in events + assert "spawn" not in events + + +def test_run_rolls_back_prepared_handover_when_supervisor_stop_fails(monkeypatch, tmp_path: Path) -> None: + events: list[str] = [] + restart_argv = ["python.exe", "-m", "flocks.cli.main", "start"] + + monkeypatch.setattr(restart_handoff, "_record_handoff_log", lambda message: events.append(f"log:{message}")) + monkeypatch.setattr(restart_handoff, "_wait_for_parent_exit", lambda parent_pid: True) + monkeypatch.setattr(restart_handoff, "_prepare_upgrade_handover", lambda args: events.append("prepare") or True) + monkeypatch.setattr(restart_handoff, "_run_upgrade_tasks", lambda args: None) + monkeypatch.setattr(restart_handoff, "_stop_supervisor_before_restart", lambda: False) + monkeypatch.setattr(restart_handoff, "_rollback_upgrade_handover", lambda: events.append("rollback-handover")) + monkeypatch.setattr( + restart_handoff.subprocess, + "Popen", + lambda *_args, **_kwargs: events.append("spawn"), + ) + + code = restart_handoff.run(_handoff_args(tmp_path, restart_argv, prepare_handover=True)) + + assert code == 1 + assert "rollback-handover" in events + assert "spawn" not in events + + +def test_run_rolls_back_prepared_handover_when_restart_spawn_fails(monkeypatch, tmp_path: Path) -> None: + events: list[str] = [] + restart_argv = ["python.exe", "-m", "flocks.cli.main", "start"] + + monkeypatch.setattr(restart_handoff, "_record_handoff_log", lambda message: events.append(f"log:{message}")) + monkeypatch.setattr(restart_handoff, "_wait_for_parent_exit", lambda parent_pid: True) + monkeypatch.setattr(restart_handoff, "_prepare_upgrade_handover", lambda args: events.append("prepare") or True) + monkeypatch.setattr(restart_handoff, "_run_upgrade_tasks", lambda args: None) + monkeypatch.setattr(restart_handoff, "_stop_supervisor_before_restart", lambda: True) + monkeypatch.setattr(restart_handoff, "_rollback_upgrade_handover", lambda: events.append("rollback-handover")) + monkeypatch.setattr( + restart_handoff.subprocess, + "Popen", + lambda *_args, **_kwargs: (_ for _ in ()).throw(OSError("spawn failed")), + ) + + code = restart_handoff.run(_handoff_args(tmp_path, restart_argv, prepare_handover=True)) + + assert code == 1 + assert "log:restart_spawn_failed error=spawn failed" in events + assert "rollback-handover" in events + + +@pytest.mark.skipif(sys.platform == "win32", reason="uses the Unix domain socket control API") +def test_stop_supervisor_before_restart_waits_until_real_control_api_stops(monkeypatch) -> None: + short_root = make_short_runtime_root("flocks-handoff-") + monkeypatch.setenv("FLOCKS_ROOT", str(short_root)) + paths = service_manager.runtime_paths() + daemon, thread = start_supervisor( + service_manager.ServiceConfig(backend_port=9995, frontend_port=9996), + ) + + try: + wait_for_supervisor(paths, running=True) + + assert restart_handoff._stop_supervisor_before_restart(timeout_seconds=5.0, poll_interval_seconds=0.05) is True + + wait_for_supervisor(paths, running=False) + thread.join(timeout=5) + assert not thread.is_alive() + finally: + stop_supervisor(daemon, thread) + shutil.rmtree(short_root, ignore_errors=True) + + +def test_ensure_backend_port_free_waits_again_after_timeout(monkeypatch) -> None: events: list[str] = [] wait_results = iter([False, True]) - backend_pid_file = tmp_path / "backend.pid" monkeypatch.setattr(restart_handoff, "_record_handoff_log", lambda message: events.append(f"log:{message}")) monkeypatch.setattr( @@ -160,16 +415,10 @@ def test_ensure_backend_port_free_stops_backend_after_wait_timeout(monkeypatch, "_wait_for_backend_port_free", lambda port, **kwargs: events.append(f"wait:{port}:{kwargs.get('timeout_seconds')}") or next(wait_results), ) - monkeypatch.setattr( - restart_handoff.service_manager, - "stop_one", - lambda port, pid_file, name, console: events.append(f"stop:{port}:{pid_file.name}:{name}"), - ) - assert restart_handoff._ensure_backend_port_free(8000, backend_pid_file) is True + assert restart_handoff._ensure_backend_port_free(8000) is True assert events == [ "wait:8000:None", - "log:backend_port_still_in_use port=8000; stopping backend", - "stop:8000:backend.pid:backend", + "log:backend_port_still_in_use port=8000", "wait:8000:20.0", ] diff --git a/tests/updater/test_updater.py b/tests/updater/test_updater.py index 1a176274c..cbeb4b043 100644 --- a/tests/updater/test_updater.py +++ b/tests/updater/test_updater.py @@ -10,8 +10,15 @@ import pytest -from flocks.cli import service_manager +from flocks.cli import service_control, service_manager from flocks.updater import updater +from tests.helpers.service_supervisor import ( + make_short_runtime_root, + start_supervisor, + stop_supervisor, + wait_for_process_exit, + wait_for_supervisor, +) def _write_pyproject_version(pyproject_path: Path, version: str) -> None: @@ -39,6 +46,33 @@ def _prepare_real_restart_runtime(install_root: Path) -> None: python_path.chmod(0o755) +def _webui_control_payload(state: str = "healthy", last_error: str | None = None) -> dict[str, object]: + return { + "webui": { + "state": state, + "last_error": last_error, + }, + } + + +def _webui_control_status( + state: str = "healthy", + last_error: str | None = None, +) -> service_control.SupervisorStatus: + return service_control.parse_supervisor_status(_webui_control_payload(state, last_error)) + + +def test_current_service_config_requires_supervisor_control_api(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + service_control, + "read_supervisor_status", + lambda **_kwargs: (_ for _ in ()).throw(RuntimeError("control down")), + ) + + with pytest.raises(RuntimeError, match="Supervisor control API is unavailable"): + updater._current_service_config() + + def test_run_handles_none_process_output(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: def fake_run(*args, **kwargs): return subprocess.CompletedProcess(args=args[0], returncode=0, stdout=None, stderr=None) @@ -225,13 +259,6 @@ def test_find_executable_checks_windows_cmd_suffixes( assert updater._find_executable("npm") == str(npm_cmd) -def test_is_windows_file_in_use_error_detects_winerror32(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(updater.sys, "platform", "win32") - - assert updater._is_windows_file_in_use_error(PermissionError("[WinError 32] file in use")) is True - assert updater._is_windows_file_in_use_error(PermissionError("[WinError 5] access denied")) is False - - def test_is_uv_managed_python_runtime_error_detects_virtualenv_creation_failure() -> None: text = ( "Failed to create temporary virtualenv\n" @@ -504,12 +531,14 @@ def test_build_dependency_sync_command_keeps_project_install_on_non_windows( assert updater._build_dependency_sync_command("uv") == ["uv", "sync", "--frozen", "--no-python-downloads"] -def test_wheel_build_config_does_not_force_include_flockshub() -> None: +def test_wheel_build_config_does_not_force_include_runtime_or_build_outputs() -> None: pyproject_path = Path(__file__).resolve().parents[2] / "pyproject.toml" pyproject = tomllib.loads(pyproject_path.read_text(encoding="utf-8")) wheel_config = pyproject["tool"]["hatch"]["build"]["targets"]["wheel"] + forced_includes = wheel_config.get("force-include", {}) - assert ".flocks/flockshub" not in wheel_config.get("force-include", {}) + assert ".flocks/flockshub" not in forced_includes + assert "webui/dist" not in forced_includes def test_build_frontend_subprocess_env_prepends_bundled_node_on_windows( @@ -772,6 +801,50 @@ def test_build_restart_argv_uses_venv_python_on_non_windows( ] +def test_build_restart_handoff_argv_rewrites_serve_to_managed_start( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + config = service_manager.ServiceConfig( + backend_host="10.0.0.8", + backend_port=5273, + frontend_host="10.0.0.8", + frontend_port=5273, + legacy_backend_host="0.0.0.0", + legacy_backend_port=9000, + ) + monkeypatch.setattr(updater, "_handoff_service_config", lambda: config) + monkeypatch.setattr(updater.os, "getpid", lambda: 1234) + + argv = updater._build_restart_handoff_argv( + ["python", "-m", "flocks.cli.main", "serve", "--host", "0.0.0.0", "--port", "9000"], + tmp_path, + uv_path="uv", + sync_timeout=300, + version="2026.4.1", + current_version="2026.3.31", + prepare_handover=True, + ) + + assert "--prepare-handover" in argv[: argv.index("--")] + assert argv[argv.index("--") + 1 :] == [ + "python", + "-m", + "flocks.cli.main", + "start", + "--no-browser", + "--skip-webui-build", + "--host", + "10.0.0.8", + "--port", + "5273", + "--server-host", + "0.0.0.0", + "--server-port", + "9000", + ] + + def test_refresh_global_cli_entry_creates_symlink_on_unix( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, @@ -990,92 +1063,134 @@ def test_safe_remove_renames_locked_directory_on_windows( assert (leftovers[0] / "dist" / "index.html").exists() -def test_prepare_upgrade_handover_writes_state_and_stops_frontend( +@pytest.mark.skipif(sys.platform == "win32", reason="uses the Unix domain socket control API") +def test_prepare_upgrade_handover_writes_state_and_stops_frontend_with_real_control_api( monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, ) -> None: - monkeypatch.setenv("FLOCKS_ROOT", str(tmp_path / ".flocks")) - paths = service_manager.RuntimePaths( - root=tmp_path / ".flocks", - run_dir=tmp_path / ".flocks" / "run", - log_dir=tmp_path / ".flocks" / "logs", - backend_pid=tmp_path / ".flocks" / "run" / "backend.pid", - frontend_pid=tmp_path / ".flocks" / "run" / "webui.pid", - backend_log=tmp_path / ".flocks" / "logs" / "backend.log", - frontend_log=tmp_path / ".flocks" / "logs" / "webui.log", - ) - paths.run_dir.mkdir(parents=True) - paths.log_dir.mkdir(parents=True) - - calls: list[tuple[int, str]] = [] - monkeypatch.setattr(updater, "_current_service_config", lambda: service_manager.ServiceConfig()) + short_root = make_short_runtime_root("flocks-updater-") + monkeypatch.setenv("FLOCKS_ROOT", str(short_root)) + paths = service_manager.runtime_paths() + config = service_manager.ServiceConfig( + backend_host="127.0.0.1", + backend_port=9995, + frontend_host="127.0.0.1", + frontend_port=9996, + ) + daemon, thread = start_supervisor(config) + wait_for_supervisor(paths, running=True) + monkeypatch.setattr( updater, "_start_upgrade_page_server", - lambda config, version: {"upgrade_server_pid": 321, "page_dir": str(tmp_path / "page"), "page_log": str(tmp_path / "upgrade.log")}, - ) - monkeypatch.setattr(service_manager, "ensure_runtime_dirs", lambda: paths) - monkeypatch.setattr(service_manager, "_recorded_port", lambda _pid_file, default: default) - monkeypatch.setattr( - service_manager, - "stop_one", - lambda port, _pid_file, name, _console: calls.append((port, name)), + lambda _config, _version: { + "upgrade_server_pid": 321, + "page_dir": str(short_root / "page"), + "page_log": str(short_root / "logs" / "upgrade.log"), + }, ) - payload = updater._prepare_upgrade_handover("2026.3.31.1") - - assert calls == [(5173, "WebUI")] - assert payload["upgrade_server_pid"] == 321 - assert updater._read_upgrade_state()["version"] == "2026.3.31.1" - - -def test_prepare_upgrade_handover_restores_frontend_when_upgrade_page_fails( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - monkeypatch.setenv("FLOCKS_ROOT", str(tmp_path / ".flocks")) - paths = service_manager.RuntimePaths( - root=tmp_path / ".flocks", - run_dir=tmp_path / ".flocks" / "run", - log_dir=tmp_path / ".flocks" / "logs", - backend_pid=tmp_path / ".flocks" / "run" / "backend.pid", - frontend_pid=tmp_path / ".flocks" / "run" / "webui.pid", - backend_log=tmp_path / ".flocks" / "logs" / "backend.log", - frontend_log=tmp_path / ".flocks" / "logs" / "webui.log", - ) - paths.run_dir.mkdir(parents=True) - paths.log_dir.mkdir(parents=True) - - calls: list[tuple[str, bool]] = [] - monkeypatch.setattr(updater, "_current_service_config", lambda: service_manager.ServiceConfig()) - monkeypatch.setattr(service_manager, "ensure_runtime_dirs", lambda: paths) - monkeypatch.setattr(service_manager, "_recorded_port", lambda _pid_file, default: default) - monkeypatch.setattr( - service_manager, - "stop_one", - lambda port, _pid_file, name, _console: calls.append((f"stop:{name}:{port}", True)), + try: + payload = updater._prepare_upgrade_handover("2026.3.31.1") + + status = service_control.read_supervisor_status(paths) + assert status.backend.paused is True + assert status.webui.paused is True + assert payload["upgrade_server_pid"] == 321 + assert payload["backend_port"] == 9995 + assert payload["frontend_port"] == 9996 + assert updater._read_upgrade_state()["version"] == "2026.3.31.1" + finally: + stop_supervisor(daemon, thread) + shutil.rmtree(short_root, ignore_errors=True) + + +@pytest.mark.skipif(sys.platform == "win32", reason="uses the Unix domain socket control API") +def test_prepare_upgrade_handover_restores_frontend_when_upgrade_page_fails_with_real_control_api( + monkeypatch: pytest.MonkeyPatch, +) -> None: + short_root = make_short_runtime_root("flocks-updater-") + monkeypatch.setenv("FLOCKS_ROOT", str(short_root)) + paths = service_manager.runtime_paths() + config = service_manager.ServiceConfig( + backend_host="127.0.0.1", + backend_port=9995, + frontend_host="127.0.0.1", + frontend_port=9996, ) + daemon, thread = start_supervisor(config) + wait_for_supervisor(paths, running=True) + calls: list[str] = [] - def fake_start_frontend(config, _console) -> None: - calls.append(("start_frontend", config.skip_frontend_build)) - - monkeypatch.setattr(service_manager, "start_frontend", fake_start_frontend) - monkeypatch.setattr(updater, "_stop_upgrade_page_server", lambda **kw: calls.append(("stop_page", True))) + monkeypatch.setattr(updater, "_stop_upgrade_page_server", lambda **_kw: calls.append("stop_page")) monkeypatch.setattr( updater, "_start_upgrade_page_server", lambda _config, _version: (_ for _ in ()).throw(RuntimeError("page failed")), ) - with pytest.raises(RuntimeError, match="page failed"): - updater._prepare_upgrade_handover("2026.3.31.1") - - assert calls == [ - ("stop:WebUI:5173", True), - ("stop_page", True), - ("start_frontend", False), - ] - assert updater._read_upgrade_state() is None + try: + with pytest.raises(RuntimeError, match="page failed"): + updater._prepare_upgrade_handover("2026.3.31.1") + + status = service_control.read_supervisor_status(paths) + assert calls == ["stop_page"] + assert status.backend.paused is False + assert status.backend.pid is not None + assert status.webui.paused is False + assert status.webui.pid is None + assert status.webui.state == "static" + assert updater._read_upgrade_state() is None + finally: + stop_supervisor(daemon, thread) + shutil.rmtree(short_root, ignore_errors=True) + + +@pytest.mark.skipif(sys.platform == "win32", reason="uses the Unix domain socket control API") +def test_rollback_failed_update_resumes_backend_when_handoff_tasks_fail( + monkeypatch: pytest.MonkeyPatch, +) -> None: + short_root = make_short_runtime_root("flocks-updater-") + monkeypatch.setenv("FLOCKS_ROOT", str(short_root)) + paths = service_manager.runtime_paths() + config = service_manager.ServiceConfig( + backend_host="127.0.0.1", + backend_port=9995, + frontend_host="127.0.0.1", + frontend_port=9996, + ) + daemon, thread = start_supervisor(config) + wait_for_supervisor(paths, running=True) + monkeypatch.setattr(updater, "_stop_upgrade_page_server", lambda **_kw: None) + + try: + updater._write_upgrade_state( + { + "version": "2026.4.1", + "backend_host": "127.0.0.1", + "backend_port": 9995, + "frontend_host": "127.0.0.1", + "frontend_port": 9996, + "skip_frontend_build": True, + } + ) + old_backend = daemon.backend.process + assert old_backend is not None + service_control.request_prepare_upgrade(paths=paths) + wait_for_process_exit(old_backend) + + updater._rollback_failed_update(None, short_root / "install", "2026.3.31") + + status = service_control.read_supervisor_status(paths) + assert status.backend.paused is False + assert status.webui.paused is False + assert status.backend.pid is not None + assert status.backend.pid != old_backend.pid + assert status.webui.pid is None + assert status.webui.state == "static" + assert updater._read_upgrade_state() is None + finally: + stop_supervisor(daemon, thread) + shutil.rmtree(short_root, ignore_errors=True) def test_recover_upgrade_state_restarts_frontend_and_clears_marker( @@ -1083,14 +1198,15 @@ def test_recover_upgrade_state_restarts_frontend_and_clears_marker( tmp_path: Path, ) -> None: monkeypatch.setenv("FLOCKS_ROOT", str(tmp_path / ".flocks")) - started: list[tuple[int, bool]] = [] + started: list[tuple[int, bool | None]] = [] stopped: list[str] = [] monkeypatch.setattr(updater, "_stop_upgrade_page_server", lambda **kw: stopped.append("stop")) monkeypatch.setattr( - service_manager, - "start_frontend", - lambda config, _console: started.append((config.frontend_port, config.skip_frontend_build)), + service_control, + "request_resume_upgrade", + lambda config, **_kwargs: started.append((config.frontend_port, config.skip_frontend_build)) + or _webui_control_status(), ) updater._write_upgrade_state( { @@ -1115,16 +1231,25 @@ def test_recover_upgrade_state_retries_frontend_with_build_when_dist_is_missing( tmp_path: Path, ) -> None: monkeypatch.setenv("FLOCKS_ROOT", str(tmp_path / ".flocks")) - starts: list[bool] = [] + starts: list[tuple[str, bool | None, bool | None]] = [] monkeypatch.setattr(updater, "_stop_upgrade_page_server", lambda **kw: None) - def fake_start_frontend(config, _console) -> None: - starts.append(config.skip_frontend_build) - if config.skip_frontend_build: - raise service_manager.ServiceError("missing dist") + results = iter([ + _webui_control_payload("degraded", "missing dist"), + _webui_control_payload(), + ]) + + def fake_resume_upgrade(config, **_kwargs): + starts.append(("resume", config.skip_frontend_build, None)) + return service_control.parse_supervisor_status(next(results)) - monkeypatch.setattr(service_manager, "start_frontend", fake_start_frontend) + def fake_restart_webui(config, *, force_frontend_build=False, **_kwargs): + starts.append(("restart_webui", config.skip_frontend_build, force_frontend_build or None)) + return service_control.parse_supervisor_status(next(results)) + + monkeypatch.setattr(service_control, "request_resume_upgrade", fake_resume_upgrade) + monkeypatch.setattr(service_control, "request_restart_webui", fake_restart_webui) updater._write_upgrade_state( { "version": "2026.3.31.1", @@ -1138,7 +1263,7 @@ def fake_start_frontend(config, _console) -> None: updater.recover_upgrade_state() - assert starts == [True, False] + assert starts == [("resume", True, None), ("restart_webui", False, True)] assert updater._read_upgrade_state() is None @@ -1147,15 +1272,20 @@ def test_recover_upgrade_state_restart_failure_clears_state_without_restarting_p tmp_path: Path, ) -> None: monkeypatch.setenv("FLOCKS_ROOT", str(tmp_path / ".flocks")) - starts: list[bool] = [] + starts: list[tuple[str, bool | None, bool | None]] = [] monkeypatch.setattr(updater, "_stop_upgrade_page_server", lambda **kw: None) - def fake_start_frontend(config, _console) -> None: - starts.append(config.skip_frontend_build) - raise service_manager.ServiceError("still broken") + def fake_resume_upgrade(config, **_kwargs): + starts.append(("resume", config.skip_frontend_build, None)) + return _webui_control_status("degraded", "still broken") + + def fake_restart_webui(config, *, force_frontend_build=False, **_kwargs): + starts.append(("restart_webui", config.skip_frontend_build, force_frontend_build or None)) + return _webui_control_status("degraded", "still broken") - monkeypatch.setattr(service_manager, "start_frontend", fake_start_frontend) + monkeypatch.setattr(service_control, "request_resume_upgrade", fake_resume_upgrade) + monkeypatch.setattr(service_control, "request_restart_webui", fake_restart_webui) updater._write_upgrade_state( { "version": "2026.3.31.1", @@ -1167,10 +1297,10 @@ def fake_start_frontend(config, _console) -> None: } ) - with pytest.raises(service_manager.ServiceError, match="still broken"): + with pytest.raises(RuntimeError, match="still broken"): updater.recover_upgrade_state() - assert starts == [True, False] + assert starts == [("resume", True, None), ("restart_webui", False, True)] assert updater._read_upgrade_state() is None @@ -1220,6 +1350,51 @@ def test_start_upgrade_page_server_binds_configured_frontend_host( assert captured["wait_host"] == "0.0.0.0" +def test_stop_upgrade_page_server_does_not_kill_unified_flocks_service( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + flocks_root = tmp_path / ".flocks" + monkeypatch.setenv("FLOCKS_ROOT", str(flocks_root)) + killed: list[int] = [] + + monkeypatch.setattr(service_manager, "port_owner_pids", lambda _port: [111]) + monkeypatch.setattr( + service_manager, + "_process_command_line", + lambda _pid: "/env/bin/python -m flocks.cli.main serve --host 127.0.0.1 --port 5173", + ) + monkeypatch.setattr(updater.os, "kill", lambda pid, _sig: killed.append(pid)) + + updater._stop_upgrade_page_server(frontend_port=5173) + + assert killed == [] + + +def test_stop_upgrade_page_server_kills_only_upgrade_page_process( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + flocks_root = tmp_path / ".flocks" + page_dir = flocks_root / "run" / "upgrade-page" + monkeypatch.setenv("FLOCKS_ROOT", str(flocks_root)) + killed: list[int] = [] + + def fake_command_line(pid: int) -> str: + if pid == 222: + return f"/env/bin/python -m http.server 5173 --directory {page_dir}" + return "/env/bin/python -m flocks.cli.main serve --host 127.0.0.1 --port 5173" + + monkeypatch.setattr(service_manager, "port_owner_pids", lambda _port: [111, 222]) + monkeypatch.setattr(service_manager, "_process_command_line", fake_command_line) + monkeypatch.setattr(updater.os, "kill", lambda pid, _sig: killed.append(pid)) + monkeypatch.setattr(updater.time, "sleep", lambda _seconds: None) + + updater._stop_upgrade_page_server(frontend_port=5173) + + assert killed == [222] + + def test_wait_for_upgrade_page_uses_access_host_for_local_probe( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -1286,12 +1461,21 @@ def test_rollback_failed_update_restores_backup_and_rebuilds_frontend_if_needed( monkeypatch.setattr(updater, "_stop_upgrade_page_server", lambda **kw: events.append("stop_page")) monkeypatch.setattr(updater.shutil, "rmtree", lambda path, ignore_errors=True: events.append(f"rmtree:{Path(path).name}")) - def fake_start_frontend(config, _console) -> None: - events.append(f"start_frontend:{config.skip_frontend_build}") - if config.skip_frontend_build: - raise service_manager.ServiceError("missing dist") + results = iter([ + _webui_control_payload("degraded", "missing dist"), + _webui_control_payload(), + ]) + + def fake_resume_upgrade(config, **_kwargs) -> service_control.SupervisorStatus: + events.append(f"resume:{config.skip_frontend_build}") + return service_control.parse_supervisor_status(next(results)) - monkeypatch.setattr(service_manager, "start_frontend", fake_start_frontend) + def fake_restart_webui(config, *, force_frontend_build=False, **_kwargs) -> service_control.SupervisorStatus: + events.append(f"restart_webui:{config.skip_frontend_build}:{force_frontend_build or None}") + return service_control.parse_supervisor_status(next(results)) + + monkeypatch.setattr(service_control, "request_resume_upgrade", fake_resume_upgrade) + monkeypatch.setattr(service_control, "request_restart_webui", fake_restart_webui) updater._write_upgrade_state( { "version": "2026.4.1", @@ -1311,8 +1495,8 @@ def fake_start_frontend(config, _console) -> None: "restore:backup.tar.gz:install", "marker:2026.3.31", "stop_page", - "start_frontend:True", - "start_frontend:False", + "resume:True", + "restart_webui:False:True", "rmtree:upgrade-page", ] assert updater._read_upgrade_state() is None @@ -1334,11 +1518,16 @@ def test_rollback_failed_update_clears_state_when_restore_and_frontend_both_fail monkeypatch.setattr(updater, "_stop_upgrade_page_server", lambda **kw: events.append("stop_page")) monkeypatch.setattr(updater.shutil, "rmtree", lambda path, ignore_errors=True: events.append(f"rmtree:{Path(path).name}")) - def fake_start_frontend(config, _console) -> None: - events.append(f"start_frontend:{config.skip_frontend_build}") - raise service_manager.ServiceError("frontend still broken") + def fake_resume_upgrade(config, **_kwargs) -> service_control.SupervisorStatus: + events.append(f"resume:{config.skip_frontend_build}") + return _webui_control_status("degraded", "frontend still broken") + + def fake_restart_webui(config, **_kwargs) -> service_control.SupervisorStatus: + events.append(f"restart_webui:{config.skip_frontend_build}") + return _webui_control_status("degraded", "frontend still broken") - monkeypatch.setattr(service_manager, "start_frontend", fake_start_frontend) + monkeypatch.setattr(service_control, "request_resume_upgrade", fake_resume_upgrade) + monkeypatch.setattr(service_control, "request_restart_webui", fake_restart_webui) updater._write_upgrade_state( { "version": "2026.4.1", @@ -1357,7 +1546,7 @@ def fake_start_frontend(config, _console) -> None: assert events == [ "stop_page", - "start_frontend:True", + "resume:True", "rmtree:upgrade-page", ] assert updater._read_upgrade_state() is None @@ -1513,7 +1702,7 @@ def test_replace_install_dir_copies_dot_flocks_plugins_from_source( @pytest.mark.asyncio -async def test_perform_update_schedules_handoff_after_handover( +async def test_perform_update_schedules_handoff_with_deferred_handover( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: @@ -1566,6 +1755,7 @@ async def fake_sleep(_seconds) -> None: lambda name: "/usr/bin/npm" if name in {"npm", "npm.cmd"} else "/usr/bin/uv", ) monkeypatch.setattr(updater, "_prepare_upgrade_handover", lambda _version: events.append("handover") or {}) + monkeypatch.setattr(updater, "_handoff_service_config", lambda: service_manager.ServiceConfig()) monkeypatch.setattr( updater, "_replace_install_dir", @@ -1576,7 +1766,11 @@ async def fake_sleep(_seconds) -> None: monkeypatch.setattr(updater.asyncio, "sleep", fake_sleep) monkeypatch.setattr(updater, "_rollback_failed_update", lambda *_args: events.append("rollback")) monkeypatch.setattr(updater, "rollback_upgrade_handover", lambda *_args: events.append("rollback_handover")) - monkeypatch.setattr(updater.subprocess, "Popen", lambda argv, **_kwargs: popen_calls.append(list(argv)) or SimpleNamespace(pid=4321)) + monkeypatch.setattr( + updater, + "_spawn_restart_handoff", + lambda argv, **_kwargs: popen_calls.append(list(argv)) or SimpleNamespace(pid=4321), + ) monkeypatch.setattr(updater.os, "_exit", lambda code: (_ for _ in ()).throw(SystemExit(code))) with pytest.raises(SystemExit, match="0"): @@ -1584,22 +1778,33 @@ async def fake_sleep(_seconds) -> None: pass assert events[:2] == ["replace", "sleep"] - assert "handover" in events + assert "handover" not in events assert len(popen_calls) == 1 handoff_argv = popen_calls[0] assert handoff_argv[:3] == ["/usr/bin/python3", "-m", "flocks.updater.restart_handoff"] assert "--uv-path" in handoff_argv assert "--version" in handoff_argv + assert "--prepare-handover" in handoff_argv[: handoff_argv.index("--")] assert handoff_argv[handoff_argv.index("--") + 1 :] == [ "/usr/bin/python3", "-m", "flocks.cli.main", "start", + "--no-browser", + "--skip-webui-build", + "--host", + "127.0.0.1", + "--port", + "5173", + "--server-host", + "127.0.0.1", + "--server-port", + "8000", ] @pytest.mark.asyncio -async def test_perform_update_errors_when_handover_fails_before_frontend_build( +async def test_perform_update_does_not_prepare_handover_before_spawning_handoff( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: @@ -1615,6 +1820,7 @@ async def test_perform_update_errors_when_handover_fails_before_frontend_build( install_root.mkdir() events: list[str] = [] + popen_calls: list[list[str]] = [] async def fake_get_updater_config(): return SimpleNamespace( @@ -1671,12 +1877,20 @@ async def fake_sleep(_seconds) -> None: lambda _version: (_ for _ in ()).throw(RuntimeError("handover boom")), ) monkeypatch.setattr(updater, "_restore_backup_if_possible", lambda *_args: events.append("restore")) + monkeypatch.setattr( + updater, + "_spawn_restart_handoff", + lambda argv, **_kwargs: popen_calls.append(list(argv)) or SimpleNamespace(pid=4321), + ) + monkeypatch.setattr(updater.os, "_exit", lambda code: (_ for _ in ()).throw(SystemExit(code))) - progresses = [step async for step in updater.perform_update("2026.4.1")] + with pytest.raises(SystemExit, match="0"): + async for _step in updater.perform_update("2026.4.1"): + pass - assert progresses[-1].stage == "error" - assert progresses[-1].message == "Failed to prepare WebUI handover: handover boom" - assert events == ["replace", "restore"] + assert events == ["replace"] + assert len(popen_calls) == 1 + assert "--prepare-handover" in popen_calls[0][: popen_calls[0].index("--")] @pytest.mark.asyncio @@ -2710,7 +2924,7 @@ async def fake_run_async(cmd, cwd=None, timeout=None, env=None): @pytest.mark.asyncio -async def test_perform_update_retries_after_windows_file_lock_and_rolls_back_handover_failures( +async def test_perform_update_reports_windows_file_lock_without_stopping_current_backend( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: @@ -2770,24 +2984,25 @@ def fake_replace_install_dir(*_args, **_kwargs): lambda name: "/usr/bin/npm" if name in {"npm", "npm.cmd"} else "/usr/bin/uv", ) monkeypatch.setattr(updater, "_prepare_upgrade_handover", lambda _version: events.append("handover")) + monkeypatch.setattr(updater, "_handoff_service_config", lambda: service_manager.ServiceConfig()) monkeypatch.setattr(updater, "_replace_install_dir", fake_replace_install_dir) monkeypatch.setattr(updater, "_rollback_failed_update", lambda *_args: events.append("rollback")) monkeypatch.setattr(updater, "_restore_backup_if_possible", lambda *_args: events.append("restore")) monkeypatch.setattr(updater, "_build_restart_argv", lambda install_root=None: [r"C:\tool\python.exe", "-m", "flocks.cli.main", "start"]) - monkeypatch.setattr(updater.subprocess, "Popen", lambda *_args, **_kwargs: events.append("popen") or SimpleNamespace(pid=4321)) + monkeypatch.setattr( + updater, + "_spawn_restart_handoff", + lambda *_args, **_kwargs: events.append("popen") or SimpleNamespace(pid=4321), + ) monkeypatch.setattr(updater.os, "_exit", lambda code: (_ for _ in ()).throw(SystemExit(code))) - with pytest.raises(SystemExit, match="0"): - async for _step in updater.perform_update("2026.4.1"): - pass + progresses = [step async for step in updater.perform_update("2026.4.1")] - assert events == [ - "replace-1", - "handover", - "replace-2", - "popen", - ] - assert "restore" not in events + assert progresses[-1].stage == "error" + assert "WinError 32" in progresses[-1].message + assert events == ["replace-1", "restore"] + assert "handover" not in events + assert "popen" not in events @pytest.mark.asyncio @@ -3017,7 +3232,7 @@ async def test_perform_update_spawns_restart_process_on_windows( (staged_webui / "dist").mkdir() (staged_webui / "dist" / "index.html").write_text("", encoding="utf-8") - popen_calls: list[tuple[list[str], Path, bool]] = [] + popen_calls: list[tuple[list[str], Path]] = [] events: list[str] = [] async def fake_get_updater_config(): @@ -3057,7 +3272,12 @@ async def fake_run_async(cmd, cwd=None, timeout=None, env=None): monkeypatch.setattr(updater, "_refresh_global_cli_entry", lambda _root: None) monkeypatch.setattr(updater, "_build_restart_argv", lambda install_root=None: [r"C:\tool\python.exe", "-m", "flocks.cli.main", "start"]) monkeypatch.setattr(updater, "_prepare_upgrade_handover", lambda _version: events.append("handover")) - monkeypatch.setattr(updater.subprocess, "Popen", lambda argv, cwd=None, close_fds=False: popen_calls.append((list(argv), cwd, close_fds)) or SimpleNamespace(pid=4321)) + monkeypatch.setattr(updater, "_handoff_service_config", lambda: service_manager.ServiceConfig()) + monkeypatch.setattr( + updater, + "_spawn_restart_handoff", + lambda argv, cwd=None: popen_calls.append((list(argv), cwd)) or SimpleNamespace(pid=4321), + ) monkeypatch.setattr(updater.os, "_exit", lambda code: (_ for _ in ()).throw(SystemExit(code))) monkeypatch.setattr(updater.os, "execv", lambda *_args: events.append("execv")) @@ -3066,19 +3286,29 @@ async def fake_run_async(cmd, cwd=None, timeout=None, env=None): pass assert len(popen_calls) == 1 - handoff_argv, cwd, close_fds = popen_calls[0] + handoff_argv, cwd = popen_calls[0] assert cwd == tmp_path / "install-root" - assert close_fds is True assert handoff_argv[:3] == [r"C:\tool\python.exe", "-m", "flocks.updater.restart_handoff"] assert "--parent-pid" in handoff_argv assert "--backend-port" in handoff_argv + assert "--prepare-handover" in handoff_argv[: handoff_argv.index("--")] assert handoff_argv[handoff_argv.index("--") + 1 :] == [ r"C:\tool\python.exe", "-m", "flocks.cli.main", "start", + "--no-browser", + "--skip-webui-build", + "--host", + "127.0.0.1", + "--port", + "5173", + "--server-host", + "127.0.0.1", + "--server-port", + "8000", ] - assert events == ["handover"] + assert events == [] assert "execv" not in events @@ -3259,10 +3489,11 @@ async def fake_run_async(cmd, cwd=None, timeout=None, env=None): monkeypatch.setattr(updater, "_refresh_global_cli_entry", lambda _root: None) monkeypatch.setattr(updater, "_build_restart_argv", lambda install_root=None: [r"C:\tool\python.exe", "-m", "flocks.cli.main"]) monkeypatch.setattr(updater, "_prepare_upgrade_handover", lambda _version: events.append("handover")) + monkeypatch.setattr(updater, "_handoff_service_config", lambda: service_manager.ServiceConfig()) monkeypatch.setattr(updater, "rollback_upgrade_handover", lambda: events.append("rollback_handover")) monkeypatch.setattr( - updater.subprocess, - "Popen", + updater, + "_spawn_restart_handoff", lambda *_args, **_kwargs: (_ for _ in ()).throw(OSError("spawn failed")), ) @@ -3270,4 +3501,5 @@ async def fake_run_async(cmd, cwd=None, timeout=None, env=None): assert progresses[-1].stage == "error" assert "Failed to restart service" in progresses[-1].message - assert "rollback_handover" in events + assert "handover" not in events + assert "rollback_handover" not in events diff --git a/tests/updater/test_updater_console_manifest_bundle.py b/tests/updater/test_updater_console_manifest_bundle.py index 518f3b862..118c75f55 100644 --- a/tests/updater/test_updater_console_manifest_bundle.py +++ b/tests/updater/test_updater_console_manifest_bundle.py @@ -15,6 +15,9 @@ async def test_fetch_console_manifest_release_uses_bundle_url(monkeypatch: pytes from flocks.storage.storage import Storage monkeypatch.setenv("FLOCKS_ROOT", str(tmp_path)) + license_path = tmp_path / "flockspro" / "license.json" + license_path.parent.mkdir(parents=True) + license_path.write_text('{"license_id": "lic_manifest"}', encoding="utf-8") await Storage.set("console:session", {"console_session_token": "cs_manifest"}, "json") class _Resp: @@ -23,11 +26,11 @@ def raise_for_status(self) -> None: def json(self) -> dict: return { - "display_version": "v2026.5.10", + "bundle_version": "v2026.5.10", "compare_version": "2026.5.10", "bundle_url": "https://cdn.example.com/flockspro-bundle-v2026.5.10.tar.gz", "bundle_sha256": "abc123", - "oss_version": "v2026.5.10", + "core_version": "v2026.5.10", "flockspro_component_version": "pro-v2026-5-10", "release_notes": "bundle release", } @@ -41,7 +44,11 @@ async def __aexit__(self, exc_type, exc, tb): async def get(self, url, headers=None, follow_redirects=True): assert "channel=flockspro" in url - assert headers == {"Authorization": "Bearer cs_manifest"} + assert "license_id=lic_manifest" in url + assert headers == { + "x-license-id": "lic_manifest", + "Authorization": "Bearer cs_manifest", + } return _Resp() monkeypatch.setenv("FLOCKS_CONSOLE_BASE_URL", "https://console.example.com") @@ -68,7 +75,8 @@ async def test_check_update_uses_pro_marker_bundle_version_and_component_metadat marker.parent.mkdir(parents=True) marker.write_text( """{ - "installed_version": "v2026.5.23", + "bundle_version": "v2026.5.23", + "core_version": "v2026.5.23", "flockspro_component_version": "pro-v2026-05-23" }""", encoding="utf-8", @@ -86,7 +94,8 @@ async def _fake_manifest_info(): bundle_sha256=None, bundle_format="zip", manifest={ - "display_version": "v2026.5.23", + "bundle_version": "v2026.5.23", + "core_version": "v2026.5.23", "flockspro_component_version": "pro-v2026-05-23", }, ) @@ -119,7 +128,8 @@ async def test_check_update_force_console_manifest_uses_bundle_versions(monkeypa marker.parent.mkdir(parents=True) marker.write_text( """{ - "installed_version": "v2026.5.23", + "bundle_version": "v2026.5.23", + "core_version": "v2026.5.23", "flockspro_component_version": "pro-v2026-05-23" }""", encoding="utf-8", @@ -137,7 +147,7 @@ async def _fake_manifest_info(): bundle_sha256="abc123", bundle_format="zip", manifest={ - "display_version": "v2026.5.24", + "bundle_version": "v2026.5.24", "core_version": "v2026.5.23", "flockspro_component_version": "pro-v2026-05-24", }, @@ -173,7 +183,8 @@ async def test_check_update_force_console_manifest_detects_component_only_update marker.parent.mkdir(parents=True) marker.write_text( """{ - "installed_version": "v2026.6.18", + "bundle_version": "v2026.6.18", + "core_version": "v2026.6.18", "flockspro_component_version": "v2026.6.1" }""", encoding="utf-8", @@ -191,8 +202,8 @@ async def _fake_manifest_info(): bundle_sha256="def456", bundle_format="zip", manifest={ - "display_version": "v2026.6.18", - "oss_version": "v2026.6.18", + "bundle_version": "v2026.6.18", + "core_version": "v2026.6.18", "flockspro_component_version": "v2026.6.2", }, ) @@ -220,7 +231,7 @@ async def test_check_update_force_console_manifest_reports_stale_product_marker_ marker.parent.mkdir(parents=True) marker.write_text( """{ - "installed_version": "v2026.6.22", + "bundle_version": "v2026.6.22", "core_version": "v2026.6.21", "flockspro_component_version": "v2026.6.23" }""", @@ -239,7 +250,7 @@ async def _fake_manifest_info(): bundle_sha256="ghi789", bundle_format="zip", manifest={ - "display_version": "v2026.6.23", + "bundle_version": "v2026.6.23", "core_version": "v2026.6.21", "flockspro_component_version": "v2026.6.23", }, @@ -264,6 +275,54 @@ async def _fake_manifest_info(): assert info.has_update is True +@pytest.mark.asyncio +async def test_check_update_trusts_pro_marker_core_when_global_marker_is_stale( + monkeypatch: pytest.MonkeyPatch, + tmp_path, +) -> None: + marker = tmp_path / "run" / "pro-bundle-installed.json" + marker.parent.mkdir(parents=True) + marker.write_text( + """{ + "bundle_version": "v2026.7.5", + "core_version": "v2026.7.4", + "flockspro_component_version": "v2026.7.4" +}""", + encoding="utf-8", + ) + + async def _fake_config(): + return SimpleNamespace(enabled=True, sources=["github"], repo="", token=None) + + async def _fake_manifest_info(): + return updater.ConsoleManifestRelease( + version="v2026.7.5", + release_notes="latest pro", + release_url="https://console.example.com/v1/pro-bundles/rel_75/download", + bundle_url="https://console.example.com/v1/pro-bundles/rel_75/download", + bundle_sha256="sha75", + bundle_format="zip", + manifest={ + "bundle_version": "v2026.7.5", + "core_version": "v2026.7.4", + "flockspro_component_version": "v2026.7.4", + }, + ) + + monkeypatch.setenv("FLOCKS_ROOT", str(tmp_path)) + monkeypatch.setattr("flocks.updater.deploy.detect_deploy_mode", lambda: "source") + monkeypatch.setattr(updater, "_get_updater_config", _fake_config) + monkeypatch.setattr(updater, "_fetch_console_manifest_release_info", _fake_manifest_info) + monkeypatch.setattr(updater, "get_current_version", lambda: "2026.7.5") + + info = await updater.check_update(force_console_manifest=True) + + assert info.current_version == "v2026.7.5" + assert info.current_bundle_version == "v2026.7.5" + assert info.current_core_version == "v2026.7.4" + assert info.latest_core_version == "v2026.7.4" + + def test_console_manifest_release_identity_writes_product_and_core_versions( monkeypatch: pytest.MonkeyPatch, tmp_path, @@ -271,29 +330,219 @@ def test_console_manifest_release_identity_writes_product_and_core_versions( monkeypatch.setenv("FLOCKS_ROOT", str(tmp_path)) merged = updater._merge_console_manifest_release_identity( { - "display_version": "v2026.6.21", + "bundle_version": "v2026.6.21", "core_version": "v2026.6.21", "flockspro_component_version": "v2026.6.23", }, { "release_id": "rel_623", - "display_version": "v2026.6.23", + "bundle_version": "v2026.6.23", "core_version": "v2026.6.21", "flockspro_component_version": "v2026.6.23", "build_id": "job_623", }, ) - assert merged["display_version"] == "v2026.6.23" + assert merged["bundle_version"] == "v2026.6.23" assert merged["core_version"] == "v2026.6.21" updater._write_pro_bundle_install_marker(merged, bundle_sha256="sha623") marker = json.loads((tmp_path / "run" / "pro-bundle-installed.json").read_text(encoding="utf-8")) - assert marker["installed_version"] == "v2026.6.23" + assert marker["bundle_version"] == "v2026.6.23" assert marker["core_version"] == "v2026.6.21" - assert marker["oss_version"] == "v2026.6.21" assert marker["flockspro_component_version"] == "v2026.6.23" assert marker["build_id"] == "job_623" + pending = json.loads((tmp_path / "run" / "pro-bundle-install-receipt-pending.json").read_text(encoding="utf-8")) + assert pending["install_result"] == "success" + assert pending["bundle_version"] == "v2026.6.23" + assert pending["core_version"] == "v2026.6.21" + assert pending["flockspro_component_version"] == "v2026.6.23" + assert "version_info" not in pending + + +def test_pro_bundle_install_marker_requires_all_runtime_versions( + monkeypatch: pytest.MonkeyPatch, + tmp_path, +) -> None: + monkeypatch.setenv("FLOCKS_ROOT", str(tmp_path)) + + with pytest.raises(ValueError, match="core_version"): + updater._write_pro_bundle_install_marker( + { + "bundle_version": "v2026.7.5", + "flockspro_component_version": "v2026.7.4", + } + ) + + assert not (tmp_path / "run" / "pro-bundle-installed.json").exists() + + +@pytest.mark.asyncio +async def test_uninstall_pro_component_uses_uv_uninstall_without_yes_flag( + monkeypatch: pytest.MonkeyPatch, + tmp_path, +) -> None: + install_root = tmp_path / "install" + python_path = updater._venv_python_path(install_root) + python_path.parent.mkdir(parents=True) + python_path.write_text("#!/usr/bin/env python\n", encoding="utf-8") + captured: dict[str, object] = {} + + async def _fake_run_async(cmd, cwd=None, timeout=None, env=None): + captured["cmd"] = cmd + captured["cwd"] = cwd + captured["timeout"] = timeout + captured["env"] = env + return 0, "", "" + + monkeypatch.setattr(updater, "_run_async", _fake_run_async) + + error = await updater._uninstall_pro_component( + uv_path="/usr/bin/uv", + install_root=install_root, + env={"UV_NO_PROGRESS": "1"}, + ) + + assert error is None + assert captured["cmd"] == ["/usr/bin/uv", "pip", "uninstall", "--python", str(python_path), "flockspro"] + assert "-y" not in captured["cmd"] + assert captured["cwd"] == install_root + assert captured["timeout"] == 180 + assert captured["env"] == {"UV_NO_PROGRESS": "1"} + + +@pytest.mark.asyncio +async def test_perform_pro_bundle_downgrade_archives_pending_install_receipt( + monkeypatch: pytest.MonkeyPatch, + tmp_path, +) -> None: + from flocks.updater import deploy as deploy_mod + + flocks_root = tmp_path / "flocks-root" + run_dir = flocks_root / "run" + run_dir.mkdir(parents=True) + pending_receipt = run_dir / "pro-bundle-install-receipt-pending.json" + pending_receipt.write_text( + json.dumps( + { + "install_result": "success", + "bundle_version": "v2026.6.24", + "core_version": "v2026.6.21", + "flockspro_component_version": "v2026.6.24-pro", + } + ), + encoding="utf-8", + ) + install_marker = run_dir / "pro-bundle-installed.json" + install_marker.write_text( + json.dumps( + { + "bundle_version": "v2026.6.24", + "core_version": "v2026.6.21", + "flockspro_component_version": "v2026.6.24-pro", + "installed_at": "2026-06-24T08:00:00+00:00", + } + ), + encoding="utf-8", + ) + + uninstall_calls: list[tuple[str, str]] = [] + report_calls: list[str] = [] + version_writes: list[str] = [] + + async def _fake_uninstall_pro_component(*, uv_path, install_root, env): + uninstall_calls.append((uv_path, str(install_root))) + return None + + async def _after_uninstall(): + report_calls.append("reported") + + monkeypatch.setenv("FLOCKS_ROOT", str(flocks_root)) + monkeypatch.setattr(deploy_mod, "detect_deploy_mode", lambda: "source") + monkeypatch.setattr(updater, "_get_repo_root", lambda: tmp_path / "install-root") + monkeypatch.setattr(updater, "get_current_version", lambda: "2026.6.24") + monkeypatch.setattr(updater, "_is_pro_component_installed", lambda: True) + monkeypatch.setattr(updater, "_find_executable", lambda name: "/usr/bin/uv" if name == "uv" else None) + monkeypatch.setattr(updater, "_uninstall_pro_component", _fake_uninstall_pro_component) + monkeypatch.setattr(updater, "_write_version_marker", lambda version: version_writes.append(version)) + monkeypatch.setattr(updater, "_refresh_global_cli_entry", lambda _install_root: None) + + progresses = [ + step + async for step in updater.perform_pro_bundle_downgrade( + restart=False, + reason="user_requested", + after_uninstall=_after_uninstall, + ) + ] + + assert progresses[-1].stage == "done" + assert uninstall_calls == [("/usr/bin/uv", str(tmp_path / "install-root"))] + assert report_calls == ["reported"] + assert version_writes == ["2026.6.21"] + assert not pending_receipt.exists() + assert not install_marker.exists() + archived_pending = list((run_dir / "archive").glob("pro-bundle-install-receipt-pending-*.json")) + assert len(archived_pending) == 1 + archived_payload = json.loads(archived_pending[0].read_text(encoding="utf-8")) + assert archived_payload["install_result"] == "success" + assert archived_payload["archive_reason"] == "user_requested" + archived_marker = list((run_dir / "archive").glob("pro-bundle-installed-*.json")) + assert len(archived_marker) == 1 + + +@pytest.mark.asyncio +async def test_perform_pro_bundle_downgrade_continues_when_report_callback_fails( + monkeypatch: pytest.MonkeyPatch, + tmp_path, +) -> None: + from flocks.updater import deploy as deploy_mod + + flocks_root = tmp_path / "flocks-root" + run_dir = flocks_root / "run" + run_dir.mkdir(parents=True) + install_marker = run_dir / "pro-bundle-installed.json" + install_marker.write_text( + json.dumps( + { + "bundle_version": "v2026.6.24", + "core_version": "v2026.6.21", + "flockspro_component_version": "v2026.6.24-pro", + "installed_at": "2026-06-24T08:00:00+00:00", + } + ), + encoding="utf-8", + ) + + async def _fake_uninstall_pro_component(*, uv_path, install_root, env): + return None + + async def _after_uninstall(): + raise RuntimeError("console unavailable") + + monkeypatch.setenv("FLOCKS_ROOT", str(flocks_root)) + monkeypatch.setattr(deploy_mod, "detect_deploy_mode", lambda: "source") + monkeypatch.setattr(updater, "_get_repo_root", lambda: tmp_path / "install-root") + monkeypatch.setattr(updater, "get_current_version", lambda: "2026.6.21") + monkeypatch.setattr(updater, "_is_pro_component_installed", lambda: True) + monkeypatch.setattr(updater, "_find_executable", lambda name: "/usr/bin/uv" if name == "uv" else None) + monkeypatch.setattr(updater, "_uninstall_pro_component", _fake_uninstall_pro_component) + monkeypatch.setattr(updater, "_refresh_global_cli_entry", lambda _install_root: None) + + progresses = [ + step + async for step in updater.perform_pro_bundle_downgrade( + restart=False, + reason="user_requested", + after_uninstall=_after_uninstall, + ) + ] + + assert [step.stage for step in progresses] == ["checking", "downgrading", "reporting", "done"] + assert progresses[-1].success is True + assert not install_marker.exists() + archived_marker = list((run_dir / "archive").glob("pro-bundle-installed-*.json")) + assert len(archived_marker) == 1 @pytest.mark.asyncio @@ -358,7 +607,7 @@ def raise_for_status(self) -> None: def json(self) -> dict: return { - "display_version": "v2026.5.10", + "bundle_version": "v2026.5.10", "bundle_url": "https://cdn.example.com/flockspro-bundle-v2026.5.10.tar.gz", "frozen": True, } @@ -577,8 +826,8 @@ async def test_perform_pro_bundle_install_replaces_core_and_installs_wheel( wheel.write_bytes(b"fake-wheel") (bundle_root / "manifest.json").write_text( """{ - "display_version": "v2026.5.10", - "oss_version": "v2026.5.10", + "bundle_version": "v2026.5.11", + "core_version": "v2026.5.10", "flockspro_component_version": "pro-v2026-5-10", "flockspro_wheel": "wheels/flockspro-0.1.0-py3-none-any.whl", "build_id": "job_test" @@ -600,12 +849,30 @@ async def test_perform_pro_bundle_install_replaces_core_and_installs_wheel( monkeypatch.setenv("FLOCKS_ROOT", str(tmp_path / "flocks-root")) monkeypatch.setattr(updater, "_get_repo_root", lambda: install_root) monkeypatch.setattr(updater, "get_current_version", lambda: "2026.5.10") - monkeypatch.setattr(updater, "_fetch_console_manifest_release_info", lambda: _async_manifest_info(bundle)) + + async def _fake_manifest_info(): + return updater.ConsoleManifestRelease( + version="v2026.5.11", + release_notes=None, + release_url=str(bundle), + bundle_url=str(bundle), + bundle_sha256=None, + bundle_format="zip", + manifest={ + "bundle_version": "v2026.5.11", + "core_version": "v2026.5.10", + "flockspro_component_version": "pro-v2026-5-10", + "build_id": "job_test", + }, + ) + + monkeypatch.setattr(updater, "_fetch_console_manifest_release_info", _fake_manifest_info) monkeypatch.setattr(updater, "_download_console_bundle", lambda *_args, **_kwargs: _async_path(bundle)) monkeypatch.setattr(updater, "_verify_download_sha256", lambda *_args, **_kwargs: None) monkeypatch.setattr(updater, "_find_executable", lambda name: "/usr/bin/uv" if name == "uv" else None) monkeypatch.setattr(updater, "_backup_current_version", lambda *_args, **_kwargs: tmp_path / "backup.tar.gz") - monkeypatch.setattr(updater, "_write_version_marker", lambda *_args, **_kwargs: None) + version_writes: list[str] = [] + monkeypatch.setattr(updater, "_write_version_marker", lambda version: version_writes.append(version)) monkeypatch.setattr(updater, "_refresh_global_cli_entry", lambda *_args, **_kwargs: None) captured: list[list[str]] = [] @@ -628,12 +895,13 @@ async def _fake_run_async(cmd, **_kwargs): marker = tmp_path / "flocks-root" / "run" / "pro-bundle-installed.json" assert marker.is_file() marker_payload = __import__("json").loads(marker.read_text(encoding="utf-8")) - assert marker_payload["display_version"] == "v2026.5.10" - assert marker_payload["oss_version"] == "v2026.5.10" + assert version_writes == ["2026.5.10"] + assert marker_payload["bundle_version"] == "v2026.5.11" + assert marker_payload["core_version"] == "v2026.5.10" @pytest.mark.asyncio -async def test_perform_pro_bundle_install_keeps_newer_local_core_when_bundle_oss_is_older( +async def test_perform_pro_bundle_install_keeps_newer_local_core_when_bundle_core_is_older( monkeypatch: pytest.MonkeyPatch, tmp_path, ) -> None: @@ -648,8 +916,8 @@ async def test_perform_pro_bundle_install_keeps_newer_local_core_when_bundle_oss wheel.write_bytes(b"fake-wheel") (bundle_root / "manifest.json").write_text( """{ - "display_version": "v2026.6.13", - "oss_version": "v2026.6.13", + "bundle_version": "v2026.6.13", + "core_version": "v2026.6.13", "flockspro_component_version": "v2026.6.2", "flockspro_wheel": "wheels/flockspro-0.2.0-py3-none-any.whl", "build_id": "job_new_pro_old_core" @@ -683,8 +951,8 @@ async def _fake_manifest_info(): bundle_format="zip", manifest={ "release_id": "rel_new_pro_old_core", - "display_version": "v2026.6.13", - "oss_version": "v2026.6.13", + "bundle_version": "v2026.6.13", + "core_version": "v2026.6.13", "flockspro_component_version": "v2026.6.2", "build_id": "job_new_pro_old_core", }, @@ -718,10 +986,8 @@ async def _fake_run_async(cmd, **_kwargs): marker = tmp_path / "flocks-root" / "run" / "pro-bundle-installed.json" marker_payload = __import__("json").loads(marker.read_text(encoding="utf-8")) assert marker_payload["release_id"] == "rel_new_pro_old_core" - assert marker_payload["display_version"] == "v2026.6.13" - assert marker_payload["installed_version"] == "v2026.6.13" + assert marker_payload["bundle_version"] == "v2026.6.13" assert marker_payload["core_version"] == "v2026.6.18" - assert marker_payload["oss_version"] == "v2026.6.18" assert marker_payload["flockspro_component_version"] == "v2026.6.2" @@ -740,8 +1006,8 @@ async def test_perform_pro_bundle_install_schedules_restart_before_stream_can_cl wheel.write_bytes(b"fake-wheel") (bundle_root / "manifest.json").write_text( """{ - "display_version": "v2026.5.10", - "oss_version": "v2026.5.10", + "bundle_version": "v2026.5.10", + "core_version": "v2026.5.10", "flockspro_component_version": "pro-v2026-5-10", "flockspro_wheel": "wheels/flockspro-0.1.0-py3-none-any.whl", "build_id": "job_test" @@ -792,8 +1058,8 @@ async def _async_manifest_info(bundle): bundle_sha256=None, bundle_format="zip", manifest={ - "display_version": "v2026.5.10", - "oss_version": "v2026.5.10", + "bundle_version": "v2026.5.10", + "core_version": "v2026.5.10", "flockspro_component_version": "pro-v2026-5-10", "build_id": "job_test", }, diff --git a/tests/updater/test_updater_edition_sources.py b/tests/updater/test_updater_edition_sources.py index 2798ed541..a58b33ac9 100644 --- a/tests/updater/test_updater_edition_sources.py +++ b/tests/updater/test_updater_edition_sources.py @@ -10,7 +10,8 @@ async def test_installed_pro_bundle_marker_without_active_license_keeps_oss_sour marker.parent.mkdir(parents=True) marker.write_text( """{ - "installed_version": "v2026.5.23", + "bundle_version": "v2026.5.23", + "core_version": "v2026.5.23", "flockspro_component_version": "pro-v2026-05-23" }""", encoding="utf-8", diff --git a/tests/workflow/test_loop_host_forensics_fast_workflow.py b/tests/workflow/test_loop_host_forensics_fast_workflow.py index 678d06baa..6f5ec5c74 100644 --- a/tests/workflow/test_loop_host_forensics_fast_workflow.py +++ b/tests/workflow/test_loop_host_forensics_fast_workflow.py @@ -19,6 +19,12 @@ def _load_workflow() -> dict: return json.loads(WORKFLOW_PATH.read_text(encoding="utf-8")) +def test_workflow_exposes_chinese_name() -> None: + workflow = _load_workflow() + + assert workflow["nameCn"] == "批量主机快速巡检工作流" + + def test_inspect_host_extracts_verdict_into_lightweight_result(tmp_path: Path) -> None: workflow = _load_workflow() inspect_host = next(node for node in workflow["nodes"] if node["id"] == "inspect_host") diff --git a/tests/workflow/test_ndr_alert_triage_workflow.py b/tests/workflow/test_ndr_alert_triage_workflow.py index 1a61a6497..3ea5770a1 100644 --- a/tests/workflow/test_ndr_alert_triage_workflow.py +++ b/tests/workflow/test_ndr_alert_triage_workflow.py @@ -16,6 +16,12 @@ def _load_workflow() -> dict: return json.loads(WORKFLOW_PATH.read_text(encoding="utf-8")) +def test_tdp_alert_triage_exposes_chinese_name() -> None: + workflow = _load_workflow() + + assert workflow["nameCn"] == "TDP 告警调查工作流" + + def test_tdp_alert_triage_has_join_before_report() -> None: workflow = _load_workflow() join_node = next(node for node in workflow["nodes"] if node["id"] == "join_results") diff --git a/tests/workflow/test_repl_runtime_security.py b/tests/workflow/test_repl_runtime_security.py new file mode 100644 index 000000000..1c0d455a0 --- /dev/null +++ b/tests/workflow/test_repl_runtime_security.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +import pytest + +from flocks.workflow.repl_runtime import PythonExecRuntime + + +def test_python_exec_runtime_allows_installed_requirement_imports() -> None: + pytest.importorskip("pydantic") + pytest.importorskip("yaml") + pytest.importorskip("httpx") + + outputs, _stdout = PythonExecRuntime().execute( + "\n".join( + [ + "import httpx", + "import pydantic", + "import yaml", + "outputs['ok'] = bool(httpx and pydantic and yaml)", + ] + ), + {}, + ) + + assert outputs == {"ok": True} diff --git a/tests/workflow/test_workflow_template_sandbox.py b/tests/workflow/test_workflow_template_sandbox.py new file mode 100644 index 000000000..12f141508 --- /dev/null +++ b/tests/workflow/test_workflow_template_sandbox.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import pytest + +from flocks.workflow.engine import WorkflowEngine +from flocks.workflow.errors import NodeExecutionError +from flocks.workflow.models import Node, Workflow + + +def _engine() -> WorkflowEngine: + workflow = Workflow.from_dict( + { + "start": "start", + "nodes": [{"id": "start", "type": "python", "code": "outputs['ok'] = True"}], + "edges": [], + } + ) + return WorkflowEngine(workflow) + + +def test_llm_node_prompt_uses_jinja_sandbox() -> None: + node = Node.model_validate( + { + "id": "llm", + "type": "llm", + "prompt": "{{ ''.__class__.__mro__ }}", + } + ) + + with pytest.raises(NodeExecutionError, match="Prompt template render failed"): + _engine()._execute_llm_node(node, {}) + + +def test_http_request_node_url_uses_jinja_sandbox() -> None: + node = Node.model_validate( + { + "id": "http", + "type": "http_request", + "method": "GET", + "url": "{{ ''.__class__.__mro__ }}", + } + ) + + with pytest.raises(NodeExecutionError, match="HTTP request template render failed"): + _engine()._execute_http_request_node(node, {}) diff --git a/uv.lock b/uv.lock index e13b4c58e..95cf9d106 100644 --- a/uv.lock +++ b/uv.lock @@ -9,25 +9,25 @@ resolution-markers = [ [[package]] name = "aiofiles" version = "25.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/41/c3/534eac40372d8ee36ef40df62ec129bee4fdb5ad9706e58a29be53b2c970/aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2", size = 46354, upload-time = "2025-10-09T20:51:04.358Z" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/41/c3/534eac40372d8ee36ef40df62ec129bee4fdb5ad9706e58a29be53b2c970/aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bc/8a/340a1555ae33d7354dbca4faa54948d76d89a27ceef032c8c3bc661d003e/aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695", size = 14668, upload-time = "2025-10-09T20:51:03.174Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bc/8a/340a1555ae33d7354dbca4faa54948d76d89a27ceef032c8c3bc661d003e/aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695" }, ] [[package]] name = "aiohappyeyeballs" version = "2.6.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8" }, ] [[package]] name = "aiohttp" version = "3.13.5" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "aiohappyeyeballs" }, { name = "aiosignal" }, @@ -37,90 +37,90 @@ dependencies = [ { name = "propcache" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271, upload-time = "2026-03-31T22:01:03.343Z" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1" } wheels = [ - { url = "https://files.pythonhosted.org/packages/be/6f/353954c29e7dcce7cf00280a02c75f30e133c00793c7a2ed3776d7b2f426/aiohttp-3.13.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:023ecba036ddd840b0b19bf195bfae970083fd7024ce1ac22e9bba90464620e9", size = 748876, upload-time = "2026-03-31T21:57:36.319Z" }, - { url = "https://files.pythonhosted.org/packages/f5/1b/428a7c64687b3b2e9cd293186695affc0e1e54a445d0361743b231f11066/aiohttp-3.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15c933ad7920b7d9a20de151efcd05a6e38302cbf0e10c9b2acb9a42210a2416", size = 499557, upload-time = "2026-03-31T21:57:38.236Z" }, - { url = "https://files.pythonhosted.org/packages/29/47/7be41556bfbb6917069d6a6634bb7dd5e163ba445b783a90d40f5ac7e3a7/aiohttp-3.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab2899f9fa2f9f741896ebb6fa07c4c883bfa5c7f2ddd8cf2aafa86fa981b2d2", size = 500258, upload-time = "2026-03-31T21:57:39.923Z" }, - { url = "https://files.pythonhosted.org/packages/67/84/c9ecc5828cb0b3695856c07c0a6817a99d51e2473400f705275a2b3d9239/aiohttp-3.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60eaa2d440cd4707696b52e40ed3e2b0f73f65be07fd0ef23b6b539c9c0b0b4", size = 1749199, upload-time = "2026-03-31T21:57:41.938Z" }, - { url = "https://files.pythonhosted.org/packages/f0/d3/3c6d610e66b495657622edb6ae7c7fd31b2e9086b4ec50b47897ad6042a9/aiohttp-3.13.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:55b3bdd3292283295774ab585160c4004f4f2f203946997f49aac032c84649e9", size = 1721013, upload-time = "2026-03-31T21:57:43.904Z" }, - { url = "https://files.pythonhosted.org/packages/49/a0/24409c12217456df0bae7babe3b014e460b0b38a8e60753d6cb339f6556d/aiohttp-3.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2b2355dc094e5f7d45a7bb262fe7207aa0460b37a0d87027dcf21b5d890e7d5", size = 1781501, upload-time = "2026-03-31T21:57:46.285Z" }, - { url = "https://files.pythonhosted.org/packages/98/9d/b65ec649adc5bccc008b0957a9a9c691070aeac4e41cea18559fef49958b/aiohttp-3.13.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b38765950832f7d728297689ad78f5f2cf79ff82487131c4d26fe6ceecdc5f8e", size = 1878981, upload-time = "2026-03-31T21:57:48.734Z" }, - { url = "https://files.pythonhosted.org/packages/57/d8/8d44036d7eb7b6a8ec4c5494ea0c8c8b94fbc0ed3991c1a7adf230df03bf/aiohttp-3.13.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b18f31b80d5a33661e08c89e202edabf1986e9b49c42b4504371daeaa11b47c1", size = 1767934, upload-time = "2026-03-31T21:57:51.171Z" }, - { url = "https://files.pythonhosted.org/packages/31/04/d3f8211f273356f158e3464e9e45484d3fb8c4ce5eb2f6fe9405c3273983/aiohttp-3.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:33add2463dde55c4f2d9635c6ab33ce154e5ecf322bd26d09af95c5f81cfa286", size = 1566671, upload-time = "2026-03-31T21:57:53.326Z" }, - { url = "https://files.pythonhosted.org/packages/41/db/073e4ebe00b78e2dfcacff734291651729a62953b48933d765dc513bf798/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:327cc432fdf1356fb4fbc6fe833ad4e9f6aacb71a8acaa5f1855e4b25910e4a9", size = 1705219, upload-time = "2026-03-31T21:57:55.385Z" }, - { url = "https://files.pythonhosted.org/packages/48/45/7dfba71a2f9fd97b15c95c06819de7eb38113d2cdb6319669195a7d64270/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7c35b0bf0b48a70b4cb4fc5d7bed9b932532728e124874355de1a0af8ec4bc88", size = 1743049, upload-time = "2026-03-31T21:57:57.341Z" }, - { url = "https://files.pythonhosted.org/packages/18/71/901db0061e0f717d226386a7f471bb59b19566f2cae5f0d93874b017271f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:df23d57718f24badef8656c49743e11a89fd6f5358fa8a7b96e728fda2abf7d3", size = 1749557, upload-time = "2026-03-31T21:57:59.626Z" }, - { url = "https://files.pythonhosted.org/packages/08/d5/41eebd16066e59cd43728fe74bce953d7402f2b4ddfdfef2c0e9f17ca274/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:02e048037a6501a5ec1f6fc9736135aec6eb8a004ce48838cb951c515f32c80b", size = 1558931, upload-time = "2026-03-31T21:58:01.972Z" }, - { url = "https://files.pythonhosted.org/packages/30/e6/4a799798bf05740e66c3a1161079bda7a3dd8e22ca392481d7a7f9af82a6/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31cebae8b26f8a615d2b546fee45d5ffb76852ae6450e2a03f42c9102260d6fe", size = 1774125, upload-time = "2026-03-31T21:58:04.007Z" }, - { url = "https://files.pythonhosted.org/packages/84/63/7749337c90f92bc2cb18f9560d67aa6258c7060d1397d21529b8004fcf6f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:888e78eb5ca55a615d285c3c09a7a91b42e9dd6fc699b166ebd5dee87c9ccf14", size = 1732427, upload-time = "2026-03-31T21:58:06.337Z" }, - { url = "https://files.pythonhosted.org/packages/98/de/cf2f44ff98d307e72fb97d5f5bbae3bfcb442f0ea9790c0bf5c5c2331404/aiohttp-3.13.5-cp312-cp312-win32.whl", hash = "sha256:8bd3ec6376e68a41f9f95f5ed170e2fcf22d4eb27a1f8cb361d0508f6e0557f3", size = 433534, upload-time = "2026-03-31T21:58:08.712Z" }, - { url = "https://files.pythonhosted.org/packages/aa/ca/eadf6f9c8fa5e31d40993e3db153fb5ed0b11008ad5d9de98a95045bed84/aiohttp-3.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:110e448e02c729bcebb18c60b9214a87ba33bac4a9fa5e9a5f139938b56c6cb1", size = 460446, upload-time = "2026-03-31T21:58:10.945Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/be/6f/353954c29e7dcce7cf00280a02c75f30e133c00793c7a2ed3776d7b2f426/aiohttp-3.13.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:023ecba036ddd840b0b19bf195bfae970083fd7024ce1ac22e9bba90464620e9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f5/1b/428a7c64687b3b2e9cd293186695affc0e1e54a445d0361743b231f11066/aiohttp-3.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15c933ad7920b7d9a20de151efcd05a6e38302cbf0e10c9b2acb9a42210a2416" }, + { url = "https://mirrors.aliyun.com/pypi/packages/29/47/7be41556bfbb6917069d6a6634bb7dd5e163ba445b783a90d40f5ac7e3a7/aiohttp-3.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab2899f9fa2f9f741896ebb6fa07c4c883bfa5c7f2ddd8cf2aafa86fa981b2d2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/67/84/c9ecc5828cb0b3695856c07c0a6817a99d51e2473400f705275a2b3d9239/aiohttp-3.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60eaa2d440cd4707696b52e40ed3e2b0f73f65be07fd0ef23b6b539c9c0b0b4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f0/d3/3c6d610e66b495657622edb6ae7c7fd31b2e9086b4ec50b47897ad6042a9/aiohttp-3.13.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:55b3bdd3292283295774ab585160c4004f4f2f203946997f49aac032c84649e9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/49/a0/24409c12217456df0bae7babe3b014e460b0b38a8e60753d6cb339f6556d/aiohttp-3.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2b2355dc094e5f7d45a7bb262fe7207aa0460b37a0d87027dcf21b5d890e7d5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/98/9d/b65ec649adc5bccc008b0957a9a9c691070aeac4e41cea18559fef49958b/aiohttp-3.13.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b38765950832f7d728297689ad78f5f2cf79ff82487131c4d26fe6ceecdc5f8e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/57/d8/8d44036d7eb7b6a8ec4c5494ea0c8c8b94fbc0ed3991c1a7adf230df03bf/aiohttp-3.13.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b18f31b80d5a33661e08c89e202edabf1986e9b49c42b4504371daeaa11b47c1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/31/04/d3f8211f273356f158e3464e9e45484d3fb8c4ce5eb2f6fe9405c3273983/aiohttp-3.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:33add2463dde55c4f2d9635c6ab33ce154e5ecf322bd26d09af95c5f81cfa286" }, + { url = "https://mirrors.aliyun.com/pypi/packages/41/db/073e4ebe00b78e2dfcacff734291651729a62953b48933d765dc513bf798/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:327cc432fdf1356fb4fbc6fe833ad4e9f6aacb71a8acaa5f1855e4b25910e4a9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/48/45/7dfba71a2f9fd97b15c95c06819de7eb38113d2cdb6319669195a7d64270/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7c35b0bf0b48a70b4cb4fc5d7bed9b932532728e124874355de1a0af8ec4bc88" }, + { url = "https://mirrors.aliyun.com/pypi/packages/18/71/901db0061e0f717d226386a7f471bb59b19566f2cae5f0d93874b017271f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:df23d57718f24badef8656c49743e11a89fd6f5358fa8a7b96e728fda2abf7d3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/08/d5/41eebd16066e59cd43728fe74bce953d7402f2b4ddfdfef2c0e9f17ca274/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:02e048037a6501a5ec1f6fc9736135aec6eb8a004ce48838cb951c515f32c80b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/30/e6/4a799798bf05740e66c3a1161079bda7a3dd8e22ca392481d7a7f9af82a6/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31cebae8b26f8a615d2b546fee45d5ffb76852ae6450e2a03f42c9102260d6fe" }, + { url = "https://mirrors.aliyun.com/pypi/packages/84/63/7749337c90f92bc2cb18f9560d67aa6258c7060d1397d21529b8004fcf6f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:888e78eb5ca55a615d285c3c09a7a91b42e9dd6fc699b166ebd5dee87c9ccf14" }, + { url = "https://mirrors.aliyun.com/pypi/packages/98/de/cf2f44ff98d307e72fb97d5f5bbae3bfcb442f0ea9790c0bf5c5c2331404/aiohttp-3.13.5-cp312-cp312-win32.whl", hash = "sha256:8bd3ec6376e68a41f9f95f5ed170e2fcf22d4eb27a1f8cb361d0508f6e0557f3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/aa/ca/eadf6f9c8fa5e31d40993e3db153fb5ed0b11008ad5d9de98a95045bed84/aiohttp-3.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:110e448e02c729bcebb18c60b9214a87ba33bac4a9fa5e9a5f139938b56c6cb1" }, ] [[package]] name = "aiokafka" version = "0.14.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "async-timeout" }, { name = "packaging" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/89/5f/dfc1180fd22d1acdc91949ec36e97199c43742dacb057cb8efed3679ed04/aiokafka-0.14.0.tar.gz", hash = "sha256:8ffdc945798ba4d3d132b705d4244d0a1f493925efb57c637a2ca88ee82794e1", size = 601374, upload-time = "2026-04-29T10:43:03.574Z" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/89/5f/dfc1180fd22d1acdc91949ec36e97199c43742dacb057cb8efed3679ed04/aiokafka-0.14.0.tar.gz", hash = "sha256:8ffdc945798ba4d3d132b705d4244d0a1f493925efb57c637a2ca88ee82794e1" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f9/9d/3441db94829f9feb802a2f4052df61c0d1a01272accd174c351d7e9e1f6a/aiokafka-0.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:284a90d617584d7e42688a181aaa8c2a909d9c658ab9b69c6cf92f4df5c4b320", size = 348458, upload-time = "2026-04-29T10:42:37.243Z" }, - { url = "https://files.pythonhosted.org/packages/a4/10/7297589aac95654596af13301b31da2c9502c80e7e308530ee7a9bd5b9f1/aiokafka-0.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b4f211d9e03a1fc83871a37eefcf307bc0943ee99adae25aa39bd1722e70747b", size = 351057, upload-time = "2026-04-29T10:42:38.69Z" }, - { url = "https://files.pythonhosted.org/packages/26/4e/5c0aa8db717fff0ffb8f3e16deece8f98ded6ca17c6a543b6b20cc9a7f84/aiokafka-0.14.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be517b9b9513eba43ba19961dd770a6e26d08325743093feb47182770d235dd9", size = 1142238, upload-time = "2026-04-29T10:42:39.96Z" }, - { url = "https://files.pythonhosted.org/packages/88/78/322f797b9593a4cc8afd647342fa66b9ad732ee55098e5e084188c6202aa/aiokafka-0.14.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:219d2dc66b97b1aaea100697c928024b6a0348b7baa370b824900054bf86916e", size = 1131567, upload-time = "2026-04-29T10:42:41.542Z" }, - { url = "https://files.pythonhosted.org/packages/8d/0a/a45320778385142299a7fc3ae402152ec1f383537130b8aa8e8587742fad/aiokafka-0.14.0-cp312-cp312-win32.whl", hash = "sha256:1086b470f6c452471603a2d9c8d6933739230c75758d777d8d113ff8112bad68", size = 312160, upload-time = "2026-04-29T10:42:42.811Z" }, - { url = "https://files.pythonhosted.org/packages/a3/fb/7802a0ed69200e3e8e8791df06bd6daf9b00523839d045662de4ff061b18/aiokafka-0.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:bcf3a8f6592d73f45965ca0750bfdfccf2555c8625358175c92f75f2cce1261a", size = 331897, upload-time = "2026-04-29T10:42:43.984Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f9/9d/3441db94829f9feb802a2f4052df61c0d1a01272accd174c351d7e9e1f6a/aiokafka-0.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:284a90d617584d7e42688a181aaa8c2a909d9c658ab9b69c6cf92f4df5c4b320" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a4/10/7297589aac95654596af13301b31da2c9502c80e7e308530ee7a9bd5b9f1/aiokafka-0.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b4f211d9e03a1fc83871a37eefcf307bc0943ee99adae25aa39bd1722e70747b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/26/4e/5c0aa8db717fff0ffb8f3e16deece8f98ded6ca17c6a543b6b20cc9a7f84/aiokafka-0.14.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be517b9b9513eba43ba19961dd770a6e26d08325743093feb47182770d235dd9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/88/78/322f797b9593a4cc8afd647342fa66b9ad732ee55098e5e084188c6202aa/aiokafka-0.14.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:219d2dc66b97b1aaea100697c928024b6a0348b7baa370b824900054bf86916e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8d/0a/a45320778385142299a7fc3ae402152ec1f383537130b8aa8e8587742fad/aiokafka-0.14.0-cp312-cp312-win32.whl", hash = "sha256:1086b470f6c452471603a2d9c8d6933739230c75758d777d8d113ff8112bad68" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a3/fb/7802a0ed69200e3e8e8791df06bd6daf9b00523839d045662de4ff061b18/aiokafka-0.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:bcf3a8f6592d73f45965ca0750bfdfccf2555c8625358175c92f75f2cce1261a" }, ] [[package]] name = "aiosignal" version = "1.4.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "frozenlist" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e" }, ] [[package]] name = "aiosqlite" version = "0.22.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821, upload-time = "2025-12-23T19:25:43.997Z" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650" } wheels = [ - { url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb" }, ] [[package]] name = "annotated-doc" version = "0.0.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320" }, ] [[package]] name = "annotated-types" version = "0.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89" } wheels = [ - { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53" }, ] [[package]] name = "anthropic" version = "0.86.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "anyio" }, { name = "distro" }, @@ -131,362 +131,378 @@ dependencies = [ { name = "sniffio" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/37/7a/8b390dc47945d3169875d342847431e5f7d5fa716b2e37494d57cfc1db10/anthropic-0.86.0.tar.gz", hash = "sha256:60023a7e879aa4fbb1fed99d487fe407b2ebf6569603e5047cfe304cebdaa0e5", size = 583820, upload-time = "2026-03-18T18:43:08.017Z" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/37/7a/8b390dc47945d3169875d342847431e5f7d5fa716b2e37494d57cfc1db10/anthropic-0.86.0.tar.gz", hash = "sha256:60023a7e879aa4fbb1fed99d487fe407b2ebf6569603e5047cfe304cebdaa0e5" } wheels = [ - { url = "https://files.pythonhosted.org/packages/63/5f/67db29c6e5d16c8c9c4652d3efb934d89cb750cad201539141781d8eae14/anthropic-0.86.0-py3-none-any.whl", hash = "sha256:9d2bbd339446acce98858c5627d33056efe01f70435b22b63546fe7edae0cd57", size = 469400, upload-time = "2026-03-18T18:43:06.526Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/63/5f/67db29c6e5d16c8c9c4652d3efb934d89cb750cad201539141781d8eae14/anthropic-0.86.0-py3-none-any.whl", hash = "sha256:9d2bbd339446acce98858c5627d33056efe01f70435b22b63546fe7edae0cd57" }, ] [[package]] name = "anyio" version = "4.13.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "idna" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc" } wheels = [ - { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708" }, ] [[package]] name = "asttokens" version = "3.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/be/a5/8e3f9b6771b0b408517c82d97aed8f2036509bc247d46114925e32fe33f0/asttokens-3.0.1.tar.gz", hash = "sha256:71a4ee5de0bde6a31d64f6b13f2293ac190344478f081c3d1bccfcf5eacb0cb7", size = 62308, upload-time = "2025-11-15T16:43:48.578Z" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/be/a5/8e3f9b6771b0b408517c82d97aed8f2036509bc247d46114925e32fe33f0/asttokens-3.0.1.tar.gz", hash = "sha256:71a4ee5de0bde6a31d64f6b13f2293ac190344478f081c3d1bccfcf5eacb0cb7" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/39/e7eaf1799466a4aef85b6a4fe7bd175ad2b1c6345066aa33f1f58d4b18d0/asttokens-3.0.1-py3-none-any.whl", hash = "sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a", size = 27047, upload-time = "2025-11-15T16:43:16.109Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d2/39/e7eaf1799466a4aef85b6a4fe7bd175ad2b1c6345066aa33f1f58d4b18d0/asttokens-3.0.1-py3-none-any.whl", hash = "sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a" }, ] [[package]] name = "async-timeout" version = "5.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c" }, ] [[package]] name = "asyncssh" version = "2.22.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "cryptography" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fc/d5/957886c316466349d55c4de6a688a10a98295c0b4429deb8db1a17f3eb19/asyncssh-2.22.0.tar.gz", hash = "sha256:c3ce72b01be4f97b40e62844dd384227e5ff5a401a3793007c42f86a5c8eb537", size = 540523, upload-time = "2025-12-21T23:38:30.5Z" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/fc/d5/957886c316466349d55c4de6a688a10a98295c0b4429deb8db1a17f3eb19/asyncssh-2.22.0.tar.gz", hash = "sha256:c3ce72b01be4f97b40e62844dd384227e5ff5a401a3793007c42f86a5c8eb537" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ed/ae/0da2f2214fc183338af1afe5a103a2052fd03464e8eafbd827abff58a4d0/asyncssh-2.22.0-py3-none-any.whl", hash = "sha256:d16465ccdf1ed20eba1131b14415b155e047f6f5be0d19f39c2e0b61331ee0e7", size = 374938, upload-time = "2025-12-21T23:38:28.976Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ed/ae/0da2f2214fc183338af1afe5a103a2052fd03464e8eafbd827abff58a4d0/asyncssh-2.22.0-py3-none-any.whl", hash = "sha256:d16465ccdf1ed20eba1131b14415b155e047f6f5be0d19f39c2e0b61331ee0e7" }, ] [[package]] name = "attrs" version = "26.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32" } wheels = [ - { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309" }, ] [[package]] name = "backoff" version = "2.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/47/d7/5bbeb12c44d7c4f2fb5b56abce497eb5ed9f34d85701de869acedd602619/backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba", size = 17001, upload-time = "2022-10-05T19:19:32.061Z" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/47/d7/5bbeb12c44d7c4f2fb5b56abce497eb5ed9f34d85701de869acedd602619/backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba" } wheels = [ - { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8" }, ] [[package]] name = "beautifulsoup4" version = "4.14.3" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "soupsieve" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb" }, ] [[package]] name = "cattrs" version = "26.1.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "attrs" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a0/ec/ba18945e7d6e55a58364d9fb2e46049c1c2998b3d805f19b703f14e81057/cattrs-26.1.0.tar.gz", hash = "sha256:fa239e0f0ec0715ba34852ce813986dfed1e12117e209b816ab87401271cdd40", size = 495672, upload-time = "2026-02-18T22:15:19.406Z" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/a0/ec/ba18945e7d6e55a58364d9fb2e46049c1c2998b3d805f19b703f14e81057/cattrs-26.1.0.tar.gz", hash = "sha256:fa239e0f0ec0715ba34852ce813986dfed1e12117e209b816ab87401271cdd40" } wheels = [ - { url = "https://files.pythonhosted.org/packages/80/56/60547f7801b97c67e97491dc3d9ade9fbccbd0325058fd3dfcb2f5d98d90/cattrs-26.1.0-py3-none-any.whl", hash = "sha256:d1e0804c42639494d469d08d4f26d6b9de9b8ab26b446db7b5f8c2e97f7c3096", size = 73054, upload-time = "2026-02-18T22:15:17.958Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/80/56/60547f7801b97c67e97491dc3d9ade9fbccbd0325058fd3dfcb2f5d98d90/cattrs-26.1.0-py3-none-any.whl", hash = "sha256:d1e0804c42639494d469d08d4f26d6b9de9b8ab26b446db7b5f8c2e97f7c3096" }, ] [[package]] name = "cdp-use" version = "1.4.5" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "httpx" }, { name = "typing-extensions" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f7/7a/c549417e8c5e4dface6d5d828cd7dc72502dcea33a99f5324abf5a853ce9/cdp_use-1.4.5.tar.gz", hash = "sha256:0da3a32df46336a03ff5a22bc6bc442cd7d2f2d50a118fd4856f29d37f6d26a0", size = 193961, upload-time = "2026-02-22T04:32:50.574Z" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/f7/7a/c549417e8c5e4dface6d5d828cd7dc72502dcea33a99f5324abf5a853ce9/cdp_use-1.4.5.tar.gz", hash = "sha256:0da3a32df46336a03ff5a22bc6bc442cd7d2f2d50a118fd4856f29d37f6d26a0" } wheels = [ - { url = "https://files.pythonhosted.org/packages/56/12/386d8c6bf0448c43674e24d6194c3b57d62e5361e90bca3d58108819ad32/cdp_use-1.4.5-py3-none-any.whl", hash = "sha256:8f8e2435e3a20e4009d2974144192cf3c132f6c2971338e156198814d9b91ecb", size = 350504, upload-time = "2026-02-22T04:32:49.22Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/56/12/386d8c6bf0448c43674e24d6194c3b57d62e5361e90bca3d58108819ad32/cdp_use-1.4.5-py3-none-any.whl", hash = "sha256:8f8e2435e3a20e4009d2974144192cf3c132f6c2971338e156198814d9b91ecb" }, ] [[package]] name = "certifi" version = "2026.2.25" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa" }, ] [[package]] name = "cffi" version = "2.0.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "pycparser", marker = "implementation_name != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, - { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, - { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, - { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, - { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, - { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, - { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, - { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, - { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, - { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, - { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, - { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037" }, + { url = "https://mirrors.aliyun.com/pypi/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba" }, + { url = "https://mirrors.aliyun.com/pypi/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6" }, ] [[package]] name = "charset-normalizer" version = "3.4.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7b/60/e3bec1881450851b087e301bedc3daa9377a4d45f1c26aa90b0b235e38aa/charset_normalizer-3.4.6.tar.gz", hash = "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6", size = 143363, upload-time = "2026-03-15T18:53:25.478Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/62/c0815c992c9545347aeea7859b50dc9044d147e2e7278329c6e02ac9a616/charset_normalizer-3.4.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2ef7fedc7a6ecbe99969cd09632516738a97eeb8bd7258bf8a0f23114c057dab", size = 295154, upload-time = "2026-03-15T18:50:50.88Z" }, - { url = "https://files.pythonhosted.org/packages/a8/37/bdca6613c2e3c58c7421891d80cc3efa1d32e882f7c4a7ee6039c3fc951a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4ea868bc28109052790eb2b52a9ab33f3aa7adc02f96673526ff47419490e21", size = 199191, upload-time = "2026-03-15T18:50:52.658Z" }, - { url = "https://files.pythonhosted.org/packages/6c/92/9934d1bbd69f7f398b38c5dae1cbf9cc672e7c34a4adf7b17c0a9c17d15d/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:836ab36280f21fc1a03c99cd05c6b7af70d2697e374c7af0b61ed271401a72a2", size = 218674, upload-time = "2026-03-15T18:50:54.102Z" }, - { url = "https://files.pythonhosted.org/packages/af/90/25f6ab406659286be929fd89ab0e78e38aa183fc374e03aa3c12d730af8a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f1ce721c8a7dfec21fcbdfe04e8f68174183cf4e8188e0645e92aa23985c57ff", size = 215259, upload-time = "2026-03-15T18:50:55.616Z" }, - { url = "https://files.pythonhosted.org/packages/4e/ef/79a463eb0fff7f96afa04c1d4c51f8fc85426f918db467854bfb6a569ce3/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e28d62a8fc7a1fa411c43bd65e346f3bce9716dc51b897fbe930c5987b402d5", size = 207276, upload-time = "2026-03-15T18:50:57.054Z" }, - { url = "https://files.pythonhosted.org/packages/f7/72/d0426afec4b71dc159fa6b4e68f868cd5a3ecd918fec5813a15d292a7d10/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:530d548084c4a9f7a16ed4a294d459b4f229db50df689bfe92027452452943a0", size = 195161, upload-time = "2026-03-15T18:50:58.686Z" }, - { url = "https://files.pythonhosted.org/packages/bf/18/c82b06a68bfcb6ce55e508225d210c7e6a4ea122bfc0748892f3dc4e8e11/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30f445ae60aad5e1f8bdbb3108e39f6fbc09f4ea16c815c66578878325f8f15a", size = 203452, upload-time = "2026-03-15T18:51:00.196Z" }, - { url = "https://files.pythonhosted.org/packages/44/d6/0c25979b92f8adafdbb946160348d8d44aa60ce99afdc27df524379875cb/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ac2393c73378fea4e52aa56285a3d64be50f1a12395afef9cce47772f60334c2", size = 202272, upload-time = "2026-03-15T18:51:01.703Z" }, - { url = "https://files.pythonhosted.org/packages/2e/3d/7fea3e8fe84136bebbac715dd1221cc25c173c57a699c030ab9b8900cbb7/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:90ca27cd8da8118b18a52d5f547859cc1f8354a00cd1e8e5120df3e30d6279e5", size = 195622, upload-time = "2026-03-15T18:51:03.526Z" }, - { url = "https://files.pythonhosted.org/packages/57/8a/d6f7fd5cb96c58ef2f681424fbca01264461336d2a7fc875e4446b1f1346/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e5a94886bedca0f9b78fecd6afb6629142fd2605aa70a125d49f4edc6037ee6", size = 220056, upload-time = "2026-03-15T18:51:05.269Z" }, - { url = "https://files.pythonhosted.org/packages/16/50/478cdda782c8c9c3fb5da3cc72dd7f331f031e7f1363a893cdd6ca0f8de0/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:695f5c2823691a25f17bc5d5ffe79fa90972cc34b002ac6c843bb8a1720e950d", size = 203751, upload-time = "2026-03-15T18:51:06.858Z" }, - { url = "https://files.pythonhosted.org/packages/75/fc/cc2fcac943939c8e4d8791abfa139f685e5150cae9f94b60f12520feaa9b/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:231d4da14bcd9301310faf492051bee27df11f2bc7549bc0bb41fef11b82daa2", size = 216563, upload-time = "2026-03-15T18:51:08.564Z" }, - { url = "https://files.pythonhosted.org/packages/a8/b7/a4add1d9a5f68f3d037261aecca83abdb0ab15960a3591d340e829b37298/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a056d1ad2633548ca18ffa2f85c202cfb48b68615129143915b8dc72a806a923", size = 209265, upload-time = "2026-03-15T18:51:10.312Z" }, - { url = "https://files.pythonhosted.org/packages/6c/18/c094561b5d64a24277707698e54b7f67bd17a4f857bbfbb1072bba07c8bf/charset_normalizer-3.4.6-cp312-cp312-win32.whl", hash = "sha256:c2274ca724536f173122f36c98ce188fd24ce3dad886ec2b7af859518ce008a4", size = 144229, upload-time = "2026-03-15T18:51:11.694Z" }, - { url = "https://files.pythonhosted.org/packages/ab/20/0567efb3a8fd481b8f34f739ebddc098ed062a59fed41a8d193a61939e8f/charset_normalizer-3.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:c8ae56368f8cc97c7e40a7ee18e1cedaf8e780cd8bc5ed5ac8b81f238614facb", size = 154277, upload-time = "2026-03-15T18:51:13.004Z" }, - { url = "https://files.pythonhosted.org/packages/15/57/28d79b44b51933119e21f65479d0864a8d5893e494cf5daab15df0247c17/charset_normalizer-3.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:899d28f422116b08be5118ef350c292b36fc15ec2daeb9ea987c89281c7bb5c4", size = 142817, upload-time = "2026-03-15T18:51:14.408Z" }, - { url = "https://files.pythonhosted.org/packages/2a/68/687187c7e26cb24ccbd88e5069f5ef00eba804d36dde11d99aad0838ab45/charset_normalizer-3.4.6-py3-none-any.whl", hash = "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69", size = 61455, upload-time = "2026-03-15T18:53:23.833Z" }, +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/7b/60/e3bec1881450851b087e301bedc3daa9377a4d45f1c26aa90b0b235e38aa/charset_normalizer-3.4.6.tar.gz", hash = "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/e5/62/c0815c992c9545347aeea7859b50dc9044d147e2e7278329c6e02ac9a616/charset_normalizer-3.4.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2ef7fedc7a6ecbe99969cd09632516738a97eeb8bd7258bf8a0f23114c057dab" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a8/37/bdca6613c2e3c58c7421891d80cc3efa1d32e882f7c4a7ee6039c3fc951a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4ea868bc28109052790eb2b52a9ab33f3aa7adc02f96673526ff47419490e21" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6c/92/9934d1bbd69f7f398b38c5dae1cbf9cc672e7c34a4adf7b17c0a9c17d15d/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:836ab36280f21fc1a03c99cd05c6b7af70d2697e374c7af0b61ed271401a72a2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/af/90/25f6ab406659286be929fd89ab0e78e38aa183fc374e03aa3c12d730af8a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f1ce721c8a7dfec21fcbdfe04e8f68174183cf4e8188e0645e92aa23985c57ff" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4e/ef/79a463eb0fff7f96afa04c1d4c51f8fc85426f918db467854bfb6a569ce3/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e28d62a8fc7a1fa411c43bd65e346f3bce9716dc51b897fbe930c5987b402d5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f7/72/d0426afec4b71dc159fa6b4e68f868cd5a3ecd918fec5813a15d292a7d10/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:530d548084c4a9f7a16ed4a294d459b4f229db50df689bfe92027452452943a0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bf/18/c82b06a68bfcb6ce55e508225d210c7e6a4ea122bfc0748892f3dc4e8e11/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30f445ae60aad5e1f8bdbb3108e39f6fbc09f4ea16c815c66578878325f8f15a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/44/d6/0c25979b92f8adafdbb946160348d8d44aa60ce99afdc27df524379875cb/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ac2393c73378fea4e52aa56285a3d64be50f1a12395afef9cce47772f60334c2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2e/3d/7fea3e8fe84136bebbac715dd1221cc25c173c57a699c030ab9b8900cbb7/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:90ca27cd8da8118b18a52d5f547859cc1f8354a00cd1e8e5120df3e30d6279e5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/57/8a/d6f7fd5cb96c58ef2f681424fbca01264461336d2a7fc875e4446b1f1346/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e5a94886bedca0f9b78fecd6afb6629142fd2605aa70a125d49f4edc6037ee6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/16/50/478cdda782c8c9c3fb5da3cc72dd7f331f031e7f1363a893cdd6ca0f8de0/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:695f5c2823691a25f17bc5d5ffe79fa90972cc34b002ac6c843bb8a1720e950d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/75/fc/cc2fcac943939c8e4d8791abfa139f685e5150cae9f94b60f12520feaa9b/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:231d4da14bcd9301310faf492051bee27df11f2bc7549bc0bb41fef11b82daa2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a8/b7/a4add1d9a5f68f3d037261aecca83abdb0ab15960a3591d340e829b37298/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a056d1ad2633548ca18ffa2f85c202cfb48b68615129143915b8dc72a806a923" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6c/18/c094561b5d64a24277707698e54b7f67bd17a4f857bbfbb1072bba07c8bf/charset_normalizer-3.4.6-cp312-cp312-win32.whl", hash = "sha256:c2274ca724536f173122f36c98ce188fd24ce3dad886ec2b7af859518ce008a4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ab/20/0567efb3a8fd481b8f34f739ebddc098ed062a59fed41a8d193a61939e8f/charset_normalizer-3.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:c8ae56368f8cc97c7e40a7ee18e1cedaf8e780cd8bc5ed5ac8b81f238614facb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/15/57/28d79b44b51933119e21f65479d0864a8d5893e494cf5daab15df0247c17/charset_normalizer-3.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:899d28f422116b08be5118ef350c292b36fc15ec2daeb9ea987c89281c7bb5c4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2a/68/687187c7e26cb24ccbd88e5069f5ef00eba804d36dde11d99aad0838ab45/charset_normalizer-3.4.6-py3-none-any.whl", hash = "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69" }, ] [[package]] name = "claude" version = "0.4.11" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fa/0d/40d26e7074d10f946ae06f6125de511ef0318598d7c3cc27c4ce9c9eb0a8/claude-0.4.11.tar.gz", hash = "sha256:a76ecc7e28869866c4353a39d176397a3b6964b543ef176363db5ea32726868d", size = 820, upload-time = "2025-06-10T18:49:08.992Z" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/fa/0d/40d26e7074d10f946ae06f6125de511ef0318598d7c3cc27c4ce9c9eb0a8/claude-0.4.11.tar.gz", hash = "sha256:a76ecc7e28869866c4353a39d176397a3b6964b543ef176363db5ea32726868d" } wheels = [ - { url = "https://files.pythonhosted.org/packages/63/2b/aa9b6b6fbf63e094c3dfe2ec0010ddd952934647ee51e13a97186b91a44d/claude-0.4.11-py3-none-any.whl", hash = "sha256:369b5945461154bdcb9957f375944e9268ad343e15a924af2476a05b1c167478", size = 1167, upload-time = "2025-06-10T18:49:08.17Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/63/2b/aa9b6b6fbf63e094c3dfe2ec0010ddd952934647ee51e13a97186b91a44d/claude-0.4.11-py3-none-any.whl", hash = "sha256:369b5945461154bdcb9957f375944e9268ad343e15a924af2476a05b1c167478" }, ] [[package]] name = "click" version = "8.1.8" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593, upload-time = "2024-12-21T18:38:44.339Z" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188, upload-time = "2024-12-21T18:38:41.666Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2" }, ] [[package]] name = "colorama" version = "0.4.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" }, ] [[package]] name = "coloredlogs" version = "15.0.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "humanfriendly" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cc/c7/eed8f27100517e8c0e6b923d5f0845d0cb99763da6fdee00478f91db7325/coloredlogs-15.0.1.tar.gz", hash = "sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0", size = 278520, upload-time = "2021-06-11T10:22:45.202Z" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/cc/c7/eed8f27100517e8c0e6b923d5f0845d0cb99763da6fdee00478f91db7325/coloredlogs-15.0.1.tar.gz", hash = "sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/06/3d6badcf13db419e25b07041d9c7b4a2c331d3f4e7134445ec5df57714cd/coloredlogs-15.0.1-py2.py3-none-any.whl", hash = "sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934", size = 46018, upload-time = "2021-06-11T10:22:42.561Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a7/06/3d6badcf13db419e25b07041d9c7b4a2c331d3f4e7134445ec5df57714cd/coloredlogs-15.0.1-py2.py3-none-any.whl", hash = "sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934" }, ] [[package]] name = "coverage" version = "7.13.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554, upload-time = "2026-03-17T10:30:42.208Z" }, - { url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908, upload-time = "2026-03-17T10:30:43.906Z" }, - { url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419, upload-time = "2026-03-17T10:30:45.545Z" }, - { url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159, upload-time = "2026-03-17T10:30:47.204Z" }, - { url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270, upload-time = "2026-03-17T10:30:48.812Z" }, - { url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538, upload-time = "2026-03-17T10:30:50.77Z" }, - { url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821, upload-time = "2026-03-17T10:30:52.5Z" }, - { url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191, upload-time = "2026-03-17T10:30:54.543Z" }, - { url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337, upload-time = "2026-03-17T10:30:56.663Z" }, - { url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404, upload-time = "2026-03-17T10:30:58.427Z" }, - { url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903, upload-time = "2026-03-17T10:31:00.093Z" }, - { url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780, upload-time = "2026-03-17T10:31:01.916Z" }, - { url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093, upload-time = "2026-03-17T10:31:03.642Z" }, - { url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900, upload-time = "2026-03-17T10:31:05.651Z" }, - { url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515, upload-time = "2026-03-17T10:31:07.293Z" }, - { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" }, +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422" }, + { url = "https://mirrors.aliyun.com/pypi/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376" }, + { url = "https://mirrors.aliyun.com/pypi/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256" }, + { url = "https://mirrors.aliyun.com/pypi/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09" }, + { url = "https://mirrors.aliyun.com/pypi/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61" }, ] [[package]] name = "croniter" version = "6.2.2" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "python-dateutil" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/df/de/5832661ed55107b8a09af3f0a2e71e0957226a59eb1dcf0a445cce6daf20/croniter-6.2.2.tar.gz", hash = "sha256:ba60832a5ec8e12e51b8691c3309a113d1cf6526bdf1a48150ce8ec7a532d0ab", size = 113762, upload-time = "2026-03-15T08:43:48.112Z" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/df/de/5832661ed55107b8a09af3f0a2e71e0957226a59eb1dcf0a445cce6daf20/croniter-6.2.2.tar.gz", hash = "sha256:ba60832a5ec8e12e51b8691c3309a113d1cf6526bdf1a48150ce8ec7a532d0ab" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/39/783980e78cb92c2d7bdb1fc7dbc86e94ccc6d58224d76a7f1f51b6c51e30/croniter-6.2.2-py3-none-any.whl", hash = "sha256:a5d17b1060974d36251ea4faf388233eca8acf0d09cbd92d35f4c4ac8f279960", size = 45422, upload-time = "2026-03-15T08:43:46.626Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d0/39/783980e78cb92c2d7bdb1fc7dbc86e94ccc6d58224d76a7f1f51b6c51e30/croniter-6.2.2-py3-none-any.whl", hash = "sha256:a5d17b1060974d36251ea4faf388233eca8acf0d09cbd92d35f4c4ac8f279960" }, ] [[package]] name = "cryptography" version = "46.0.5" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" }, - { url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" }, - { url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" }, - { url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" }, - { url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" }, - { url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" }, - { url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" }, - { url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" }, - { url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" }, - { url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" }, - { url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" }, - { url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" }, - { url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" }, - { url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" }, - { url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" }, - { url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" }, - { url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" }, - { url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" }, - { url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" }, - { url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" }, - { url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" }, - { url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" }, - { url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" }, - { url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" }, - { url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" }, - { url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" }, - { url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" }, - { url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" }, +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263" }, + { url = "https://mirrors.aliyun.com/pypi/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76" }, + { url = "https://mirrors.aliyun.com/pypi/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614" }, + { url = "https://mirrors.aliyun.com/pypi/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229" }, + { url = "https://mirrors.aliyun.com/pypi/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72" }, + { url = "https://mirrors.aliyun.com/pypi/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595" }, ] [[package]] name = "datasketch" version = "1.10.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "numpy" }, { name = "scipy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8d/73/8e9014887f9fca2d785777a0a6186813e4fc7faa24f05fc88c6420624891/datasketch-1.10.0.tar.gz", hash = "sha256:d23aea80ce4c40790ca7a40795659848be92ecc43db80942be26f21e81d24714", size = 91699, upload-time = "2026-04-17T23:06:56.388Z" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/8d/73/8e9014887f9fca2d785777a0a6186813e4fc7faa24f05fc88c6420624891/datasketch-1.10.0.tar.gz", hash = "sha256:d23aea80ce4c40790ca7a40795659848be92ecc43db80942be26f21e81d24714" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ed/e7/a94668082e078099eb0161635649510aa887690767b779fffe4bdc479913/datasketch-1.10.0-py3-none-any.whl", hash = "sha256:303dd90cda0948a21abba3aaefc9f8528fa12b8204edc5e1ae8b1d7b750234e7", size = 99914, upload-time = "2026-04-17T23:06:54.39Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ed/e7/a94668082e078099eb0161635649510aa887690767b779fffe4bdc479913/datasketch-1.10.0-py3-none-any.whl", hash = "sha256:303dd90cda0948a21abba3aaefc9f8528fa12b8204edc5e1ae8b1d7b750234e7" }, +] + +[[package]] +name = "ddddocr" +version = "1.6.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "numpy" }, + { name = "onnxruntime" }, + { name = "opencv-python", marker = "sys_platform == 'darwin' or sys_platform == 'win32'" }, + { name = "opencv-python-headless", marker = "sys_platform == 'linux'" }, + { name = "pillow" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/07/5f/7c06bbb594b77062e6d0d43f06dc88668aaeb699c8737c84542aaa39da8c/ddddocr-1.6.1.tar.gz", hash = "sha256:1c59d84d63d8703c6c486465a32389c9e41dd92852c794c5e4c0181a5f82d43a" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/0e/48/cbaed3981b8d8d51141b9b4779b811f4728e65d952a1e3e2e5e929539183/ddddocr-1.6.1-py3-none-any.whl", hash = "sha256:c7c70f4ae2d0335440ae8b272eea48c9f6888ecef46785fe2311f0c97a133935" }, ] [[package]] name = "decorator" version = "5.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/fa/6d96a0978d19e17b68d634497769987b16c8f4cd0a7a05048bec693caa6b/decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360", size = 56711, upload-time = "2025-02-24T04:41:34.073Z" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/43/fa/6d96a0978d19e17b68d634497769987b16c8f4cd0a7a05048bec693caa6b/decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a" }, ] [[package]] name = "defusedxml" version = "0.7.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69" } wheels = [ - { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61" }, ] [[package]] name = "dingtalk-stream" version = "0.24.3" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "aiohttp" }, { name = "requests" }, { name = "websockets" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/4c/44/102dede3f371277598df6aa9725b82e3add068c729333c7a5dbc12764579/dingtalk_stream-0.24.3-py3-none-any.whl", hash = "sha256:2160403656985962878bf60cdf5adf41619f21067348e06f07a7c7eebf5943ad", size = 27813, upload-time = "2025-10-24T09:36:57.497Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4c/44/102dede3f371277598df6aa9725b82e3add068c729333c7a5dbc12764579/dingtalk_stream-0.24.3-py3-none-any.whl", hash = "sha256:2160403656985962878bf60cdf5adf41619f21067348e06f07a7c7eebf5943ad" }, ] [[package]] name = "distro" version = "1.9.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed" } wheels = [ - { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2" }, ] [[package]] name = "docstring-parser" version = "0.17.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b2/9d/c3b43da9515bd270df0f80548d9944e389870713cc1fe2b8fb35fe2bcefd/docstring_parser-0.17.0.tar.gz", hash = "sha256:583de4a309722b3315439bb31d64ba3eebada841f2e2cee23b99df001434c912", size = 27442, upload-time = "2025-07-21T07:35:01.868Z" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/b2/9d/c3b43da9515bd270df0f80548d9944e389870713cc1fe2b8fb35fe2bcefd/docstring_parser-0.17.0.tar.gz", hash = "sha256:583de4a309722b3315439bb31d64ba3eebada841f2e2cee23b99df001434c912" } wheels = [ - { url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708" }, ] [[package]] name = "executing" version = "2.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017" }, ] [[package]] name = "fastapi" version = "0.135.2" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "annotated-doc" }, { name = "pydantic" }, @@ -494,50 +510,50 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c4/73/5903c4b13beae98618d64eb9870c3fac4f605523dd0312ca5c80dadbd5b9/fastapi-0.135.2.tar.gz", hash = "sha256:88a832095359755527b7f63bb4c6bc9edb8329a026189eed83d6c1afcf419d56", size = 395833, upload-time = "2026-03-23T14:12:41.697Z" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/c4/73/5903c4b13beae98618d64eb9870c3fac4f605523dd0312ca5c80dadbd5b9/fastapi-0.135.2.tar.gz", hash = "sha256:88a832095359755527b7f63bb4c6bc9edb8329a026189eed83d6c1afcf419d56" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8f/ea/18f6d0457f9efb2fc6fa594857f92810cadb03024975726db6546b3d6fcf/fastapi-0.135.2-py3-none-any.whl", hash = "sha256:0af0447d541867e8db2a6a25c23a8c4bd80e2394ac5529bd87501bbb9e240ca5", size = 117407, upload-time = "2026-03-23T14:12:43.284Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8f/ea/18f6d0457f9efb2fc6fa594857f92810cadb03024975726db6546b3d6fcf/fastapi-0.135.2-py3-none-any.whl", hash = "sha256:0af0447d541867e8db2a6a25c23a8c4bd80e2394ac5529bd87501bbb9e240ca5" }, ] [[package]] name = "fastuuid" version = "0.14.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c3/7d/d9daedf0f2ebcacd20d599928f8913e9d2aea1d56d2d355a93bfa2b611d7/fastuuid-0.14.0.tar.gz", hash = "sha256:178947fc2f995b38497a74172adee64fdeb8b7ec18f2a5934d037641ba265d26", size = 18232, upload-time = "2025-10-19T22:19:22.402Z" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/c3/7d/d9daedf0f2ebcacd20d599928f8913e9d2aea1d56d2d355a93bfa2b611d7/fastuuid-0.14.0.tar.gz", hash = "sha256:178947fc2f995b38497a74172adee64fdeb8b7ec18f2a5934d037641ba265d26" } wheels = [ - { url = "https://files.pythonhosted.org/packages/02/a2/e78fcc5df65467f0d207661b7ef86c5b7ac62eea337c0c0fcedbeee6fb13/fastuuid-0.14.0-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:77e94728324b63660ebf8adb27055e92d2e4611645bf12ed9d88d30486471d0a", size = 510164, upload-time = "2025-10-19T22:31:45.635Z" }, - { url = "https://files.pythonhosted.org/packages/2b/b3/c846f933f22f581f558ee63f81f29fa924acd971ce903dab1a9b6701816e/fastuuid-0.14.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:caa1f14d2102cb8d353096bc6ef6c13b2c81f347e6ab9d6fbd48b9dea41c153d", size = 261837, upload-time = "2025-10-19T22:38:38.53Z" }, - { url = "https://files.pythonhosted.org/packages/54/ea/682551030f8c4fa9a769d9825570ad28c0c71e30cf34020b85c1f7ee7382/fastuuid-0.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d23ef06f9e67163be38cece704170486715b177f6baae338110983f99a72c070", size = 251370, upload-time = "2025-10-19T22:40:26.07Z" }, - { url = "https://files.pythonhosted.org/packages/14/dd/5927f0a523d8e6a76b70968e6004966ee7df30322f5fc9b6cdfb0276646a/fastuuid-0.14.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0c9ec605ace243b6dbe3bd27ebdd5d33b00d8d1d3f580b39fdd15cd96fd71796", size = 277766, upload-time = "2025-10-19T22:37:23.779Z" }, - { url = "https://files.pythonhosted.org/packages/16/6e/c0fb547eef61293153348f12e0f75a06abb322664b34a1573a7760501336/fastuuid-0.14.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:808527f2407f58a76c916d6aa15d58692a4a019fdf8d4c32ac7ff303b7d7af09", size = 278105, upload-time = "2025-10-19T22:26:56.821Z" }, - { url = "https://files.pythonhosted.org/packages/2d/b1/b9c75e03b768f61cf2e84ee193dc18601aeaf89a4684b20f2f0e9f52b62c/fastuuid-0.14.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2fb3c0d7fef6674bbeacdd6dbd386924a7b60b26de849266d1ff6602937675c8", size = 301564, upload-time = "2025-10-19T22:30:31.604Z" }, - { url = "https://files.pythonhosted.org/packages/fc/fa/f7395fdac07c7a54f18f801744573707321ca0cee082e638e36452355a9d/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ab3f5d36e4393e628a4df337c2c039069344db5f4b9d2a3c9cea48284f1dd741", size = 459659, upload-time = "2025-10-19T22:31:32.341Z" }, - { url = "https://files.pythonhosted.org/packages/66/49/c9fd06a4a0b1f0f048aacb6599e7d96e5d6bc6fa680ed0d46bf111929d1b/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:b9a0ca4f03b7e0b01425281ffd44e99d360e15c895f1907ca105854ed85e2057", size = 478430, upload-time = "2025-10-19T22:26:22.962Z" }, - { url = "https://files.pythonhosted.org/packages/be/9c/909e8c95b494e8e140e8be6165d5fc3f61fdc46198c1554df7b3e1764471/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3acdf655684cc09e60fb7e4cf524e8f42ea760031945aa8086c7eae2eeeabeb8", size = 450894, upload-time = "2025-10-19T22:27:01.647Z" }, - { url = "https://files.pythonhosted.org/packages/90/eb/d29d17521976e673c55ef7f210d4cdd72091a9ec6755d0fd4710d9b3c871/fastuuid-0.14.0-cp312-cp312-win32.whl", hash = "sha256:9579618be6280700ae36ac42c3efd157049fe4dd40ca49b021280481c78c3176", size = 154374, upload-time = "2025-10-19T22:29:19.879Z" }, - { url = "https://files.pythonhosted.org/packages/cc/fc/f5c799a6ea6d877faec0472d0b27c079b47c86b1cdc577720a5386483b36/fastuuid-0.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:d9e4332dc4ba054434a9594cbfaf7823b57993d7d8e7267831c3e059857cf397", size = 156550, upload-time = "2025-10-19T22:27:49.658Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/02/a2/e78fcc5df65467f0d207661b7ef86c5b7ac62eea337c0c0fcedbeee6fb13/fastuuid-0.14.0-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:77e94728324b63660ebf8adb27055e92d2e4611645bf12ed9d88d30486471d0a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2b/b3/c846f933f22f581f558ee63f81f29fa924acd971ce903dab1a9b6701816e/fastuuid-0.14.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:caa1f14d2102cb8d353096bc6ef6c13b2c81f347e6ab9d6fbd48b9dea41c153d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/54/ea/682551030f8c4fa9a769d9825570ad28c0c71e30cf34020b85c1f7ee7382/fastuuid-0.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d23ef06f9e67163be38cece704170486715b177f6baae338110983f99a72c070" }, + { url = "https://mirrors.aliyun.com/pypi/packages/14/dd/5927f0a523d8e6a76b70968e6004966ee7df30322f5fc9b6cdfb0276646a/fastuuid-0.14.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0c9ec605ace243b6dbe3bd27ebdd5d33b00d8d1d3f580b39fdd15cd96fd71796" }, + { url = "https://mirrors.aliyun.com/pypi/packages/16/6e/c0fb547eef61293153348f12e0f75a06abb322664b34a1573a7760501336/fastuuid-0.14.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:808527f2407f58a76c916d6aa15d58692a4a019fdf8d4c32ac7ff303b7d7af09" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2d/b1/b9c75e03b768f61cf2e84ee193dc18601aeaf89a4684b20f2f0e9f52b62c/fastuuid-0.14.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2fb3c0d7fef6674bbeacdd6dbd386924a7b60b26de849266d1ff6602937675c8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fc/fa/f7395fdac07c7a54f18f801744573707321ca0cee082e638e36452355a9d/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ab3f5d36e4393e628a4df337c2c039069344db5f4b9d2a3c9cea48284f1dd741" }, + { url = "https://mirrors.aliyun.com/pypi/packages/66/49/c9fd06a4a0b1f0f048aacb6599e7d96e5d6bc6fa680ed0d46bf111929d1b/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:b9a0ca4f03b7e0b01425281ffd44e99d360e15c895f1907ca105854ed85e2057" }, + { url = "https://mirrors.aliyun.com/pypi/packages/be/9c/909e8c95b494e8e140e8be6165d5fc3f61fdc46198c1554df7b3e1764471/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3acdf655684cc09e60fb7e4cf524e8f42ea760031945aa8086c7eae2eeeabeb8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/90/eb/d29d17521976e673c55ef7f210d4cdd72091a9ec6755d0fd4710d9b3c871/fastuuid-0.14.0-cp312-cp312-win32.whl", hash = "sha256:9579618be6280700ae36ac42c3efd157049fe4dd40ca49b021280481c78c3176" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cc/fc/f5c799a6ea6d877faec0472d0b27c079b47c86b1cdc577720a5386483b36/fastuuid-0.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:d9e4332dc4ba054434a9594cbfaf7823b57993d7d8e7267831c3e059857cf397" }, ] [[package]] name = "filelock" version = "3.25.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/b8/00651a0f559862f3bb7d6f7477b192afe3f583cc5e26403b44e59a55ab34/filelock-3.25.2.tar.gz", hash = "sha256:b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694", size = 40480, upload-time = "2026-03-11T20:45:38.487Z" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/94/b8/00651a0f559862f3bb7d6f7477b192afe3f583cc5e26403b44e59a55ab34/filelock-3.25.2.tar.gz", hash = "sha256:b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a4/a5/842ae8f0c08b61d6484b52f99a03510a3a72d23141942d216ebe81fefbce/filelock-3.25.2-py3-none-any.whl", hash = "sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70", size = 26759, upload-time = "2026-03-11T20:45:37.437Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a4/a5/842ae8f0c08b61d6484b52f99a03510a3a72d23141942d216ebe81fefbce/filelock-3.25.2-py3-none-any.whl", hash = "sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70" }, ] [[package]] name = "flatbuffers" version = "25.12.19" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4", size = 26661, upload-time = "2025-12-19T23:16:13.622Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4" }, ] [[package]] name = "flocks" -version = "2026.7.1" +version = "2026.7.8" source = { editable = "." } dependencies = [ { name = "aiofiles" }, @@ -551,6 +567,7 @@ dependencies = [ { name = "click" }, { name = "croniter" }, { name = "datasketch" }, + { name = "ddddocr" }, { name = "defusedxml" }, { name = "dingtalk-stream" }, { name = "fastapi" }, @@ -619,6 +636,7 @@ requires-dist = [ { name = "click", specifier = ">=8.1.7" }, { name = "croniter", specifier = ">=6.0.0" }, { name = "datasketch", specifier = ">=1.10.0" }, + { name = "ddddocr", specifier = ">=1.6.1" }, { name = "defusedxml", specifier = ">=0.7.1" }, { name = "dingtalk-stream", specifier = ">=0.20" }, { name = "fastapi", specifier = ">=0.109.0" }, @@ -677,72 +695,72 @@ dev = [ [[package]] name = "frozenlist" version = "1.8.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" }, - { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" }, - { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" }, - { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" }, - { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" }, - { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" }, - { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" }, - { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" }, - { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" }, - { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" }, - { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" }, - { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" }, - { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" }, - { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" }, - { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" }, - { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" }, - { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29" }, + { url = "https://mirrors.aliyun.com/pypi/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa" }, + { url = "https://mirrors.aliyun.com/pypi/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d" }, ] [[package]] name = "fsspec" version = "2026.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/51/7c/f60c259dcbf4f0c47cc4ddb8f7720d2dcdc8888c8e5ad84c73ea4531cc5b/fsspec-2026.2.0.tar.gz", hash = "sha256:6544e34b16869f5aacd5b90bdf1a71acb37792ea3ddf6125ee69a22a53fb8bff", size = 313441, upload-time = "2026-02-05T21:50:53.743Z" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/51/7c/f60c259dcbf4f0c47cc4ddb8f7720d2dcdc8888c8e5ad84c73ea4531cc5b/fsspec-2026.2.0.tar.gz", hash = "sha256:6544e34b16869f5aacd5b90bdf1a71acb37792ea3ddf6125ee69a22a53fb8bff" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e6/ab/fb21f4c939bb440104cc2b396d3be1d9b7a9fd3c6c2a53d98c45b3d7c954/fsspec-2026.2.0-py3-none-any.whl", hash = "sha256:98de475b5cb3bd66bedd5c4679e87b4fdfe1a3bf4d707b151b3c07e58c9a2437", size = 202505, upload-time = "2026-02-05T21:50:51.819Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e6/ab/fb21f4c939bb440104cc2b396d3be1d9b7a9fd3c6c2a53d98c45b3d7c954/fsspec-2026.2.0-py3-none-any.whl", hash = "sha256:98de475b5cb3bd66bedd5c4679e87b4fdfe1a3bf4d707b151b3c07e58c9a2437" }, ] [[package]] name = "gitdb" version = "4.0.12" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "smmap" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684, upload-time = "2025-01-02T07:20:46.413Z" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794, upload-time = "2025-01-02T07:20:43.624Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf" }, ] [[package]] name = "gitpython" version = "3.1.46" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "gitdb" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/df/b5/59d16470a1f0dfe8c793f9ef56fd3826093fc52b3bd96d6b9d6c26c7e27b/gitpython-3.1.46.tar.gz", hash = "sha256:400124c7d0ef4ea03f7310ac2fbf7151e09ff97f2a3288d64a440c584a29c37f", size = 215371, upload-time = "2026-01-01T15:37:32.073Z" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/df/b5/59d16470a1f0dfe8c793f9ef56fd3826093fc52b3bd96d6b9d6c26c7e27b/gitpython-3.1.46.tar.gz", hash = "sha256:400124c7d0ef4ea03f7310ac2fbf7151e09ff97f2a3288d64a440c584a29c37f" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/09/e21df6aef1e1ffc0c816f0522ddc3f6dcded766c3261813131c78a704470/gitpython-3.1.46-py3-none-any.whl", hash = "sha256:79812ed143d9d25b6d176a10bb511de0f9c67b1fa641d82097b0ab90398a2058", size = 208620, upload-time = "2026-01-01T15:37:30.574Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6a/09/e21df6aef1e1ffc0c816f0522ddc3f6dcded766c3261813131c78a704470/gitpython-3.1.46-py3-none-any.whl", hash = "sha256:79812ed143d9d25b6d176a10bb511de0f9c67b1fa641d82097b0ab90398a2058" }, ] [[package]] name = "google-auth" version = "2.49.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "cryptography" }, { name = "pyasn1-modules" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ea/80/6a696a07d3d3b0a92488933532f03dbefa4a24ab80fb231395b9a2a1be77/google_auth-2.49.1.tar.gz", hash = "sha256:16d40da1c3c5a0533f57d268fe72e0ebb0ae1cc3b567024122651c045d879b64", size = 333825, upload-time = "2026-03-12T19:30:58.135Z" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/ea/80/6a696a07d3d3b0a92488933532f03dbefa4a24ab80fb231395b9a2a1be77/google_auth-2.49.1.tar.gz", hash = "sha256:16d40da1c3c5a0533f57d268fe72e0ebb0ae1cc3b567024122651c045d879b64" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/eb/c6c2478d8a8d633460be40e2a8a6f8f429171997a35a96f81d3b680dec83/google_auth-2.49.1-py3-none-any.whl", hash = "sha256:195ebe3dca18eddd1b3db5edc5189b76c13e96f29e73043b923ebcf3f1a860f7", size = 240737, upload-time = "2026-03-12T19:30:53.159Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e9/eb/c6c2478d8a8d633460be40e2a8a6f8f429171997a35a96f81d3b680dec83/google_auth-2.49.1-py3-none-any.whl", hash = "sha256:195ebe3dca18eddd1b3db5edc5189b76c13e96f29e73043b923ebcf3f1a860f7" }, ] [package.optional-dependencies] @@ -753,7 +771,7 @@ requests = [ [[package]] name = "google-genai" version = "1.68.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "anyio" }, { name = "distro" }, @@ -766,105 +784,105 @@ dependencies = [ { name = "typing-extensions" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9c/2c/f059982dbcb658cc535c81bbcbe7e2c040d675f4b563b03cdb01018a4bc3/google_genai-1.68.0.tar.gz", hash = "sha256:ac30c0b8bc630f9372993a97e4a11dae0e36f2e10d7c55eacdca95a9fa14ca96", size = 511285, upload-time = "2026-03-18T01:03:18.243Z" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/9c/2c/f059982dbcb658cc535c81bbcbe7e2c040d675f4b563b03cdb01018a4bc3/google_genai-1.68.0.tar.gz", hash = "sha256:ac30c0b8bc630f9372993a97e4a11dae0e36f2e10d7c55eacdca95a9fa14ca96" } wheels = [ - { url = "https://files.pythonhosted.org/packages/84/de/7d3ee9c94b74c3578ea4f88d45e8de9405902f857932334d81e89bce3dfa/google_genai-1.68.0-py3-none-any.whl", hash = "sha256:a1bc9919c0e2ea2907d1e319b65471d3d6d58c54822039a249fe1323e4178d15", size = 750912, upload-time = "2026-03-18T01:03:15.983Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/84/de/7d3ee9c94b74c3578ea4f88d45e8de9405902f857932334d81e89bce3dfa/google_genai-1.68.0-py3-none-any.whl", hash = "sha256:a1bc9919c0e2ea2907d1e319b65471d3d6d58c54822039a249fe1323e4178d15" }, ] [[package]] name = "googleapis-common-protos" version = "1.73.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/99/96/a0205167fa0154f4a542fd6925bdc63d039d88dab3588b875078107e6f06/googleapis_common_protos-1.73.0.tar.gz", hash = "sha256:778d07cd4fbeff84c6f7c72102f0daf98fa2bfd3fa8bea426edc545588da0b5a", size = 147323, upload-time = "2026-03-06T21:53:09.727Z" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/99/96/a0205167fa0154f4a542fd6925bdc63d039d88dab3588b875078107e6f06/googleapis_common_protos-1.73.0.tar.gz", hash = "sha256:778d07cd4fbeff84c6f7c72102f0daf98fa2bfd3fa8bea426edc545588da0b5a" } wheels = [ - { url = "https://files.pythonhosted.org/packages/69/28/23eea8acd65972bbfe295ce3666b28ac510dfcb115fac089d3edb0feb00a/googleapis_common_protos-1.73.0-py3-none-any.whl", hash = "sha256:dfdaaa2e860f242046be561e6d6cb5c5f1541ae02cfbcb034371aadb2942b4e8", size = 297578, upload-time = "2026-03-06T21:52:33.933Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/69/28/23eea8acd65972bbfe295ce3666b28ac510dfcb115fac089d3edb0feb00a/googleapis_common_protos-1.73.0-py3-none-any.whl", hash = "sha256:dfdaaa2e860f242046be561e6d6cb5c5f1541ae02cfbcb034371aadb2942b4e8" }, ] [[package]] name = "greenlet" version = "3.3.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a3/51/1664f6b78fc6ebbd98019a1fd730e83fa78f2db7058f72b1463d3612b8db/greenlet-3.3.2.tar.gz", hash = "sha256:2eaf067fc6d886931c7962e8c6bede15d2f01965560f3359b27c80bde2d151f2", size = 188267, upload-time = "2026-02-20T20:54:15.531Z" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/a3/51/1664f6b78fc6ebbd98019a1fd730e83fa78f2db7058f72b1463d3612b8db/greenlet-3.3.2.tar.gz", hash = "sha256:2eaf067fc6d886931c7962e8c6bede15d2f01965560f3359b27c80bde2d151f2" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ea/ab/1608e5a7578e62113506740b88066bf09888322a311cff602105e619bd87/greenlet-3.3.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:ac8d61d4343b799d1e526db579833d72f23759c71e07181c2d2944e429eb09cd", size = 280358, upload-time = "2026-02-20T20:17:43.971Z" }, - { url = "https://files.pythonhosted.org/packages/a5/23/0eae412a4ade4e6623ff7626e38998cb9b11e9ff1ebacaa021e4e108ec15/greenlet-3.3.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ceec72030dae6ac0c8ed7591b96b70410a8be370b6a477b1dbc072856ad02bd", size = 601217, upload-time = "2026-02-20T20:47:31.462Z" }, - { url = "https://files.pythonhosted.org/packages/f8/16/5b1678a9c07098ecb9ab2dd159fafaf12e963293e61ee8d10ecb55273e5e/greenlet-3.3.2-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2a5be83a45ce6188c045bcc44b0ee037d6a518978de9a5d97438548b953a1ac", size = 611792, upload-time = "2026-02-20T20:55:58.423Z" }, - { url = "https://files.pythonhosted.org/packages/50/1f/5155f55bd71cabd03765a4aac9ac446be129895271f73872c36ebd4b04b6/greenlet-3.3.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e99d1749147ac21dde49b99c9abffcbc1e2d55c67501465ef0930d6e78e070", size = 613875, upload-time = "2026-02-20T20:21:01.102Z" }, - { url = "https://files.pythonhosted.org/packages/fc/dd/845f249c3fcd69e32df80cdab059b4be8b766ef5830a3d0aa9d6cad55beb/greenlet-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c956a19350e2c37f2c48b336a3afb4bff120b36076d9d7fb68cb44e05d95b79", size = 1571467, upload-time = "2026-02-20T20:49:33.495Z" }, - { url = "https://files.pythonhosted.org/packages/2a/50/2649fe21fcc2b56659a452868e695634722a6655ba245d9f77f5656010bf/greenlet-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c6f8ba97d17a1e7d664151284cb3315fc5f8353e75221ed4324f84eb162b395", size = 1640001, upload-time = "2026-02-20T20:21:09.154Z" }, - { url = "https://files.pythonhosted.org/packages/9b/40/cc802e067d02af8b60b6771cea7d57e21ef5e6659912814babb42b864713/greenlet-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:34308836d8370bddadb41f5a7ce96879b72e2fdfb4e87729330c6ab52376409f", size = 231081, upload-time = "2026-02-20T20:17:28.121Z" }, - { url = "https://files.pythonhosted.org/packages/58/2e/fe7f36ff1982d6b10a60d5e0740c759259a7d6d2e1dc41da6d96de32fff6/greenlet-3.3.2-cp312-cp312-win_arm64.whl", hash = "sha256:d3a62fa76a32b462a97198e4c9e99afb9ab375115e74e9a83ce180e7a496f643", size = 230331, upload-time = "2026-02-20T20:17:23.34Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ea/ab/1608e5a7578e62113506740b88066bf09888322a311cff602105e619bd87/greenlet-3.3.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:ac8d61d4343b799d1e526db579833d72f23759c71e07181c2d2944e429eb09cd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a5/23/0eae412a4ade4e6623ff7626e38998cb9b11e9ff1ebacaa021e4e108ec15/greenlet-3.3.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ceec72030dae6ac0c8ed7591b96b70410a8be370b6a477b1dbc072856ad02bd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f8/16/5b1678a9c07098ecb9ab2dd159fafaf12e963293e61ee8d10ecb55273e5e/greenlet-3.3.2-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2a5be83a45ce6188c045bcc44b0ee037d6a518978de9a5d97438548b953a1ac" }, + { url = "https://mirrors.aliyun.com/pypi/packages/50/1f/5155f55bd71cabd03765a4aac9ac446be129895271f73872c36ebd4b04b6/greenlet-3.3.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e99d1749147ac21dde49b99c9abffcbc1e2d55c67501465ef0930d6e78e070" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fc/dd/845f249c3fcd69e32df80cdab059b4be8b766ef5830a3d0aa9d6cad55beb/greenlet-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c956a19350e2c37f2c48b336a3afb4bff120b36076d9d7fb68cb44e05d95b79" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2a/50/2649fe21fcc2b56659a452868e695634722a6655ba245d9f77f5656010bf/greenlet-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c6f8ba97d17a1e7d664151284cb3315fc5f8353e75221ed4324f84eb162b395" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9b/40/cc802e067d02af8b60b6771cea7d57e21ef5e6659912814babb42b864713/greenlet-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:34308836d8370bddadb41f5a7ce96879b72e2fdfb4e87729330c6ab52376409f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/58/2e/fe7f36ff1982d6b10a60d5e0740c759259a7d6d2e1dc41da6d96de32fff6/greenlet-3.3.2-cp312-cp312-win_arm64.whl", hash = "sha256:d3a62fa76a32b462a97198e4c9e99afb9ab375115e74e9a83ce180e7a496f643" }, ] [[package]] name = "h11" version = "0.16.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1" } wheels = [ - { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86" }, ] [[package]] name = "hf-xet" version = "1.4.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/09/08/23c84a26716382c89151b5b447b4beb19e3345f3a93d3b73009a71a57ad3/hf_xet-1.4.2.tar.gz", hash = "sha256:b7457b6b482d9e0743bd116363239b1fa904a5e65deede350fbc0c4ea67c71ea", size = 672357, upload-time = "2026-03-13T06:58:51.077Z" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/09/08/23c84a26716382c89151b5b447b4beb19e3345f3a93d3b73009a71a57ad3/hf_xet-1.4.2.tar.gz", hash = "sha256:b7457b6b482d9e0743bd116363239b1fa904a5e65deede350fbc0c4ea67c71ea" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b4/86/b40b83a2ff03ef05c4478d2672b1fc2b9683ff870e2b25f4f3af240f2e7b/hf_xet-1.4.2-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:71f02d6e4cdd07f344f6844845d78518cc7186bd2bc52d37c3b73dc26a3b0bc5", size = 3800339, upload-time = "2026-03-13T06:58:36.245Z" }, - { url = "https://files.pythonhosted.org/packages/64/2e/af4475c32b4378b0e92a587adb1aa3ec53e3450fd3e5fe0372a874531c00/hf_xet-1.4.2-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:e9b38d876e94d4bdcf650778d6ebbaa791dd28de08db9736c43faff06ede1b5a", size = 3559664, upload-time = "2026-03-13T06:58:34.787Z" }, - { url = "https://files.pythonhosted.org/packages/3c/4c/781267da3188db679e601de18112021a5cb16506fe86b246e22c5401a9c4/hf_xet-1.4.2-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:77e8c180b7ef12d8a96739a4e1e558847002afe9ea63b6f6358b2271a8bdda1c", size = 4217422, upload-time = "2026-03-13T06:58:27.472Z" }, - { url = "https://files.pythonhosted.org/packages/68/47/d6cf4a39ecf6c7705f887a46f6ef5c8455b44ad9eb0d391aa7e8a2ff7fea/hf_xet-1.4.2-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:c3b3c6a882016b94b6c210957502ff7877802d0dbda8ad142c8595db8b944271", size = 3992847, upload-time = "2026-03-13T06:58:25.989Z" }, - { url = "https://files.pythonhosted.org/packages/2d/ef/e80815061abff54697239803948abc665c6b1d237102c174f4f7a9a5ffc5/hf_xet-1.4.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9d9a634cc929cfbaf2e1a50c0e532ae8c78fa98618426769480c58501e8c8ac2", size = 4193843, upload-time = "2026-03-13T06:58:44.59Z" }, - { url = "https://files.pythonhosted.org/packages/54/75/07f6aa680575d9646c4167db6407c41340cbe2357f5654c4e72a1b01ca14/hf_xet-1.4.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6b0932eb8b10317ea78b7da6bab172b17be03bbcd7809383d8d5abd6a2233e04", size = 4432751, upload-time = "2026-03-13T06:58:46.533Z" }, - { url = "https://files.pythonhosted.org/packages/cd/71/193eabd7e7d4b903c4aa983a215509c6114915a5a237525ec562baddb868/hf_xet-1.4.2-cp37-abi3-win_amd64.whl", hash = "sha256:ad185719fb2e8ac26f88c8100562dbf9dbdcc3d9d2add00faa94b5f106aea53f", size = 3671149, upload-time = "2026-03-13T06:58:57.07Z" }, - { url = "https://files.pythonhosted.org/packages/b4/7e/ccf239da366b37ba7f0b36095450efae4a64980bdc7ec2f51354205fdf39/hf_xet-1.4.2-cp37-abi3-win_arm64.whl", hash = "sha256:32c012286b581f783653e718c1862aea5b9eb140631685bb0c5e7012c8719a87", size = 3533426, upload-time = "2026-03-13T06:58:55.46Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b4/86/b40b83a2ff03ef05c4478d2672b1fc2b9683ff870e2b25f4f3af240f2e7b/hf_xet-1.4.2-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:71f02d6e4cdd07f344f6844845d78518cc7186bd2bc52d37c3b73dc26a3b0bc5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/64/2e/af4475c32b4378b0e92a587adb1aa3ec53e3450fd3e5fe0372a874531c00/hf_xet-1.4.2-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:e9b38d876e94d4bdcf650778d6ebbaa791dd28de08db9736c43faff06ede1b5a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3c/4c/781267da3188db679e601de18112021a5cb16506fe86b246e22c5401a9c4/hf_xet-1.4.2-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:77e8c180b7ef12d8a96739a4e1e558847002afe9ea63b6f6358b2271a8bdda1c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/68/47/d6cf4a39ecf6c7705f887a46f6ef5c8455b44ad9eb0d391aa7e8a2ff7fea/hf_xet-1.4.2-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:c3b3c6a882016b94b6c210957502ff7877802d0dbda8ad142c8595db8b944271" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2d/ef/e80815061abff54697239803948abc665c6b1d237102c174f4f7a9a5ffc5/hf_xet-1.4.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9d9a634cc929cfbaf2e1a50c0e532ae8c78fa98618426769480c58501e8c8ac2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/54/75/07f6aa680575d9646c4167db6407c41340cbe2357f5654c4e72a1b01ca14/hf_xet-1.4.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6b0932eb8b10317ea78b7da6bab172b17be03bbcd7809383d8d5abd6a2233e04" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cd/71/193eabd7e7d4b903c4aa983a215509c6114915a5a237525ec562baddb868/hf_xet-1.4.2-cp37-abi3-win_amd64.whl", hash = "sha256:ad185719fb2e8ac26f88c8100562dbf9dbdcc3d9d2add00faa94b5f106aea53f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b4/7e/ccf239da366b37ba7f0b36095450efae4a64980bdc7ec2f51354205fdf39/hf_xet-1.4.2-cp37-abi3-win_arm64.whl", hash = "sha256:32c012286b581f783653e718c1862aea5b9eb140631685bb0c5e7012c8719a87" }, ] [[package]] name = "httpcore" version = "1.0.9" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "certifi" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55" }, ] [[package]] name = "httptools" version = "0.7.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9" } wheels = [ - { url = "https://files.pythonhosted.org/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280, upload-time = "2025-10-10T03:54:39.274Z" }, - { url = "https://files.pythonhosted.org/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004, upload-time = "2025-10-10T03:54:40.403Z" }, - { url = "https://files.pythonhosted.org/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655, upload-time = "2025-10-10T03:54:41.347Z" }, - { url = "https://files.pythonhosted.org/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440, upload-time = "2025-10-10T03:54:42.452Z" }, - { url = "https://files.pythonhosted.org/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186, upload-time = "2025-10-10T03:54:43.937Z" }, - { url = "https://files.pythonhosted.org/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192, upload-time = "2025-10-10T03:54:45.003Z" }, - { url = "https://files.pythonhosted.org/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694, upload-time = "2025-10-10T03:54:45.923Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03" }, + { url = "https://mirrors.aliyun.com/pypi/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321" }, ] [[package]] name = "httpx" version = "0.28.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "anyio" }, { name = "certifi" }, { name = "httpcore" }, { name = "idna" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad" }, ] [package.optional-dependencies] @@ -875,16 +893,16 @@ socks = [ [[package]] name = "httpx-sse" version = "0.4.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc" }, ] [[package]] name = "huggingface-hub" version = "1.7.2" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "filelock" }, { name = "fsspec" }, @@ -896,57 +914,57 @@ dependencies = [ { name = "typer" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/19/15/eafc1c57bf0f8afffb243dcd4c0cceb785e956acc17bba4d9bf2ae21fc9c/huggingface_hub-1.7.2.tar.gz", hash = "sha256:7f7e294e9bbb822e025bdb2ada025fa4344d978175a7f78e824d86e35f7ab43b", size = 724684, upload-time = "2026-03-20T10:36:08.767Z" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/19/15/eafc1c57bf0f8afffb243dcd4c0cceb785e956acc17bba4d9bf2ae21fc9c/huggingface_hub-1.7.2.tar.gz", hash = "sha256:7f7e294e9bbb822e025bdb2ada025fa4344d978175a7f78e824d86e35f7ab43b" } wheels = [ - { url = "https://files.pythonhosted.org/packages/08/de/3ad061a05f74728927ded48c90b73521b9a9328c85d841bdefb30e01fb85/huggingface_hub-1.7.2-py3-none-any.whl", hash = "sha256:288f33a0a17b2a73a1359e2a5fd28d1becb2c121748c6173ab8643fb342c850e", size = 618036, upload-time = "2026-03-20T10:36:06.824Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/08/de/3ad061a05f74728927ded48c90b73521b9a9328c85d841bdefb30e01fb85/huggingface_hub-1.7.2-py3-none-any.whl", hash = "sha256:288f33a0a17b2a73a1359e2a5fd28d1becb2c121748c6173ab8643fb342c850e" }, ] [[package]] name = "humanfriendly" version = "10.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "pyreadline3", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cc/3f/2c29224acb2e2df4d2046e4c73ee2662023c58ff5b113c4c1adac0886c43/humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc", size = 360702, upload-time = "2021-09-17T21:40:43.31Z" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/cc/3f/2c29224acb2e2df4d2046e4c73ee2662023c58ff5b113c4c1adac0886c43/humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f0/0f/310fb31e39e2d734ccaa2c0fb981ee41f7bd5056ce9bc29b2248bd569169/humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477", size = 86794, upload-time = "2021-09-17T21:40:39.897Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f0/0f/310fb31e39e2d734ccaa2c0fb981ee41f7bd5056ce9bc29b2248bd569169/humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477" }, ] [[package]] name = "idna" version = "3.11" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea" }, ] [[package]] name = "importlib-metadata" version = "8.5.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "zipp" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cd/12/33e59336dca5be0c398a7482335911a33aa0e20776128f038019f1a95f1b/importlib_metadata-8.5.0.tar.gz", hash = "sha256:71522656f0abace1d072b9e5481a48f07c138e00f079c38c8f883823f9c26bd7", size = 55304, upload-time = "2024-09-11T14:56:08.937Z" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/cd/12/33e59336dca5be0c398a7482335911a33aa0e20776128f038019f1a95f1b/importlib_metadata-8.5.0.tar.gz", hash = "sha256:71522656f0abace1d072b9e5481a48f07c138e00f079c38c8f883823f9c26bd7" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/d9/a1e041c5e7caa9a05c925f4bdbdfb7f006d1f74996af53467bc394c97be7/importlib_metadata-8.5.0-py3-none-any.whl", hash = "sha256:45e54197d28b7a7f1559e60b95e7c567032b602131fbd588f1497f47880aa68b", size = 26514, upload-time = "2024-09-11T14:56:07.019Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a0/d9/a1e041c5e7caa9a05c925f4bdbdfb7f006d1f74996af53467bc394c97be7/importlib_metadata-8.5.0-py3-none-any.whl", hash = "sha256:45e54197d28b7a7f1559e60b95e7c567032b602131fbd588f1497f47880aa68b" }, ] [[package]] name = "iniconfig" version = "2.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12" }, ] [[package]] name = "ipython" version = "9.11.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, { name = "decorator" }, @@ -959,103 +977,103 @@ dependencies = [ { name = "stack-data" }, { name = "traitlets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/86/28/a4698eda5a8928a45d6b693578b135b753e14fa1c2b36ee9441e69a45576/ipython-9.11.0.tar.gz", hash = "sha256:2a94bc4406b22ecc7e4cb95b98450f3ea493a76bec8896cda11b78d7752a6667", size = 4427354, upload-time = "2026-03-05T08:57:30.549Z" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/86/28/a4698eda5a8928a45d6b693578b135b753e14fa1c2b36ee9441e69a45576/ipython-9.11.0.tar.gz", hash = "sha256:2a94bc4406b22ecc7e4cb95b98450f3ea493a76bec8896cda11b78d7752a6667" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b2/90/45c72becc57158facc6a6404f663b77bbcea2519ca57f760e2879ae1315d/ipython-9.11.0-py3-none-any.whl", hash = "sha256:6922d5bcf944c6e525a76a0a304451b60a2b6f875e86656d8bc2dfda5d710e19", size = 624222, upload-time = "2026-03-05T08:57:28.94Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b2/90/45c72becc57158facc6a6404f663b77bbcea2519ca57f760e2879ae1315d/ipython-9.11.0-py3-none-any.whl", hash = "sha256:6922d5bcf944c6e525a76a0a304451b60a2b6f875e86656d8bc2dfda5d710e19" }, ] [[package]] name = "ipython-pygments-lexers" version = "1.1.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074, upload-time = "2025-01-17T11:24:33.271Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c" }, ] [[package]] name = "jedi" version = "0.19.2" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "parso" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/72/3a/79a912fbd4d8dd6fbb02bf69afd3bb72cf0c729bb3063c6f4498603db17a/jedi-0.19.2.tar.gz", hash = "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0", size = 1231287, upload-time = "2024-11-11T01:41:42.873Z" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/72/3a/79a912fbd4d8dd6fbb02bf69afd3bb72cf0c729bb3063c6f4498603db17a/jedi-0.19.2.tar.gz", hash = "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9", size = 1572278, upload-time = "2024-11-11T01:41:40.175Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9" }, ] [[package]] name = "jinja2" version = "3.1.6" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "markupsafe" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d" } wheels = [ - { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67" }, ] [[package]] name = "jiter" version = "0.13.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0d/5e/4ec91646aee381d01cdb9974e30882c9cd3b8c5d1079d6b5ff4af522439a/jiter-0.13.0.tar.gz", hash = "sha256:f2839f9c2c7e2dffc1bc5929a510e14ce0a946be9365fd1219e7ef342dae14f4", size = 164847, upload-time = "2026-02-02T12:37:56.441Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2e/30/7687e4f87086829955013ca12a9233523349767f69653ebc27036313def9/jiter-0.13.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0a2bd69fc1d902e89925fc34d1da51b2128019423d7b339a45d9e99c894e0663", size = 307958, upload-time = "2026-02-02T12:35:57.165Z" }, - { url = "https://files.pythonhosted.org/packages/c3/27/e57f9a783246ed95481e6749cc5002a8a767a73177a83c63ea71f0528b90/jiter-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f917a04240ef31898182f76a332f508f2cc4b57d2b4d7ad2dbfebbfe167eb505", size = 318597, upload-time = "2026-02-02T12:35:58.591Z" }, - { url = "https://files.pythonhosted.org/packages/cf/52/e5719a60ac5d4d7c5995461a94ad5ef962a37c8bf5b088390e6fad59b2ff/jiter-0.13.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1e2b199f446d3e82246b4fd9236d7cb502dc2222b18698ba0d986d2fecc6152", size = 348821, upload-time = "2026-02-02T12:36:00.093Z" }, - { url = "https://files.pythonhosted.org/packages/61/db/c1efc32b8ba4c740ab3fc2d037d8753f67685f475e26b9d6536a4322bcdd/jiter-0.13.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:04670992b576fa65bd056dbac0c39fe8bd67681c380cb2b48efa885711d9d726", size = 364163, upload-time = "2026-02-02T12:36:01.937Z" }, - { url = "https://files.pythonhosted.org/packages/55/8a/fb75556236047c8806995671a18e4a0ad646ed255276f51a20f32dceaeec/jiter-0.13.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a1aff1fbdb803a376d4d22a8f63f8e7ccbce0b4890c26cc7af9e501ab339ef0", size = 483709, upload-time = "2026-02-02T12:36:03.41Z" }, - { url = "https://files.pythonhosted.org/packages/7e/16/43512e6ee863875693a8e6f6d532e19d650779d6ba9a81593ae40a9088ff/jiter-0.13.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b3fb8c2053acaef8580809ac1d1f7481a0a0bdc012fd7f5d8b18fb696a5a089", size = 370480, upload-time = "2026-02-02T12:36:04.791Z" }, - { url = "https://files.pythonhosted.org/packages/f8/4c/09b93e30e984a187bc8aaa3510e1ec8dcbdcd71ca05d2f56aac0492453aa/jiter-0.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bdaba7d87e66f26a2c45d8cbadcbfc4bf7884182317907baf39cfe9775bb4d93", size = 360735, upload-time = "2026-02-02T12:36:06.994Z" }, - { url = "https://files.pythonhosted.org/packages/1a/1b/46c5e349019874ec5dfa508c14c37e29864ea108d376ae26d90bee238cd7/jiter-0.13.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7b88d649135aca526da172e48083da915ec086b54e8e73a425ba50999468cc08", size = 391814, upload-time = "2026-02-02T12:36:08.368Z" }, - { url = "https://files.pythonhosted.org/packages/15/9e/26184760e85baee7162ad37b7912797d2077718476bf91517641c92b3639/jiter-0.13.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:e404ea551d35438013c64b4f357b0474c7abf9f781c06d44fcaf7a14c69ff9e2", size = 513990, upload-time = "2026-02-02T12:36:09.993Z" }, - { url = "https://files.pythonhosted.org/packages/e9/34/2c9355247d6debad57a0a15e76ab1566ab799388042743656e566b3b7de1/jiter-0.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1f4748aad1b4a93c8bdd70f604d0f748cdc0e8744c5547798acfa52f10e79228", size = 548021, upload-time = "2026-02-02T12:36:11.376Z" }, - { url = "https://files.pythonhosted.org/packages/ac/4a/9f2c23255d04a834398b9c2e0e665382116911dc4d06b795710503cdad25/jiter-0.13.0-cp312-cp312-win32.whl", hash = "sha256:0bf670e3b1445fc4d31612199f1744f67f889ee1bbae703c4b54dc097e5dd394", size = 203024, upload-time = "2026-02-02T12:36:12.682Z" }, - { url = "https://files.pythonhosted.org/packages/09/ee/f0ae675a957ae5a8f160be3e87acea6b11dc7b89f6b7ab057e77b2d2b13a/jiter-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:15db60e121e11fe186c0b15236bd5d18381b9ddacdcf4e659feb96fc6c969c92", size = 205424, upload-time = "2026-02-02T12:36:13.93Z" }, - { url = "https://files.pythonhosted.org/packages/1b/02/ae611edf913d3cbf02c97cdb90374af2082c48d7190d74c1111dde08bcdd/jiter-0.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:41f92313d17989102f3cb5dd533a02787cdb99454d494344b0361355da52fcb9", size = 186818, upload-time = "2026-02-02T12:36:15.308Z" }, - { url = "https://files.pythonhosted.org/packages/80/60/e50fa45dd7e2eae049f0ce964663849e897300433921198aef94b6ffa23a/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:3d744a6061afba08dd7ae375dcde870cffb14429b7477e10f67e9e6d68772a0a", size = 305169, upload-time = "2026-02-02T12:37:50.376Z" }, - { url = "https://files.pythonhosted.org/packages/d2/73/a009f41c5eed71c49bec53036c4b33555afcdee70682a18c6f66e396c039/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:ff732bd0a0e778f43d5009840f20b935e79087b4dc65bd36f1cd0f9b04b8ff7f", size = 303808, upload-time = "2026-02-02T12:37:52.092Z" }, - { url = "https://files.pythonhosted.org/packages/c4/10/528b439290763bff3d939268085d03382471b442f212dca4ff5f12802d43/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab44b178f7981fcaea7e0a5df20e773c663d06ffda0198f1a524e91b2fde7e59", size = 337384, upload-time = "2026-02-02T12:37:53.582Z" }, - { url = "https://files.pythonhosted.org/packages/67/8a/a342b2f0251f3dac4ca17618265d93bf244a2a4d089126e81e4c1056ac50/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bb00b6d26db67a05fe3e12c76edc75f32077fb51deed13822dc648fa373bc19", size = 343768, upload-time = "2026-02-02T12:37:55.055Z" }, +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/0d/5e/4ec91646aee381d01cdb9974e30882c9cd3b8c5d1079d6b5ff4af522439a/jiter-0.13.0.tar.gz", hash = "sha256:f2839f9c2c7e2dffc1bc5929a510e14ce0a946be9365fd1219e7ef342dae14f4" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/2e/30/7687e4f87086829955013ca12a9233523349767f69653ebc27036313def9/jiter-0.13.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0a2bd69fc1d902e89925fc34d1da51b2128019423d7b339a45d9e99c894e0663" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c3/27/e57f9a783246ed95481e6749cc5002a8a767a73177a83c63ea71f0528b90/jiter-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f917a04240ef31898182f76a332f508f2cc4b57d2b4d7ad2dbfebbfe167eb505" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cf/52/e5719a60ac5d4d7c5995461a94ad5ef962a37c8bf5b088390e6fad59b2ff/jiter-0.13.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1e2b199f446d3e82246b4fd9236d7cb502dc2222b18698ba0d986d2fecc6152" }, + { url = "https://mirrors.aliyun.com/pypi/packages/61/db/c1efc32b8ba4c740ab3fc2d037d8753f67685f475e26b9d6536a4322bcdd/jiter-0.13.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:04670992b576fa65bd056dbac0c39fe8bd67681c380cb2b48efa885711d9d726" }, + { url = "https://mirrors.aliyun.com/pypi/packages/55/8a/fb75556236047c8806995671a18e4a0ad646ed255276f51a20f32dceaeec/jiter-0.13.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a1aff1fbdb803a376d4d22a8f63f8e7ccbce0b4890c26cc7af9e501ab339ef0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7e/16/43512e6ee863875693a8e6f6d532e19d650779d6ba9a81593ae40a9088ff/jiter-0.13.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b3fb8c2053acaef8580809ac1d1f7481a0a0bdc012fd7f5d8b18fb696a5a089" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f8/4c/09b93e30e984a187bc8aaa3510e1ec8dcbdcd71ca05d2f56aac0492453aa/jiter-0.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bdaba7d87e66f26a2c45d8cbadcbfc4bf7884182317907baf39cfe9775bb4d93" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1a/1b/46c5e349019874ec5dfa508c14c37e29864ea108d376ae26d90bee238cd7/jiter-0.13.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7b88d649135aca526da172e48083da915ec086b54e8e73a425ba50999468cc08" }, + { url = "https://mirrors.aliyun.com/pypi/packages/15/9e/26184760e85baee7162ad37b7912797d2077718476bf91517641c92b3639/jiter-0.13.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:e404ea551d35438013c64b4f357b0474c7abf9f781c06d44fcaf7a14c69ff9e2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e9/34/2c9355247d6debad57a0a15e76ab1566ab799388042743656e566b3b7de1/jiter-0.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1f4748aad1b4a93c8bdd70f604d0f748cdc0e8744c5547798acfa52f10e79228" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ac/4a/9f2c23255d04a834398b9c2e0e665382116911dc4d06b795710503cdad25/jiter-0.13.0-cp312-cp312-win32.whl", hash = "sha256:0bf670e3b1445fc4d31612199f1744f67f889ee1bbae703c4b54dc097e5dd394" }, + { url = "https://mirrors.aliyun.com/pypi/packages/09/ee/f0ae675a957ae5a8f160be3e87acea6b11dc7b89f6b7ab057e77b2d2b13a/jiter-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:15db60e121e11fe186c0b15236bd5d18381b9ddacdcf4e659feb96fc6c969c92" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1b/02/ae611edf913d3cbf02c97cdb90374af2082c48d7190d74c1111dde08bcdd/jiter-0.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:41f92313d17989102f3cb5dd533a02787cdb99454d494344b0361355da52fcb9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/80/60/e50fa45dd7e2eae049f0ce964663849e897300433921198aef94b6ffa23a/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:3d744a6061afba08dd7ae375dcde870cffb14429b7477e10f67e9e6d68772a0a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d2/73/a009f41c5eed71c49bec53036c4b33555afcdee70682a18c6f66e396c039/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:ff732bd0a0e778f43d5009840f20b935e79087b4dc65bd36f1cd0f9b04b8ff7f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c4/10/528b439290763bff3d939268085d03382471b442f212dca4ff5f12802d43/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab44b178f7981fcaea7e0a5df20e773c663d06ffda0198f1a524e91b2fde7e59" }, + { url = "https://mirrors.aliyun.com/pypi/packages/67/8a/a342b2f0251f3dac4ca17618265d93bf244a2a4d089126e81e4c1056ac50/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bb00b6d26db67a05fe3e12c76edc75f32077fb51deed13822dc648fa373bc19" }, ] [[package]] name = "jsonschema" version = "4.23.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "attrs" }, { name = "jsonschema-specifications" }, { name = "referencing" }, { name = "rpds-py" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/38/2e/03362ee4034a4c917f697890ccd4aec0800ccf9ded7f511971c75451deec/jsonschema-4.23.0.tar.gz", hash = "sha256:d71497fef26351a33265337fa77ffeb82423f3ea21283cd9467bb03999266bc4", size = 325778, upload-time = "2024-07-08T18:40:05.546Z" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/38/2e/03362ee4034a4c917f697890ccd4aec0800ccf9ded7f511971c75451deec/jsonschema-4.23.0.tar.gz", hash = "sha256:d71497fef26351a33265337fa77ffeb82423f3ea21283cd9467bb03999266bc4" } wheels = [ - { url = "https://files.pythonhosted.org/packages/69/4a/4f9dbeb84e8850557c02365a0eee0649abe5eb1d84af92a25731c6c0f922/jsonschema-4.23.0-py3-none-any.whl", hash = "sha256:fbadb6f8b144a8f8cf9f0b89ba94501d143e50411a1278633f56a7acf7fd5566", size = 88462, upload-time = "2024-07-08T18:40:00.165Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/69/4a/4f9dbeb84e8850557c02365a0eee0649abe5eb1d84af92a25731c6c0f922/jsonschema-4.23.0-py3-none-any.whl", hash = "sha256:fbadb6f8b144a8f8cf9f0b89ba94501d143e50411a1278633f56a7acf7fd5566" }, ] [[package]] name = "jsonschema-specifications" version = "2025.9.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "referencing" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d" } wheels = [ - { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe" }, ] [[package]] name = "langfuse" version = "4.0.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "backoff" }, { name = "httpx" }, @@ -1067,15 +1085,15 @@ dependencies = [ { name = "pydantic" }, { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c9/94/ab00e21fa5977d6b9c68fb3a95de2aa1a1e586964ff2af3e37405bf65d9f/langfuse-4.0.1.tar.gz", hash = "sha256:40a6daf3ab505945c314246d5b577d48fcfde0a47e8c05267ea6bd494ae9608e", size = 272749, upload-time = "2026-03-19T14:03:34.508Z" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/c9/94/ab00e21fa5977d6b9c68fb3a95de2aa1a1e586964ff2af3e37405bf65d9f/langfuse-4.0.1.tar.gz", hash = "sha256:40a6daf3ab505945c314246d5b577d48fcfde0a47e8c05267ea6bd494ae9608e" } wheels = [ - { url = "https://files.pythonhosted.org/packages/27/8f/3145ef00940f9c29d7e0200fd040f35616eac21c6ab4610a1ba14f3a04c1/langfuse-4.0.1-py3-none-any.whl", hash = "sha256:e22f49ea31304f97fc31a97c014ba63baa8802d9568295d54f06b00b43c30524", size = 465049, upload-time = "2026-03-19T14:03:32.527Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/27/8f/3145ef00940f9c29d7e0200fd040f35616eac21c6ab4610a1ba14f3a04c1/langfuse-4.0.1-py3-none-any.whl", hash = "sha256:e22f49ea31304f97fc31a97c014ba63baa8802d9568295d54f06b00b43c30524" }, ] [[package]] name = "lark-oapi" version = "1.5.3" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "httpx" }, { name = "pycryptodome" }, @@ -1084,34 +1102,34 @@ dependencies = [ { name = "websockets" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/bf/ff/2ece5d735ebfa2af600a53176f2636ae47af2bf934e08effab64f0d1e047/lark_oapi-1.5.3-py3-none-any.whl", hash = "sha256:fda6b32bb38d21b6bdaae94979c600b94c7c521e985adade63a54e4b3e20cc36", size = 6993016, upload-time = "2026-01-27T08:21:49.307Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bf/ff/2ece5d735ebfa2af600a53176f2636ae47af2bf934e08effab64f0d1e047/lark_oapi-1.5.3-py3-none-any.whl", hash = "sha256:fda6b32bb38d21b6bdaae94979c600b94c7c521e985adade63a54e4b3e20cc36" }, ] [[package]] name = "librt" version = "0.8.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/56/9c/b4b0c54d84da4a94b37bd44151e46d5e583c9534c7e02250b961b1b6d8a8/librt-0.8.1.tar.gz", hash = "sha256:be46a14693955b3bd96014ccbdb8339ee8c9346fbe11c1b78901b55125f14c73", size = 177471, upload-time = "2026-02-17T16:13:06.101Z" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/56/9c/b4b0c54d84da4a94b37bd44151e46d5e583c9534c7e02250b961b1b6d8a8/librt-0.8.1.tar.gz", hash = "sha256:be46a14693955b3bd96014ccbdb8339ee8c9346fbe11c1b78901b55125f14c73" } wheels = [ - { url = "https://files.pythonhosted.org/packages/95/21/d39b0a87ac52fc98f621fb6f8060efb017a767ebbbac2f99fbcbc9ddc0d7/librt-0.8.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a28f2612ab566b17f3698b0da021ff9960610301607c9a5e8eaca62f5e1c350a", size = 66516, upload-time = "2026-02-17T16:11:41.604Z" }, - { url = "https://files.pythonhosted.org/packages/69/f1/46375e71441c43e8ae335905e069f1c54febee63a146278bcee8782c84fd/librt-0.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:60a78b694c9aee2a0f1aaeaa7d101cf713e92e8423a941d2897f4fa37908dab9", size = 68634, upload-time = "2026-02-17T16:11:43.268Z" }, - { url = "https://files.pythonhosted.org/packages/0a/33/c510de7f93bf1fa19e13423a606d8189a02624a800710f6e6a0a0f0784b3/librt-0.8.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:758509ea3f1eba2a57558e7e98f4659d0ea7670bff49673b0dde18a3c7e6c0eb", size = 198941, upload-time = "2026-02-17T16:11:44.28Z" }, - { url = "https://files.pythonhosted.org/packages/dd/36/e725903416409a533d92398e88ce665476f275081d0d7d42f9c4951999e5/librt-0.8.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:039b9f2c506bd0ab0f8725aa5ba339c6f0cd19d3b514b50d134789809c24285d", size = 209991, upload-time = "2026-02-17T16:11:45.462Z" }, - { url = "https://files.pythonhosted.org/packages/30/7a/8d908a152e1875c9f8eac96c97a480df425e657cdb47854b9efaa4998889/librt-0.8.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bb54f1205a3a6ab41a6fd71dfcdcbd278670d3a90ca502a30d9da583105b6f7", size = 224476, upload-time = "2026-02-17T16:11:46.542Z" }, - { url = "https://files.pythonhosted.org/packages/a8/b8/a22c34f2c485b8903a06f3fe3315341fe6876ef3599792344669db98fcff/librt-0.8.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:05bd41cdee35b0c59c259f870f6da532a2c5ca57db95b5f23689fcb5c9e42440", size = 217518, upload-time = "2026-02-17T16:11:47.746Z" }, - { url = "https://files.pythonhosted.org/packages/79/6f/5c6fea00357e4f82ba44f81dbfb027921f1ab10e320d4a64e1c408d035d9/librt-0.8.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adfab487facf03f0d0857b8710cf82d0704a309d8ffc33b03d9302b4c64e91a9", size = 225116, upload-time = "2026-02-17T16:11:49.298Z" }, - { url = "https://files.pythonhosted.org/packages/f2/a0/95ced4e7b1267fe1e2720a111685bcddf0e781f7e9e0ce59d751c44dcfe5/librt-0.8.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:153188fe98a72f206042be10a2c6026139852805215ed9539186312d50a8e972", size = 217751, upload-time = "2026-02-17T16:11:50.49Z" }, - { url = "https://files.pythonhosted.org/packages/93/c2/0517281cb4d4101c27ab59472924e67f55e375bc46bedae94ac6dc6e1902/librt-0.8.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dd3c41254ee98604b08bd5b3af5bf0a89740d4ee0711de95b65166bf44091921", size = 218378, upload-time = "2026-02-17T16:11:51.783Z" }, - { url = "https://files.pythonhosted.org/packages/43/e8/37b3ac108e8976888e559a7b227d0ceac03c384cfd3e7a1c2ee248dbae79/librt-0.8.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e0d138c7ae532908cbb342162b2611dbd4d90c941cd25ab82084aaf71d2c0bd0", size = 241199, upload-time = "2026-02-17T16:11:53.561Z" }, - { url = "https://files.pythonhosted.org/packages/4b/5b/35812d041c53967fedf551a39399271bbe4257e681236a2cf1a69c8e7fa1/librt-0.8.1-cp312-cp312-win32.whl", hash = "sha256:43353b943613c5d9c49a25aaffdba46f888ec354e71e3529a00cca3f04d66a7a", size = 54917, upload-time = "2026-02-17T16:11:54.758Z" }, - { url = "https://files.pythonhosted.org/packages/de/d1/fa5d5331b862b9775aaf2a100f5ef86854e5d4407f71bddf102f4421e034/librt-0.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:ff8baf1f8d3f4b6b7257fcb75a501f2a5499d0dda57645baa09d4d0d34b19444", size = 62017, upload-time = "2026-02-17T16:11:55.748Z" }, - { url = "https://files.pythonhosted.org/packages/c7/7c/c614252f9acda59b01a66e2ddfd243ed1c7e1deab0293332dfbccf862808/librt-0.8.1-cp312-cp312-win_arm64.whl", hash = "sha256:0f2ae3725904f7377e11cc37722d5d401e8b3d5851fb9273d7f4fe04f6b3d37d", size = 52441, upload-time = "2026-02-17T16:11:56.801Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/95/21/d39b0a87ac52fc98f621fb6f8060efb017a767ebbbac2f99fbcbc9ddc0d7/librt-0.8.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a28f2612ab566b17f3698b0da021ff9960610301607c9a5e8eaca62f5e1c350a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/69/f1/46375e71441c43e8ae335905e069f1c54febee63a146278bcee8782c84fd/librt-0.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:60a78b694c9aee2a0f1aaeaa7d101cf713e92e8423a941d2897f4fa37908dab9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0a/33/c510de7f93bf1fa19e13423a606d8189a02624a800710f6e6a0a0f0784b3/librt-0.8.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:758509ea3f1eba2a57558e7e98f4659d0ea7670bff49673b0dde18a3c7e6c0eb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/dd/36/e725903416409a533d92398e88ce665476f275081d0d7d42f9c4951999e5/librt-0.8.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:039b9f2c506bd0ab0f8725aa5ba339c6f0cd19d3b514b50d134789809c24285d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/30/7a/8d908a152e1875c9f8eac96c97a480df425e657cdb47854b9efaa4998889/librt-0.8.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bb54f1205a3a6ab41a6fd71dfcdcbd278670d3a90ca502a30d9da583105b6f7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a8/b8/a22c34f2c485b8903a06f3fe3315341fe6876ef3599792344669db98fcff/librt-0.8.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:05bd41cdee35b0c59c259f870f6da532a2c5ca57db95b5f23689fcb5c9e42440" }, + { url = "https://mirrors.aliyun.com/pypi/packages/79/6f/5c6fea00357e4f82ba44f81dbfb027921f1ab10e320d4a64e1c408d035d9/librt-0.8.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adfab487facf03f0d0857b8710cf82d0704a309d8ffc33b03d9302b4c64e91a9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f2/a0/95ced4e7b1267fe1e2720a111685bcddf0e781f7e9e0ce59d751c44dcfe5/librt-0.8.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:153188fe98a72f206042be10a2c6026139852805215ed9539186312d50a8e972" }, + { url = "https://mirrors.aliyun.com/pypi/packages/93/c2/0517281cb4d4101c27ab59472924e67f55e375bc46bedae94ac6dc6e1902/librt-0.8.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dd3c41254ee98604b08bd5b3af5bf0a89740d4ee0711de95b65166bf44091921" }, + { url = "https://mirrors.aliyun.com/pypi/packages/43/e8/37b3ac108e8976888e559a7b227d0ceac03c384cfd3e7a1c2ee248dbae79/librt-0.8.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e0d138c7ae532908cbb342162b2611dbd4d90c941cd25ab82084aaf71d2c0bd0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4b/5b/35812d041c53967fedf551a39399271bbe4257e681236a2cf1a69c8e7fa1/librt-0.8.1-cp312-cp312-win32.whl", hash = "sha256:43353b943613c5d9c49a25aaffdba46f888ec354e71e3529a00cca3f04d66a7a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/de/d1/fa5d5331b862b9775aaf2a100f5ef86854e5d4407f71bddf102f4421e034/librt-0.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:ff8baf1f8d3f4b6b7257fcb75a501f2a5499d0dda57645baa09d4d0d34b19444" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c7/7c/c614252f9acda59b01a66e2ddfd243ed1c7e1deab0293332dfbccf862808/librt-0.8.1-cp312-cp312-win_arm64.whl", hash = "sha256:0f2ae3725904f7377e11cc37722d5d401e8b3d5851fb9273d7f4fe04f6b3d37d" }, ] [[package]] name = "litellm" version = "1.83.7" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "aiohttp" }, { name = "click" }, @@ -1126,80 +1144,80 @@ dependencies = [ { name = "tiktoken" }, { name = "tokenizers" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/77/2b/b58bf6bbcbc3d0e55d0a84fdf9128e5b1436517f46fce89b1cd8948ebb81/litellm-1.83.7.tar.gz", hash = "sha256:e2f2cb99df2e2b2eab63f1354faa45c88dd7c8d40c18eb648afb1b349c689633", size = 17791694, upload-time = "2026-04-13T17:35:01.606Z" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/77/2b/b58bf6bbcbc3d0e55d0a84fdf9128e5b1436517f46fce89b1cd8948ebb81/litellm-1.83.7.tar.gz", hash = "sha256:e2f2cb99df2e2b2eab63f1354faa45c88dd7c8d40c18eb648afb1b349c689633" } wheels = [ - { url = "https://files.pythonhosted.org/packages/75/80/caeb4cdcad96451ba83ad3ba2a9da08b1e1a915fa845c489f56ea044488b/litellm-1.83.7-py3-none-any.whl", hash = "sha256:5784a1d9a9a4a8acd6ca1e347003a5e2e1b3c749b4d41e7da4904577adade111", size = 16069807, upload-time = "2026-04-13T17:34:58.36Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/75/80/caeb4cdcad96451ba83ad3ba2a9da08b1e1a915fa845c489f56ea044488b/litellm-1.83.7-py3-none-any.whl", hash = "sha256:5784a1d9a9a4a8acd6ca1e347003a5e2e1b3c749b4d41e7da4904577adade111" }, ] [[package]] name = "lsprotocol" version = "2025.0.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "attrs" }, { name = "cattrs" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e9/26/67b84e6ec1402f0e6764ef3d2a0aaf9a79522cc1d37738f4e5bb0b21521a/lsprotocol-2025.0.0.tar.gz", hash = "sha256:e879da2b9301e82cfc3e60d805630487ac2f7ab17492f4f5ba5aaba94fe56c29", size = 74896, upload-time = "2025-06-17T21:30:18.156Z" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/e9/26/67b84e6ec1402f0e6764ef3d2a0aaf9a79522cc1d37738f4e5bb0b21521a/lsprotocol-2025.0.0.tar.gz", hash = "sha256:e879da2b9301e82cfc3e60d805630487ac2f7ab17492f4f5ba5aaba94fe56c29" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7b/f0/92f2d609d6642b5f30cb50a885d2bf1483301c69d5786286500d15651ef2/lsprotocol-2025.0.0-py3-none-any.whl", hash = "sha256:f9d78f25221f2a60eaa4a96d3b4ffae011b107537facee61d3da3313880995c7", size = 76250, upload-time = "2025-06-17T21:30:19.455Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7b/f0/92f2d609d6642b5f30cb50a885d2bf1483301c69d5786286500d15651ef2/lsprotocol-2025.0.0-py3-none-any.whl", hash = "sha256:f9d78f25221f2a60eaa4a96d3b4ffae011b107537facee61d3da3313880995c7" }, ] [[package]] name = "magika" version = "0.6.3" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "click" }, { name = "numpy" }, { name = "onnxruntime" }, { name = "python-dotenv" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a3/f3/3d1dcdd7b9c41d589f5cff252d32ed91cdf86ba84391cfc81d9d8773571d/magika-0.6.3.tar.gz", hash = "sha256:7cc52aa7359af861957043e2bf7265ed4741067251c104532765cd668c0c0cb1", size = 3042784, upload-time = "2025-10-30T15:22:34.499Z" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/a3/f3/3d1dcdd7b9c41d589f5cff252d32ed91cdf86ba84391cfc81d9d8773571d/magika-0.6.3.tar.gz", hash = "sha256:7cc52aa7359af861957043e2bf7265ed4741067251c104532765cd668c0c0cb1" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a2/e4/35c323beb3280482c94299d61626116856ac2d4ec16ecef50afc4fdd4291/magika-0.6.3-py3-none-any.whl", hash = "sha256:eda443d08006ee495e02083b32e51b98cb3696ab595a7d13900d8e2ef506ec9d", size = 2969474, upload-time = "2025-10-30T15:22:25.298Z" }, - { url = "https://files.pythonhosted.org/packages/25/8f/132b0d7cd51c02c39fd52658a5896276c30c8cc2fd453270b19db8c40f7e/magika-0.6.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:86901e64b05dde5faff408c9b8245495b2e1fd4c226e3393d3d2a3fee65c504b", size = 13358841, upload-time = "2025-10-30T15:22:27.413Z" }, - { url = "https://files.pythonhosted.org/packages/c4/03/5ed859be502903a68b7b393b17ae0283bf34195cfcca79ce2dc25b9290e7/magika-0.6.3-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:3d9661eedbdf445ac9567e97e7ceefb93545d77a6a32858139ea966b5806fb64", size = 15367335, upload-time = "2025-10-30T15:22:29.907Z" }, - { url = "https://files.pythonhosted.org/packages/7b/9e/f8ee7d644affa3b80efdd623a3d75865c8f058f3950cb87fb0c48e3559bc/magika-0.6.3-py3-none-win_amd64.whl", hash = "sha256:e57f75674447b20cab4db928ae58ab264d7d8582b55183a0b876711c2b2787f3", size = 12692831, upload-time = "2025-10-30T15:22:32.063Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a2/e4/35c323beb3280482c94299d61626116856ac2d4ec16ecef50afc4fdd4291/magika-0.6.3-py3-none-any.whl", hash = "sha256:eda443d08006ee495e02083b32e51b98cb3696ab595a7d13900d8e2ef506ec9d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/25/8f/132b0d7cd51c02c39fd52658a5896276c30c8cc2fd453270b19db8c40f7e/magika-0.6.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:86901e64b05dde5faff408c9b8245495b2e1fd4c226e3393d3d2a3fee65c504b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c4/03/5ed859be502903a68b7b393b17ae0283bf34195cfcca79ce2dc25b9290e7/magika-0.6.3-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:3d9661eedbdf445ac9567e97e7ceefb93545d77a6a32858139ea966b5806fb64" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7b/9e/f8ee7d644affa3b80efdd623a3d75865c8f058f3950cb87fb0c48e3559bc/magika-0.6.3-py3-none-win_amd64.whl", hash = "sha256:e57f75674447b20cab4db928ae58ab264d7d8582b55183a0b876711c2b2787f3" }, ] [[package]] name = "markdown" version = "3.10.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805, upload-time = "2026-02-09T14:57:26.942Z" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950" } wheels = [ - { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36" }, ] [[package]] name = "markdown-it-py" version = "4.0.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "mdurl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3" } wheels = [ - { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147" }, ] [[package]] name = "markdownify" version = "1.2.2" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "beautifulsoup4" }, { name = "six" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3f/bc/c8c8eea5335341306b0fa7e1cb33c5e1c8d24ef70ddd684da65f41c49c92/markdownify-1.2.2.tar.gz", hash = "sha256:b274f1b5943180b031b699b199cbaeb1e2ac938b75851849a31fd0c3d6603d09", size = 18816, upload-time = "2025-11-16T19:21:18.565Z" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/3f/bc/c8c8eea5335341306b0fa7e1cb33c5e1c8d24ef70ddd684da65f41c49c92/markdownify-1.2.2.tar.gz", hash = "sha256:b274f1b5943180b031b699b199cbaeb1e2ac938b75851849a31fd0c3d6603d09" } wheels = [ - { url = "https://files.pythonhosted.org/packages/43/ce/f1e3e9d959db134cedf06825fae8d5b294bd368aacdd0831a3975b7c4d55/markdownify-1.2.2-py3-none-any.whl", hash = "sha256:3f02d3cc52714084d6e589f70397b6fc9f2f3a8531481bf35e8cc39f975e186a", size = 15724, upload-time = "2025-11-16T19:21:17.622Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/43/ce/f1e3e9d959db134cedf06825fae8d5b294bd368aacdd0831a3975b7c4d55/markdownify-1.2.2-py3-none-any.whl", hash = "sha256:3f02d3cc52714084d6e589f70397b6fc9f2f3a8531481bf35e8cc39f975e186a" }, ] [[package]] name = "markitdown" version = "0.1.5" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "beautifulsoup4" }, { name = "charset-normalizer" }, @@ -1208,46 +1226,46 @@ dependencies = [ { name = "markdownify" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/83/93/3b93c291c99d09f64f7535ba74c1c6a3507cf49cffd38983a55de6f834b6/markitdown-0.1.5.tar.gz", hash = "sha256:4c956ff1528bf15e1814542035ec96e989206d19d311bb799f4df973ecafc31a", size = 45099, upload-time = "2026-02-20T19:45:23.886Z" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/83/93/3b93c291c99d09f64f7535ba74c1c6a3507cf49cffd38983a55de6f834b6/markitdown-0.1.5.tar.gz", hash = "sha256:4c956ff1528bf15e1814542035ec96e989206d19d311bb799f4df973ecafc31a" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b1/8b/fd7e042455a829a1ede0bc8e9e3061aa6c7c4cf745385526ef62ff1b5a5b/markitdown-0.1.5-py3-none-any.whl", hash = "sha256:5180a9a841e20fc01c2c09dbc5d039638429bbebcdc2af1b2615c3c427840434", size = 63402, upload-time = "2026-02-20T19:45:27.195Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b1/8b/fd7e042455a829a1ede0bc8e9e3061aa6c7c4cf745385526ef62ff1b5a5b/markitdown-0.1.5-py3-none-any.whl", hash = "sha256:5180a9a841e20fc01c2c09dbc5d039638429bbebcdc2af1b2615c3c427840434" }, ] [[package]] name = "markupsafe" version = "3.0.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, - { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, - { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, - { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, - { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, - { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, - { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, - { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, - { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, - { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, - { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f" }, ] [[package]] name = "matplotlib-inline" version = "0.2.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "traitlets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c7/74/97e72a36efd4ae2bccb3463284300f8953f199b5ffbc04cbbb0ec78f74b1/matplotlib_inline-0.2.1.tar.gz", hash = "sha256:e1ee949c340d771fc39e241ea75683deb94762c8fa5f2927ec57c83c4dffa9fe", size = 8110, upload-time = "2025-10-23T09:00:22.126Z" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/c7/74/97e72a36efd4ae2bccb3463284300f8953f199b5ffbc04cbbb0ec78f74b1/matplotlib_inline-0.2.1.tar.gz", hash = "sha256:e1ee949c340d771fc39e241ea75683deb94762c8fa5f2927ec57c83c4dffa9fe" } wheels = [ - { url = "https://files.pythonhosted.org/packages/af/33/ee4519fa02ed11a94aef9559552f3b17bb863f2ecfe1a35dc7f548cde231/matplotlib_inline-0.2.1-py3-none-any.whl", hash = "sha256:d56ce5156ba6085e00a9d54fead6ed29a9c47e215cd1bba2e976ef39f5710a76", size = 9516, upload-time = "2025-10-23T09:00:20.675Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/af/33/ee4519fa02ed11a94aef9559552f3b17bb863f2ecfe1a35dc7f548cde231/matplotlib_inline-0.2.1-py3-none-any.whl", hash = "sha256:d56ce5156ba6085e00a9d54fead6ed29a9c47e215cd1bba2e976ef39f5710a76" }, ] [[package]] name = "mcp" version = "1.26.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "anyio" }, { name = "httpx" }, @@ -1264,118 +1282,118 @@ dependencies = [ { name = "typing-inspection" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fc/6d/62e76bbb8144d6ed86e202b5edd8a4cb631e7c8130f3f4893c3f90262b10/mcp-1.26.0.tar.gz", hash = "sha256:db6e2ef491eecc1a0d93711a76f28dec2e05999f93afd48795da1c1137142c66", size = 608005, upload-time = "2026-01-24T19:40:32.468Z" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/fc/6d/62e76bbb8144d6ed86e202b5edd8a4cb631e7c8130f3f4893c3f90262b10/mcp-1.26.0.tar.gz", hash = "sha256:db6e2ef491eecc1a0d93711a76f28dec2e05999f93afd48795da1c1137142c66" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca", size = 233615, upload-time = "2026-01-24T19:40:30.652Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca" }, ] [[package]] name = "mdurl" version = "0.1.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8" }, ] [[package]] name = "mpmath" version = "1.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f" } wheels = [ - { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c" }, ] [[package]] name = "multidict" version = "6.7.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" }, - { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" }, - { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" }, - { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" }, - { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" }, - { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" }, - { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" }, - { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" }, - { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" }, - { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" }, - { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" }, - { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" }, - { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" }, - { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" }, - { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" }, - { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" }, - { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" }, - { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" }, - { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75" }, + { url = "https://mirrors.aliyun.com/pypi/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961" }, + { url = "https://mirrors.aliyun.com/pypi/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582" }, + { url = "https://mirrors.aliyun.com/pypi/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511" }, + { url = "https://mirrors.aliyun.com/pypi/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56" }, ] [[package]] name = "mypy" version = "1.19.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, { name = "mypy-extensions" }, { name = "pathspec" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba" } wheels = [ - { url = "https://files.pythonhosted.org/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1", size = 13206053, upload-time = "2025-12-15T05:03:46.622Z" }, - { url = "https://files.pythonhosted.org/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e", size = 12219134, upload-time = "2025-12-15T05:03:24.367Z" }, - { url = "https://files.pythonhosted.org/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2", size = 12731616, upload-time = "2025-12-15T05:02:44.725Z" }, - { url = "https://files.pythonhosted.org/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8", size = 13620847, upload-time = "2025-12-15T05:03:39.633Z" }, - { url = "https://files.pythonhosted.org/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a", size = 13834976, upload-time = "2025-12-15T05:03:08.786Z" }, - { url = "https://files.pythonhosted.org/packages/6f/63/e7493e5f90e1e085c562bb06e2eb32cae27c5057b9653348d38b47daaecc/mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13", size = 10118104, upload-time = "2025-12-15T05:03:10.834Z" }, - { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6f/63/e7493e5f90e1e085c562bb06e2eb32cae27c5057b9653348d38b47daaecc/mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247" }, ] [[package]] name = "mypy-extensions" version = "1.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558" } wheels = [ - { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505" }, ] [[package]] name = "numpy" version = "2.4.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d7/9f/b8cef5bffa569759033adda9481211426f12f53299629b410340795c2514/numpy-2.4.4.tar.gz", hash = "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0", size = 20731587, upload-time = "2026-03-29T13:22:01.298Z" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/d7/9f/b8cef5bffa569759033adda9481211426f12f53299629b410340795c2514/numpy-2.4.4.tar.gz", hash = "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0" } wheels = [ - { url = "https://files.pythonhosted.org/packages/28/05/32396bec30fb2263770ee910142f49c1476d08e8ad41abf8403806b520ce/numpy-2.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15716cfef24d3a9762e3acdf87e27f58dc823d1348f765bbea6bef8c639bfa1b", size = 16689272, upload-time = "2026-03-29T13:18:49.223Z" }, - { url = "https://files.pythonhosted.org/packages/c5/f3/a983d28637bfcd763a9c7aafdb6d5c0ebf3d487d1e1459ffdb57e2f01117/numpy-2.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23cbfd4c17357c81021f21540da84ee282b9c8fba38a03b7b9d09ba6b951421e", size = 14699573, upload-time = "2026-03-29T13:18:52.629Z" }, - { url = "https://files.pythonhosted.org/packages/9b/fd/e5ecca1e78c05106d98028114f5c00d3eddb41207686b2b7de3e477b0e22/numpy-2.4.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:8b3b60bb7cba2c8c81837661c488637eee696f59a877788a396d33150c35d842", size = 5204782, upload-time = "2026-03-29T13:18:55.579Z" }, - { url = "https://files.pythonhosted.org/packages/de/2f/702a4594413c1a8632092beae8aba00f1d67947389369b3777aed783fdca/numpy-2.4.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:e4a010c27ff6f210ff4c6ef34394cd61470d01014439b192ec22552ee867f2a8", size = 6552038, upload-time = "2026-03-29T13:18:57.769Z" }, - { url = "https://files.pythonhosted.org/packages/7f/37/eed308a8f56cba4d1fdf467a4fc67ef4ff4bf1c888f5fc980481890104b1/numpy-2.4.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9e75681b59ddaa5e659898085ae0eaea229d054f2ac0c7e563a62205a700121", size = 15670666, upload-time = "2026-03-29T13:19:00.341Z" }, - { url = "https://files.pythonhosted.org/packages/0a/0d/0e3ecece05b7a7e87ab9fb587855548da437a061326fff64a223b6dcb78a/numpy-2.4.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:81f4a14bee47aec54f883e0cad2d73986640c1590eb9bfaaba7ad17394481e6e", size = 16645480, upload-time = "2026-03-29T13:19:03.63Z" }, - { url = "https://files.pythonhosted.org/packages/34/49/f2312c154b82a286758ee2f1743336d50651f8b5195db18cdb63675ff649/numpy-2.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:62d6b0f03b694173f9fcb1fb317f7222fd0b0b103e784c6549f5e53a27718c44", size = 17020036, upload-time = "2026-03-29T13:19:07.428Z" }, - { url = "https://files.pythonhosted.org/packages/7b/e9/736d17bd77f1b0ec4f9901aaec129c00d59f5d84d5e79bba540ef12c2330/numpy-2.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fbc356aae7adf9e6336d336b9c8111d390a05df88f1805573ebb0807bd06fd1d", size = 18368643, upload-time = "2026-03-29T13:19:10.775Z" }, - { url = "https://files.pythonhosted.org/packages/63/f6/d417977c5f519b17c8a5c3bc9e8304b0908b0e21136fe43bf628a1343914/numpy-2.4.4-cp312-cp312-win32.whl", hash = "sha256:0d35aea54ad1d420c812bfa0385c71cd7cc5bcf7c65fed95fc2cd02fe8c79827", size = 5961117, upload-time = "2026-03-29T13:19:13.464Z" }, - { url = "https://files.pythonhosted.org/packages/2d/5b/e1deebf88ff431b01b7406ca3583ab2bbb90972bbe1c568732e49c844f7e/numpy-2.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:b5f0362dc928a6ecd9db58868fca5e48485205e3855957bdedea308f8672ea4a", size = 12320584, upload-time = "2026-03-29T13:19:16.155Z" }, - { url = "https://files.pythonhosted.org/packages/58/89/e4e856ac82a68c3ed64486a544977d0e7bdd18b8da75b78a577ca31c4395/numpy-2.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:846300f379b5b12cc769334464656bc882e0735d27d9726568bc932fdc49d5ec", size = 10221450, upload-time = "2026-03-29T13:19:18.994Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/28/05/32396bec30fb2263770ee910142f49c1476d08e8ad41abf8403806b520ce/numpy-2.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15716cfef24d3a9762e3acdf87e27f58dc823d1348f765bbea6bef8c639bfa1b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c5/f3/a983d28637bfcd763a9c7aafdb6d5c0ebf3d487d1e1459ffdb57e2f01117/numpy-2.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23cbfd4c17357c81021f21540da84ee282b9c8fba38a03b7b9d09ba6b951421e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9b/fd/e5ecca1e78c05106d98028114f5c00d3eddb41207686b2b7de3e477b0e22/numpy-2.4.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:8b3b60bb7cba2c8c81837661c488637eee696f59a877788a396d33150c35d842" }, + { url = "https://mirrors.aliyun.com/pypi/packages/de/2f/702a4594413c1a8632092beae8aba00f1d67947389369b3777aed783fdca/numpy-2.4.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:e4a010c27ff6f210ff4c6ef34394cd61470d01014439b192ec22552ee867f2a8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7f/37/eed308a8f56cba4d1fdf467a4fc67ef4ff4bf1c888f5fc980481890104b1/numpy-2.4.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9e75681b59ddaa5e659898085ae0eaea229d054f2ac0c7e563a62205a700121" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0a/0d/0e3ecece05b7a7e87ab9fb587855548da437a061326fff64a223b6dcb78a/numpy-2.4.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:81f4a14bee47aec54f883e0cad2d73986640c1590eb9bfaaba7ad17394481e6e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/34/49/f2312c154b82a286758ee2f1743336d50651f8b5195db18cdb63675ff649/numpy-2.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:62d6b0f03b694173f9fcb1fb317f7222fd0b0b103e784c6549f5e53a27718c44" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7b/e9/736d17bd77f1b0ec4f9901aaec129c00d59f5d84d5e79bba540ef12c2330/numpy-2.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fbc356aae7adf9e6336d336b9c8111d390a05df88f1805573ebb0807bd06fd1d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/63/f6/d417977c5f519b17c8a5c3bc9e8304b0908b0e21136fe43bf628a1343914/numpy-2.4.4-cp312-cp312-win32.whl", hash = "sha256:0d35aea54ad1d420c812bfa0385c71cd7cc5bcf7c65fed95fc2cd02fe8c79827" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2d/5b/e1deebf88ff431b01b7406ca3583ab2bbb90972bbe1c568732e49c844f7e/numpy-2.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:b5f0362dc928a6ecd9db58868fca5e48485205e3855957bdedea308f8672ea4a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/58/89/e4e856ac82a68c3ed64486a544977d0e7bdd18b8da75b78a577ca31c4395/numpy-2.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:846300f379b5b12cc769334464656bc882e0735d27d9726568bc932fdc49d5ec" }, ] [[package]] name = "olefile" version = "0.47" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/69/1b/077b508e3e500e1629d366249c3ccb32f95e50258b231705c09e3c7a4366/olefile-0.47.zip", hash = "sha256:599383381a0bf3dfbd932ca0ca6515acd174ed48870cbf7fee123d698c192c1c", size = 112240, upload-time = "2023-12-01T16:22:53.025Z" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/69/1b/077b508e3e500e1629d366249c3ccb32f95e50258b231705c09e3c7a4366/olefile-0.47.zip", hash = "sha256:599383381a0bf3dfbd932ca0ca6515acd174ed48870cbf7fee123d698c192c1c" } wheels = [ - { url = "https://files.pythonhosted.org/packages/17/d3/b64c356a907242d719fc668b71befd73324e47ab46c8ebbbede252c154b2/olefile-0.47-py2.py3-none-any.whl", hash = "sha256:543c7da2a7adadf21214938bb79c83ea12b473a4b6ee4ad4bf854e7715e13d1f", size = 114565, upload-time = "2023-12-01T16:22:51.518Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/17/d3/b64c356a907242d719fc668b71befd73324e47ab46c8ebbbede252c154b2/olefile-0.47-py2.py3-none-any.whl", hash = "sha256:543c7da2a7adadf21214938bb79c83ea12b473a4b6ee4ad4bf854e7715e13d1f" }, ] [[package]] name = "onnxruntime" version = "1.20.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "coloredlogs" }, { name = "flatbuffers" }, @@ -1385,17 +1403,17 @@ dependencies = [ { name = "sympy" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/39/9335e0874f68f7d27103cbffc0e235e32e26759202df6085716375c078bb/onnxruntime-1.20.1-cp312-cp312-macosx_13_0_universal2.whl", hash = "sha256:22b0655e2bf4f2161d52706e31f517a0e54939dc393e92577df51808a7edc8c9", size = 31007580, upload-time = "2024-11-21T00:49:07.029Z" }, - { url = "https://files.pythonhosted.org/packages/c5/9d/a42a84e10f1744dd27c6f2f9280cc3fb98f869dd19b7cd042e391ee2ab61/onnxruntime-1.20.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f1f56e898815963d6dc4ee1c35fc6c36506466eff6d16f3cb9848cea4e8c8172", size = 11952833, upload-time = "2024-11-21T00:49:10.563Z" }, - { url = "https://files.pythonhosted.org/packages/47/42/2f71f5680834688a9c81becbe5c5bb996fd33eaed5c66ae0606c3b1d6a02/onnxruntime-1.20.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bb71a814f66517a65628c9e4a2bb530a6edd2cd5d87ffa0af0f6f773a027d99e", size = 13333903, upload-time = "2024-11-21T00:49:12.984Z" }, - { url = "https://files.pythonhosted.org/packages/c8/f1/aabfdf91d013320aa2fc46cf43c88ca0182860ff15df872b4552254a9680/onnxruntime-1.20.1-cp312-cp312-win32.whl", hash = "sha256:bd386cc9ee5f686ee8a75ba74037750aca55183085bf1941da8efcfe12d5b120", size = 9814562, upload-time = "2024-11-21T00:49:15.453Z" }, - { url = "https://files.pythonhosted.org/packages/dd/80/76979e0b744307d488c79e41051117634b956612cc731f1028eb17ee7294/onnxruntime-1.20.1-cp312-cp312-win_amd64.whl", hash = "sha256:19c2d843eb074f385e8bbb753a40df780511061a63f9def1b216bf53860223fb", size = 11331482, upload-time = "2024-11-21T00:49:19.412Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e5/39/9335e0874f68f7d27103cbffc0e235e32e26759202df6085716375c078bb/onnxruntime-1.20.1-cp312-cp312-macosx_13_0_universal2.whl", hash = "sha256:22b0655e2bf4f2161d52706e31f517a0e54939dc393e92577df51808a7edc8c9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c5/9d/a42a84e10f1744dd27c6f2f9280cc3fb98f869dd19b7cd042e391ee2ab61/onnxruntime-1.20.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f1f56e898815963d6dc4ee1c35fc6c36506466eff6d16f3cb9848cea4e8c8172" }, + { url = "https://mirrors.aliyun.com/pypi/packages/47/42/2f71f5680834688a9c81becbe5c5bb996fd33eaed5c66ae0606c3b1d6a02/onnxruntime-1.20.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bb71a814f66517a65628c9e4a2bb530a6edd2cd5d87ffa0af0f6f773a027d99e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c8/f1/aabfdf91d013320aa2fc46cf43c88ca0182860ff15df872b4552254a9680/onnxruntime-1.20.1-cp312-cp312-win32.whl", hash = "sha256:bd386cc9ee5f686ee8a75ba74037750aca55183085bf1941da8efcfe12d5b120" }, + { url = "https://mirrors.aliyun.com/pypi/packages/dd/80/76979e0b744307d488c79e41051117634b956612cc731f1028eb17ee7294/onnxruntime-1.20.1-cp312-cp312-win_amd64.whl", hash = "sha256:19c2d843eb074f385e8bbb753a40df780511061a63f9def1b216bf53860223fb" }, ] [[package]] name = "openai" version = "2.30.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "anyio" }, { name = "distro" }, @@ -1406,40 +1424,68 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/88/15/52580c8fbc16d0675d516e8749806eda679b16de1e4434ea06fb6feaa610/openai-2.30.0.tar.gz", hash = "sha256:92f7661c990bda4b22a941806c83eabe4896c3094465030dd882a71abe80c885", size = 676084, upload-time = "2026-03-25T22:08:59.96Z" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/88/15/52580c8fbc16d0675d516e8749806eda679b16de1e4434ea06fb6feaa610/openai-2.30.0.tar.gz", hash = "sha256:92f7661c990bda4b22a941806c83eabe4896c3094465030dd882a71abe80c885" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/2a/9e/5bfa2270f902d5b92ab7d41ce0475b8630572e71e349b2a4996d14bdda93/openai-2.30.0-py3-none-any.whl", hash = "sha256:9a5ae616888eb2748ec5e0c5b955a51592e0b201a11f4262db920f2a78c5231d" }, +] + +[[package]] +name = "opencv-python" +version = "4.13.0.92" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "numpy" }, +] +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/fc/6f/5a28fef4c4a382be06afe3938c64cc168223016fa520c5abaf37e8862aa5/opencv_python-4.13.0.92-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:caf60c071ec391ba51ed00a4a920f996d0b64e3e46068aac1f646b5de0326a19" }, + { url = "https://mirrors.aliyun.com/pypi/packages/08/ac/6c98c44c650b8114a0fb901691351cfb3956d502e8e9b5cd27f4ee7fbf2f/opencv_python-4.13.0.92-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:5868a8c028a0b37561579bfb8ac1875babdc69546d236249fff296a8c010ccf9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fb/17/de5458312bcb07ddf434d7bfcb24bb52c59635ad58c6e7c751b48949b009/opencv_python-4.13.0.92-cp37-abi3-win32.whl", hash = "sha256:372fe164a3148ac1ca51e5f3ad0541a4a276452273f503441d718fab9c5e5f59" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e9/a5/1be1516390333ff9be3a9cb648c9f33df79d5096e5884b5df71a588af463/opencv_python-4.13.0.92-cp37-abi3-win_amd64.whl", hash = "sha256:423d934c9fafb91aad38edf26efb46da91ffbc05f3f59c4b0c72e699720706f5" }, +] + +[[package]] +name = "opencv-python-headless" +version = "4.13.0.92" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "numpy" }, +] wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/9e/5bfa2270f902d5b92ab7d41ce0475b8630572e71e349b2a4996d14bdda93/openai-2.30.0-py3-none-any.whl", hash = "sha256:9a5ae616888eb2748ec5e0c5b955a51592e0b201a11f4262db920f2a78c5231d", size = 1146656, upload-time = "2026-03-25T22:08:58.2Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/21/76/9417a6aef9def70e467a5bf560579f816148a4c658b7d525581b356eda9e/opencv_python_headless-4.13.0.92-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c8cfc8e87ed452b5cecb9419473ee5560a989859fe1d10d1ce11ae87b09a2cb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/92/ce/bd17ff5772938267fd49716e94ca24f616ff4cb1ff4c6be13085108037be/opencv_python_headless-4.13.0.92-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0525a3d2c0b46c611e2130b5fdebc94cf404845d8fa64d2f3a3b679572a5bd22" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8f/b4/b7bcbf7c874665825a8c8e1097e93ea25d1f1d210a3e20d4451d01da30aa/opencv_python_headless-4.13.0.92-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:eb60e36b237b1ebd40a912da5384b348df8ed534f6f644d8e0b4f103e272ba7d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4b/33/b5db29a6c00eb8f50708110d8d453747ca125c8b805bc437b289dbdcc057/opencv_python_headless-4.13.0.92-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0bd48544f77c68b2941392fcdf9bcd2b9cdf00e98cb8c29b2455d194763cf99e" }, ] [[package]] name = "opentelemetry-api" version = "1.40.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "importlib-metadata" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2c/1d/4049a9e8698361cc1a1aa03a6c59e4fa4c71e0c0f94a30f988a6876a2ae6/opentelemetry_api-1.40.0.tar.gz", hash = "sha256:159be641c0b04d11e9ecd576906462773eb97ae1b657730f0ecf64d32071569f", size = 70851, upload-time = "2026-03-04T14:17:21.555Z" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/2c/1d/4049a9e8698361cc1a1aa03a6c59e4fa4c71e0c0f94a30f988a6876a2ae6/opentelemetry_api-1.40.0.tar.gz", hash = "sha256:159be641c0b04d11e9ecd576906462773eb97ae1b657730f0ecf64d32071569f" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/bf/93795954016c522008da367da292adceed71cca6ee1717e1d64c83089099/opentelemetry_api-1.40.0-py3-none-any.whl", hash = "sha256:82dd69331ae74b06f6a874704be0cfaa49a1650e1537d4a813b86ecef7d0ecf9", size = 68676, upload-time = "2026-03-04T14:17:01.24Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5f/bf/93795954016c522008da367da292adceed71cca6ee1717e1d64c83089099/opentelemetry_api-1.40.0-py3-none-any.whl", hash = "sha256:82dd69331ae74b06f6a874704be0cfaa49a1650e1537d4a813b86ecef7d0ecf9" }, ] [[package]] name = "opentelemetry-exporter-otlp-proto-common" version = "1.40.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "opentelemetry-proto" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/51/bc/1559d46557fe6eca0b46c88d4c2676285f1f3be2e8d06bb5d15fbffc814a/opentelemetry_exporter_otlp_proto_common-1.40.0.tar.gz", hash = "sha256:1cbee86a4064790b362a86601ee7934f368b81cd4cc2f2e163902a6e7818a0fa", size = 20416, upload-time = "2026-03-04T14:17:23.801Z" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/51/bc/1559d46557fe6eca0b46c88d4c2676285f1f3be2e8d06bb5d15fbffc814a/opentelemetry_exporter_otlp_proto_common-1.40.0.tar.gz", hash = "sha256:1cbee86a4064790b362a86601ee7934f368b81cd4cc2f2e163902a6e7818a0fa" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/ca/8f122055c97a932311a3f640273f084e738008933503d0c2563cd5d591fc/opentelemetry_exporter_otlp_proto_common-1.40.0-py3-none-any.whl", hash = "sha256:7081ff453835a82417bf38dccf122c827c3cbc94f2079b03bba02a3165f25149", size = 18369, upload-time = "2026-03-04T14:17:04.796Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8b/ca/8f122055c97a932311a3f640273f084e738008933503d0c2563cd5d591fc/opentelemetry_exporter_otlp_proto_common-1.40.0-py3-none-any.whl", hash = "sha256:7081ff453835a82417bf38dccf122c827c3cbc94f2079b03bba02a3165f25149" }, ] [[package]] name = "opentelemetry-exporter-otlp-proto-http" version = "1.40.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "googleapis-common-protos" }, { name = "opentelemetry-api" }, @@ -1449,323 +1495,323 @@ dependencies = [ { name = "requests" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2e/fa/73d50e2c15c56be4d000c98e24221d494674b0cc95524e2a8cb3856d95a4/opentelemetry_exporter_otlp_proto_http-1.40.0.tar.gz", hash = "sha256:db48f5e0f33217588bbc00274a31517ba830da576e59503507c839b38fa0869c", size = 17772, upload-time = "2026-03-04T14:17:25.324Z" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/2e/fa/73d50e2c15c56be4d000c98e24221d494674b0cc95524e2a8cb3856d95a4/opentelemetry_exporter_otlp_proto_http-1.40.0.tar.gz", hash = "sha256:db48f5e0f33217588bbc00274a31517ba830da576e59503507c839b38fa0869c" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/3a/8865d6754e61c9fb170cdd530a124a53769ee5f740236064816eb0ca7301/opentelemetry_exporter_otlp_proto_http-1.40.0-py3-none-any.whl", hash = "sha256:a8d1dab28f504c5d96577d6509f80a8150e44e8f45f82cdbe0e34c99ab040069", size = 19960, upload-time = "2026-03-04T14:17:07.153Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a0/3a/8865d6754e61c9fb170cdd530a124a53769ee5f740236064816eb0ca7301/opentelemetry_exporter_otlp_proto_http-1.40.0-py3-none-any.whl", hash = "sha256:a8d1dab28f504c5d96577d6509f80a8150e44e8f45f82cdbe0e34c99ab040069" }, ] [[package]] name = "opentelemetry-proto" version = "1.40.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4c/77/dd38991db037fdfce45849491cb61de5ab000f49824a00230afb112a4392/opentelemetry_proto-1.40.0.tar.gz", hash = "sha256:03f639ca129ba513f5819810f5b1f42bcb371391405d99c168fe6937c62febcd", size = 45667, upload-time = "2026-03-04T14:17:31.194Z" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/4c/77/dd38991db037fdfce45849491cb61de5ab000f49824a00230afb112a4392/opentelemetry_proto-1.40.0.tar.gz", hash = "sha256:03f639ca129ba513f5819810f5b1f42bcb371391405d99c168fe6937c62febcd" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b9/b2/189b2577dde745b15625b3214302605b1353436219d42b7912e77fa8dc24/opentelemetry_proto-1.40.0-py3-none-any.whl", hash = "sha256:266c4385d88923a23d63e353e9761af0f47a6ed0d486979777fe4de59dc9b25f", size = 72073, upload-time = "2026-03-04T14:17:16.673Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b9/b2/189b2577dde745b15625b3214302605b1353436219d42b7912e77fa8dc24/opentelemetry_proto-1.40.0-py3-none-any.whl", hash = "sha256:266c4385d88923a23d63e353e9761af0f47a6ed0d486979777fe4de59dc9b25f" }, ] [[package]] name = "opentelemetry-sdk" version = "1.40.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "opentelemetry-api" }, { name = "opentelemetry-semantic-conventions" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/58/fd/3c3125b20ba18ce2155ba9ea74acb0ae5d25f8cd39cfd37455601b7955cc/opentelemetry_sdk-1.40.0.tar.gz", hash = "sha256:18e9f5ec20d859d268c7cb3c5198c8d105d073714db3de50b593b8c1345a48f2", size = 184252, upload-time = "2026-03-04T14:17:31.87Z" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/58/fd/3c3125b20ba18ce2155ba9ea74acb0ae5d25f8cd39cfd37455601b7955cc/opentelemetry_sdk-1.40.0.tar.gz", hash = "sha256:18e9f5ec20d859d268c7cb3c5198c8d105d073714db3de50b593b8c1345a48f2" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/c5/6a852903d8bfac758c6dc6e9a68b015d3c33f2f1be5e9591e0f4b69c7e0a/opentelemetry_sdk-1.40.0-py3-none-any.whl", hash = "sha256:787d2154a71f4b3d81f20524a8ce061b7db667d24e46753f32a7bc48f1c1f3f1", size = 141951, upload-time = "2026-03-04T14:17:17.961Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2c/c5/6a852903d8bfac758c6dc6e9a68b015d3c33f2f1be5e9591e0f4b69c7e0a/opentelemetry_sdk-1.40.0-py3-none-any.whl", hash = "sha256:787d2154a71f4b3d81f20524a8ce061b7db667d24e46753f32a7bc48f1c1f3f1" }, ] [[package]] name = "opentelemetry-semantic-conventions" version = "0.61b0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "opentelemetry-api" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6d/c0/4ae7973f3c2cfd2b6e321f1675626f0dab0a97027cc7a297474c9c8f3d04/opentelemetry_semantic_conventions-0.61b0.tar.gz", hash = "sha256:072f65473c5d7c6dc0355b27d6c9d1a679d63b6d4b4b16a9773062cb7e31192a", size = 145755, upload-time = "2026-03-04T14:17:32.664Z" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/6d/c0/4ae7973f3c2cfd2b6e321f1675626f0dab0a97027cc7a297474c9c8f3d04/opentelemetry_semantic_conventions-0.61b0.tar.gz", hash = "sha256:072f65473c5d7c6dc0355b27d6c9d1a679d63b6d4b4b16a9773062cb7e31192a" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b2/37/cc6a55e448deaa9b27377d087da8615a3416d8ad523d5960b78dbeadd02a/opentelemetry_semantic_conventions-0.61b0-py3-none-any.whl", hash = "sha256:fa530a96be229795f8cef353739b618148b0fe2b4b3f005e60e262926c4d38e2", size = 231621, upload-time = "2026-03-04T14:17:19.33Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b2/37/cc6a55e448deaa9b27377d087da8615a3416d8ad523d5960b78dbeadd02a/opentelemetry_semantic_conventions-0.61b0-py3-none-any.whl", hash = "sha256:fa530a96be229795f8cef353739b618148b0fe2b4b3f005e60e262926c4d38e2" }, ] [[package]] name = "packaging" version = "25.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f" } wheels = [ - { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484" }, ] [[package]] name = "parso" version = "0.8.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/81/76/a1e769043c0c0c9fe391b702539d594731a4362334cdf4dc25d0c09761e7/parso-0.8.6.tar.gz", hash = "sha256:2b9a0332696df97d454fa67b81618fd69c35a7b90327cbe6ba5c92d2c68a7bfd", size = 401621, upload-time = "2026-02-09T15:45:24.425Z" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/81/76/a1e769043c0c0c9fe391b702539d594731a4362334cdf4dc25d0c09761e7/parso-0.8.6.tar.gz", hash = "sha256:2b9a0332696df97d454fa67b81618fd69c35a7b90327cbe6ba5c92d2c68a7bfd" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b6/61/fae042894f4296ec49e3f193aff5d7c18440da9e48102c3315e1bc4519a7/parso-0.8.6-py2.py3-none-any.whl", hash = "sha256:2c549f800b70a5c4952197248825584cb00f033b29c692671d3bf08bf380baff", size = 106894, upload-time = "2026-02-09T15:45:21.391Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b6/61/fae042894f4296ec49e3f193aff5d7c18440da9e48102c3315e1bc4519a7/parso-0.8.6-py2.py3-none-any.whl", hash = "sha256:2c549f800b70a5c4952197248825584cb00f033b29c692671d3bf08bf380baff" }, ] [[package]] name = "pathspec" version = "1.0.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723" }, ] [[package]] name = "pexpect" version = "4.9.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ - { name = "ptyprocess", marker = "sys_platform != 'win32'" }, + { name = "ptyprocess" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523" }, ] [[package]] name = "pillow" version = "12.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5" } wheels = [ - { url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" }, - { url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" }, - { url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" }, - { url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" }, - { url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" }, - { url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" }, - { url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" }, - { url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" }, - { url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" }, - { url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" }, - { url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421" }, + { url = "https://mirrors.aliyun.com/pypi/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005" }, + { url = "https://mirrors.aliyun.com/pypi/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414" }, ] [[package]] name = "pluggy" version = "1.6.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3" } wheels = [ - { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746" }, ] [[package]] name = "prompt-toolkit" version = "3.0.52" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "wcwidth" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855" } wheels = [ - { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955" }, ] [[package]] name = "propcache" version = "0.4.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a2/0f/f17b1b2b221d5ca28b4b876e8bb046ac40466513960646bda8e1853cdfa2/propcache-0.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e153e9cd40cc8945138822807139367f256f89c6810c2634a4f6902b52d3b4e2", size = 80061, upload-time = "2025-10-08T19:46:46.075Z" }, - { url = "https://files.pythonhosted.org/packages/76/47/8ccf75935f51448ba9a16a71b783eb7ef6b9ee60f5d14c7f8a8a79fbeed7/propcache-0.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd547953428f7abb73c5ad82cbb32109566204260d98e41e5dfdc682eb7f8403", size = 46037, upload-time = "2025-10-08T19:46:47.23Z" }, - { url = "https://files.pythonhosted.org/packages/0a/b6/5c9a0e42df4d00bfb4a3cbbe5cf9f54260300c88a0e9af1f47ca5ce17ac0/propcache-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f048da1b4f243fc44f205dfd320933a951b8d89e0afd4c7cacc762a8b9165207", size = 47324, upload-time = "2025-10-08T19:46:48.384Z" }, - { url = "https://files.pythonhosted.org/packages/9e/d3/6c7ee328b39a81ee877c962469f1e795f9db87f925251efeb0545e0020d0/propcache-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec17c65562a827bba85e3872ead335f95405ea1674860d96483a02f5c698fa72", size = 225505, upload-time = "2025-10-08T19:46:50.055Z" }, - { url = "https://files.pythonhosted.org/packages/01/5d/1c53f4563490b1d06a684742cc6076ef944bc6457df6051b7d1a877c057b/propcache-0.4.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:405aac25c6394ef275dee4c709be43745d36674b223ba4eb7144bf4d691b7367", size = 230242, upload-time = "2025-10-08T19:46:51.815Z" }, - { url = "https://files.pythonhosted.org/packages/20/e1/ce4620633b0e2422207c3cb774a0ee61cac13abc6217763a7b9e2e3f4a12/propcache-0.4.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0013cb6f8dde4b2a2f66903b8ba740bdfe378c943c4377a200551ceb27f379e4", size = 238474, upload-time = "2025-10-08T19:46:53.208Z" }, - { url = "https://files.pythonhosted.org/packages/46/4b/3aae6835b8e5f44ea6a68348ad90f78134047b503765087be2f9912140ea/propcache-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15932ab57837c3368b024473a525e25d316d8353016e7cc0e5ba9eb343fbb1cf", size = 221575, upload-time = "2025-10-08T19:46:54.511Z" }, - { url = "https://files.pythonhosted.org/packages/6e/a5/8a5e8678bcc9d3a1a15b9a29165640d64762d424a16af543f00629c87338/propcache-0.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:031dce78b9dc099f4c29785d9cf5577a3faf9ebf74ecbd3c856a7b92768c3df3", size = 216736, upload-time = "2025-10-08T19:46:56.212Z" }, - { url = "https://files.pythonhosted.org/packages/f1/63/b7b215eddeac83ca1c6b934f89d09a625aa9ee4ba158338854c87210cc36/propcache-0.4.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ab08df6c9a035bee56e31af99be621526bd237bea9f32def431c656b29e41778", size = 213019, upload-time = "2025-10-08T19:46:57.595Z" }, - { url = "https://files.pythonhosted.org/packages/57/74/f580099a58c8af587cac7ba19ee7cb418506342fbbe2d4a4401661cca886/propcache-0.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4d7af63f9f93fe593afbf104c21b3b15868efb2c21d07d8732c0c4287e66b6a6", size = 220376, upload-time = "2025-10-08T19:46:59.067Z" }, - { url = "https://files.pythonhosted.org/packages/c4/ee/542f1313aff7eaf19c2bb758c5d0560d2683dac001a1c96d0774af799843/propcache-0.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cfc27c945f422e8b5071b6e93169679e4eb5bf73bbcbf1ba3ae3a83d2f78ebd9", size = 226988, upload-time = "2025-10-08T19:47:00.544Z" }, - { url = "https://files.pythonhosted.org/packages/8f/18/9c6b015dd9c6930f6ce2229e1f02fb35298b847f2087ea2b436a5bfa7287/propcache-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:35c3277624a080cc6ec6f847cbbbb5b49affa3598c4535a0a4682a697aaa5c75", size = 215615, upload-time = "2025-10-08T19:47:01.968Z" }, - { url = "https://files.pythonhosted.org/packages/80/9e/e7b85720b98c45a45e1fca6a177024934dc9bc5f4d5dd04207f216fc33ed/propcache-0.4.1-cp312-cp312-win32.whl", hash = "sha256:671538c2262dadb5ba6395e26c1731e1d52534bfe9ae56d0b5573ce539266aa8", size = 38066, upload-time = "2025-10-08T19:47:03.503Z" }, - { url = "https://files.pythonhosted.org/packages/54/09/d19cff2a5aaac632ec8fc03737b223597b1e347416934c1b3a7df079784c/propcache-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:cb2d222e72399fcf5890d1d5cc1060857b9b236adff2792ff48ca2dfd46c81db", size = 41655, upload-time = "2025-10-08T19:47:04.973Z" }, - { url = "https://files.pythonhosted.org/packages/68/ab/6b5c191bb5de08036a8c697b265d4ca76148efb10fa162f14af14fb5f076/propcache-0.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:204483131fb222bdaaeeea9f9e6c6ed0cac32731f75dfc1d4a567fc1926477c1", size = 37789, upload-time = "2025-10-08T19:47:06.077Z" }, - { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/a2/0f/f17b1b2b221d5ca28b4b876e8bb046ac40466513960646bda8e1853cdfa2/propcache-0.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e153e9cd40cc8945138822807139367f256f89c6810c2634a4f6902b52d3b4e2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/76/47/8ccf75935f51448ba9a16a71b783eb7ef6b9ee60f5d14c7f8a8a79fbeed7/propcache-0.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd547953428f7abb73c5ad82cbb32109566204260d98e41e5dfdc682eb7f8403" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0a/b6/5c9a0e42df4d00bfb4a3cbbe5cf9f54260300c88a0e9af1f47ca5ce17ac0/propcache-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f048da1b4f243fc44f205dfd320933a951b8d89e0afd4c7cacc762a8b9165207" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9e/d3/6c7ee328b39a81ee877c962469f1e795f9db87f925251efeb0545e0020d0/propcache-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec17c65562a827bba85e3872ead335f95405ea1674860d96483a02f5c698fa72" }, + { url = "https://mirrors.aliyun.com/pypi/packages/01/5d/1c53f4563490b1d06a684742cc6076ef944bc6457df6051b7d1a877c057b/propcache-0.4.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:405aac25c6394ef275dee4c709be43745d36674b223ba4eb7144bf4d691b7367" }, + { url = "https://mirrors.aliyun.com/pypi/packages/20/e1/ce4620633b0e2422207c3cb774a0ee61cac13abc6217763a7b9e2e3f4a12/propcache-0.4.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0013cb6f8dde4b2a2f66903b8ba740bdfe378c943c4377a200551ceb27f379e4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/46/4b/3aae6835b8e5f44ea6a68348ad90f78134047b503765087be2f9912140ea/propcache-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15932ab57837c3368b024473a525e25d316d8353016e7cc0e5ba9eb343fbb1cf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6e/a5/8a5e8678bcc9d3a1a15b9a29165640d64762d424a16af543f00629c87338/propcache-0.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:031dce78b9dc099f4c29785d9cf5577a3faf9ebf74ecbd3c856a7b92768c3df3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f1/63/b7b215eddeac83ca1c6b934f89d09a625aa9ee4ba158338854c87210cc36/propcache-0.4.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ab08df6c9a035bee56e31af99be621526bd237bea9f32def431c656b29e41778" }, + { url = "https://mirrors.aliyun.com/pypi/packages/57/74/f580099a58c8af587cac7ba19ee7cb418506342fbbe2d4a4401661cca886/propcache-0.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4d7af63f9f93fe593afbf104c21b3b15868efb2c21d07d8732c0c4287e66b6a6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c4/ee/542f1313aff7eaf19c2bb758c5d0560d2683dac001a1c96d0774af799843/propcache-0.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cfc27c945f422e8b5071b6e93169679e4eb5bf73bbcbf1ba3ae3a83d2f78ebd9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8f/18/9c6b015dd9c6930f6ce2229e1f02fb35298b847f2087ea2b436a5bfa7287/propcache-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:35c3277624a080cc6ec6f847cbbbb5b49affa3598c4535a0a4682a697aaa5c75" }, + { url = "https://mirrors.aliyun.com/pypi/packages/80/9e/e7b85720b98c45a45e1fca6a177024934dc9bc5f4d5dd04207f216fc33ed/propcache-0.4.1-cp312-cp312-win32.whl", hash = "sha256:671538c2262dadb5ba6395e26c1731e1d52534bfe9ae56d0b5573ce539266aa8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/54/09/d19cff2a5aaac632ec8fc03737b223597b1e347416934c1b3a7df079784c/propcache-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:cb2d222e72399fcf5890d1d5cc1060857b9b236adff2792ff48ca2dfd46c81db" }, + { url = "https://mirrors.aliyun.com/pypi/packages/68/ab/6b5c191bb5de08036a8c697b265d4ca76148efb10fa162f14af14fb5f076/propcache-0.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:204483131fb222bdaaeeea9f9e6c6ed0cac32731f75dfc1d4a567fc1926477c1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237" }, ] [[package]] name = "protobuf" version = "6.33.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", size = 444531, upload-time = "2026-03-18T19:05:00.988Z" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", size = 425739, upload-time = "2026-03-18T19:04:48.373Z" }, - { url = "https://files.pythonhosted.org/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326", size = 437089, upload-time = "2026-03-18T19:04:50.381Z" }, - { url = "https://files.pythonhosted.org/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a", size = 427737, upload-time = "2026-03-18T19:04:51.866Z" }, - { url = "https://files.pythonhosted.org/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2", size = 324610, upload-time = "2026-03-18T19:04:53.096Z" }, - { url = "https://files.pythonhosted.org/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3", size = 339381, upload-time = "2026-03-18T19:04:54.616Z" }, - { url = "https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593", size = 323436, upload-time = "2026-03-18T19:04:55.768Z" }, - { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901" }, ] [[package]] name = "ptyprocess" version = "0.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762, upload-time = "2020-12-28T15:15:30.155Z" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220" } wheels = [ - { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35" }, ] [[package]] name = "pure-eval" version = "0.2.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cd/05/0a34433a064256a578f1783a10da6df098ceaa4a57bbeaa96a6c0352786b/pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42", size = 19752, upload-time = "2024-07-21T12:58:21.801Z" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/cd/05/0a34433a064256a578f1783a10da6df098ceaa4a57bbeaa96a6c0352786b/pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0" }, ] [[package]] name = "pyasn1" version = "0.6.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde" }, ] [[package]] name = "pyasn1-modules" version = "0.4.2" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "pyasn1" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6" } wheels = [ - { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a" }, ] [[package]] name = "pycparser" version = "3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992" }, ] [[package]] name = "pycryptodome" version = "3.23.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276, upload-time = "2025-05-17T17:21:45.242Z" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef" } wheels = [ - { url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627, upload-time = "2025-05-17T17:20:47.139Z" }, - { url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362, upload-time = "2025-05-17T17:20:50.392Z" }, - { url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625, upload-time = "2025-05-17T17:20:52.866Z" }, - { url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954, upload-time = "2025-05-17T17:20:55.027Z" }, - { url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534, upload-time = "2025-05-17T17:20:57.279Z" }, - { url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853, upload-time = "2025-05-17T17:20:59.322Z" }, - { url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465, upload-time = "2025-05-17T17:21:03.83Z" }, - { url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414, upload-time = "2025-05-17T17:21:06.72Z" }, - { url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484, upload-time = "2025-05-17T17:21:08.535Z" }, - { url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636, upload-time = "2025-05-17T17:21:10.393Z" }, - { url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843" }, + { url = "https://mirrors.aliyun.com/pypi/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa" }, + { url = "https://mirrors.aliyun.com/pypi/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886" }, + { url = "https://mirrors.aliyun.com/pypi/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c" }, ] [[package]] name = "pydantic" version = "2.12.5" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "annotated-types" }, { name = "pydantic-core" }, { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d" }, ] [[package]] name = "pydantic-core" version = "2.41.5" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, - { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, - { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, - { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, - { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, - { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, - { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, - { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, - { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, - { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, - { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, - { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, - { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, - { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, - { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, - { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, - { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, - { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69" }, + { url = "https://mirrors.aliyun.com/pypi/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75" }, + { url = "https://mirrors.aliyun.com/pypi/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05" }, + { url = "https://mirrors.aliyun.com/pypi/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b" }, ] [[package]] name = "pydantic-settings" version = "2.13.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "pydantic" }, { name = "python-dotenv" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025" } wheels = [ - { url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237" }, ] [[package]] name = "pygls" version = "2.1.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "attrs" }, { name = "cattrs" }, { name = "lsprotocol" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/39/06/de0a8db764391f1b3ab8ba4eaa6d1fe923151a1999350c15b987b46a3718/pygls-2.1.0.tar.gz", hash = "sha256:3f2247717deeda9174d9c2f76130ff4d3e0e0788a5be47212df248d163453aac", size = 54852, upload-time = "2026-03-19T09:16:56.853Z" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/39/06/de0a8db764391f1b3ab8ba4eaa6d1fe923151a1999350c15b987b46a3718/pygls-2.1.0.tar.gz", hash = "sha256:3f2247717deeda9174d9c2f76130ff4d3e0e0788a5be47212df248d163453aac" } wheels = [ - { url = "https://files.pythonhosted.org/packages/be/1b/523fa1d7a9ed16d41dc1a33533def97712eb3aff660d2f124033db019461/pygls-2.1.0-py3-none-any.whl", hash = "sha256:cfa8443561488cb15b59f6ce64cabfa37d79753f7120c1bf729419246bf747f9", size = 68719, upload-time = "2026-03-19T09:16:57.961Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/be/1b/523fa1d7a9ed16d41dc1a33533def97712eb3aff660d2f124033db019461/pygls-2.1.0-py3-none-any.whl", hash = "sha256:cfa8443561488cb15b59f6ce64cabfa37d79753f7120c1bf729419246bf747f9" }, ] [[package]] name = "pygments" version = "2.19.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b" }, ] [[package]] name = "pyjwt" version = "2.12.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c" }, ] [package.optional-dependencies] @@ -1776,40 +1822,40 @@ crypto = [ [[package]] name = "pymupdf" version = "1.27.2.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f1/32/f6b645c51d79a188a4844140c5dabca7b487ad56c4be69c4bc782d0d11a9/pymupdf-1.27.2.2.tar.gz", hash = "sha256:ea8fdc3ab6671ca98f629d5ec3032d662c8cf1796b146996b7ad306ac7ed3335", size = 85354380, upload-time = "2026-03-20T09:47:58.386Z" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/f1/32/f6b645c51d79a188a4844140c5dabca7b487ad56c4be69c4bc782d0d11a9/pymupdf-1.27.2.2.tar.gz", hash = "sha256:ea8fdc3ab6671ca98f629d5ec3032d662c8cf1796b146996b7ad306ac7ed3335" } wheels = [ - { url = "https://files.pythonhosted.org/packages/90/88/d01992a50165e22dec057a1129826846c547feb4ba07f42720ac030ce438/pymupdf-1.27.2.2-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:800f43e60a6f01f644343c2213b8613db02eaf4f4ba235b417b3351fa99e01c0", size = 23987563, upload-time = "2026-03-19T12:35:42.989Z" }, - { url = "https://files.pythonhosted.org/packages/6d/0e/9f526bc1d49d8082eff0d1547a69d541a0c5a052e71da625559efaba46a6/pymupdf-1.27.2.2-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:8e2e4299ef1ac0c9dff9be096cbd22783699673abecfa7c3f73173ae06421d73", size = 23263089, upload-time = "2026-03-20T09:44:16.982Z" }, - { url = "https://files.pythonhosted.org/packages/42/be/984f0d6343935b5dd30afaed6be04fc753146bf55709e63ef28bf9ef7497/pymupdf-1.27.2.2-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:c5e3d54922db1c7da844f1208ac1db05704770988752311f81dd36694ae0a07b", size = 24318817, upload-time = "2026-03-20T09:44:33.209Z" }, - { url = "https://files.pythonhosted.org/packages/22/8e/85e9d9f11dbf34036eb1df283805ef6b885f2005a56d6533bb58ab0b8a11/pymupdf-1.27.2.2-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:892698c9768457eb0991c102c96a856c0a7062539371df5e6bee0816f3ef498e", size = 24948135, upload-time = "2026-03-20T09:44:51.012Z" }, - { url = "https://files.pythonhosted.org/packages/db/e6/386edb017e5b93f1ab0bf6653ae32f3dd8dfc834ed770212e10ca62f4af9/pymupdf-1.27.2.2-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8b4bbfa6ef347fade678771a93f6364971c51a2cdc44cd2400dc4eeed1ddb4e6", size = 25169585, upload-time = "2026-03-20T09:45:05.393Z" }, - { url = "https://files.pythonhosted.org/packages/ba/fd/f1ebe24fcd31aaea8b85b3a7ac4c3fc96e20388be5466ace27c9a3c546d9/pymupdf-1.27.2.2-cp310-abi3-win32.whl", hash = "sha256:0b8e924433b7e0bd46be820899300259235997d5a747638471fb2762baa8ee30", size = 18008861, upload-time = "2026-03-20T09:45:21.353Z" }, - { url = "https://files.pythonhosted.org/packages/a8/b6/2a9a8556000199bbf80a5915dcd15d550d1e5288894316445c54726aaf53/pymupdf-1.27.2.2-cp310-abi3-win_amd64.whl", hash = "sha256:09bb53f9486ccb5297030cbc2dbdae845ba1c3c5126e96eb2d16c4f118de0b5b", size = 19238032, upload-time = "2026-03-20T09:45:37.941Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/90/88/d01992a50165e22dec057a1129826846c547feb4ba07f42720ac030ce438/pymupdf-1.27.2.2-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:800f43e60a6f01f644343c2213b8613db02eaf4f4ba235b417b3351fa99e01c0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6d/0e/9f526bc1d49d8082eff0d1547a69d541a0c5a052e71da625559efaba46a6/pymupdf-1.27.2.2-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:8e2e4299ef1ac0c9dff9be096cbd22783699673abecfa7c3f73173ae06421d73" }, + { url = "https://mirrors.aliyun.com/pypi/packages/42/be/984f0d6343935b5dd30afaed6be04fc753146bf55709e63ef28bf9ef7497/pymupdf-1.27.2.2-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:c5e3d54922db1c7da844f1208ac1db05704770988752311f81dd36694ae0a07b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/22/8e/85e9d9f11dbf34036eb1df283805ef6b885f2005a56d6533bb58ab0b8a11/pymupdf-1.27.2.2-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:892698c9768457eb0991c102c96a856c0a7062539371df5e6bee0816f3ef498e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/db/e6/386edb017e5b93f1ab0bf6653ae32f3dd8dfc834ed770212e10ca62f4af9/pymupdf-1.27.2.2-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8b4bbfa6ef347fade678771a93f6364971c51a2cdc44cd2400dc4eeed1ddb4e6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ba/fd/f1ebe24fcd31aaea8b85b3a7ac4c3fc96e20388be5466ace27c9a3c546d9/pymupdf-1.27.2.2-cp310-abi3-win32.whl", hash = "sha256:0b8e924433b7e0bd46be820899300259235997d5a747638471fb2762baa8ee30" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a8/b6/2a9a8556000199bbf80a5915dcd15d550d1e5288894316445c54726aaf53/pymupdf-1.27.2.2-cp310-abi3-win_amd64.whl", hash = "sha256:09bb53f9486ccb5297030cbc2dbdae845ba1c3c5126e96eb2d16c4f118de0b5b" }, ] [[package]] name = "pypdf" version = "6.9.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/31/83/691bdb309306232362503083cb15777491045dd54f45393a317dc7d8082f/pypdf-6.9.2.tar.gz", hash = "sha256:7f850faf2b0d4ab936582c05da32c52214c2b089d61a316627b5bfb5b0dab46c", size = 5311837, upload-time = "2026-03-23T14:53:27.983Z" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/31/83/691bdb309306232362503083cb15777491045dd54f45393a317dc7d8082f/pypdf-6.9.2.tar.gz", hash = "sha256:7f850faf2b0d4ab936582c05da32c52214c2b089d61a316627b5bfb5b0dab46c" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a5/7e/c85f41243086a8fe5d1baeba527cb26a1918158a565932b41e0f7c0b32e9/pypdf-6.9.2-py3-none-any.whl", hash = "sha256:662cf29bcb419a36a1365232449624ab40b7c2d0cfc28e54f42eeecd1fd7e844", size = 333744, upload-time = "2026-03-23T14:53:26.573Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a5/7e/c85f41243086a8fe5d1baeba527cb26a1918158a565932b41e0f7c0b32e9/pypdf-6.9.2-py3-none-any.whl", hash = "sha256:662cf29bcb419a36a1365232449624ab40b7c2d0cfc28e54f42eeecd1fd7e844" }, ] [[package]] name = "pyreadline3" version = "3.5.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0f/49/4cea918a08f02817aabae639e3d0ac046fef9f9180518a3ad394e22da148/pyreadline3-3.5.4.tar.gz", hash = "sha256:8d57d53039a1c75adba8e50dd3d992b28143480816187ea5efbd5c78e6c885b7", size = 99839, upload-time = "2024-09-19T02:40:10.062Z" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/0f/49/4cea918a08f02817aabae639e3d0ac046fef9f9180518a3ad394e22da148/pyreadline3-3.5.4.tar.gz", hash = "sha256:8d57d53039a1c75adba8e50dd3d992b28143480816187ea5efbd5c78e6c885b7" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/dc/491b7661614ab97483abf2056be1deee4dc2490ecbf7bff9ab5cdbac86e1/pyreadline3-3.5.4-py3-none-any.whl", hash = "sha256:eaf8e6cc3c49bcccf145fc6067ba8643d1df34d604a1ec0eccbf7a18e6d3fae6", size = 83178, upload-time = "2024-09-19T02:40:08.598Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5a/dc/491b7661614ab97483abf2056be1deee4dc2490ecbf7bff9ab5cdbac86e1/pyreadline3-3.5.4-py3-none-any.whl", hash = "sha256:eaf8e6cc3c49bcccf145fc6067ba8643d1df34d604a1ec0eccbf7a18e6d3fae6" }, ] [[package]] name = "pytest" version = "9.0.2" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, { name = "iniconfig" }, @@ -1817,560 +1863,560 @@ dependencies = [ { name = "pluggy" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b" }, ] [[package]] name = "pytest-asyncio" version = "1.3.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "pytest" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5" }, ] [[package]] name = "pytest-cov" version = "7.1.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "coverage" }, { name = "pluggy" }, { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678" }, ] [[package]] name = "python-dateutil" version = "2.9.0.post0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "six" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427" }, ] [[package]] name = "python-dotenv" version = "1.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bc/57/e84d88dfe0aec03b7a2d4327012c1627ab5f03652216c63d49846d7a6c58/python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca", size = 39115, upload-time = "2024-01-23T06:33:00.505Z" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/bc/57/e84d88dfe0aec03b7a2d4327012c1627ab5f03652216c63d49846d7a6c58/python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a", size = 19863, upload-time = "2024-01-23T06:32:58.246Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a" }, ] [[package]] name = "python-multipart" version = "0.0.22" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612, upload-time = "2026-01-25T10:15:56.219Z" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155" }, ] [[package]] name = "python-socks" version = "2.8.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/36/0b/cd77011c1bc01b76404f7aba07fca18aca02a19c7626e329b40201217624/python_socks-2.8.1.tar.gz", hash = "sha256:698daa9616d46dddaffe65b87db222f2902177a2d2b2c0b9a9361df607ab3687", size = 38909, upload-time = "2026-02-16T05:24:00.745Z" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/36/0b/cd77011c1bc01b76404f7aba07fca18aca02a19c7626e329b40201217624/python_socks-2.8.1.tar.gz", hash = "sha256:698daa9616d46dddaffe65b87db222f2902177a2d2b2c0b9a9361df607ab3687" } wheels = [ - { url = "https://files.pythonhosted.org/packages/15/fe/9a58cb6eec633ff6afae150ca53c16f8cc8b65862ccb3d088051efdfceb7/python_socks-2.8.1-py3-none-any.whl", hash = "sha256:28232739c4988064e725cdbcd15be194743dd23f1c910f784163365b9d7be035", size = 55087, upload-time = "2026-02-16T05:23:59.147Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/15/fe/9a58cb6eec633ff6afae150ca53c16f8cc8b65862ccb3d088051efdfceb7/python_socks-2.8.1-py3-none-any.whl", hash = "sha256:28232739c4988064e725cdbcd15be194743dd23f1c910f784163365b9d7be035" }, ] [[package]] name = "pytz" version = "2026.1.post1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/56/db/b8721d71d945e6a8ac63c0fc900b2067181dbb50805958d4d4661cf7d277/pytz-2026.1.post1.tar.gz", hash = "sha256:3378dde6a0c3d26719182142c56e60c7f9af7e968076f31aae569d72a0358ee1", size = 321088, upload-time = "2026-03-03T07:47:50.683Z" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/56/db/b8721d71d945e6a8ac63c0fc900b2067181dbb50805958d4d4661cf7d277/pytz-2026.1.post1.tar.gz", hash = "sha256:3378dde6a0c3d26719182142c56e60c7f9af7e968076f31aae569d72a0358ee1" } wheels = [ - { url = "https://files.pythonhosted.org/packages/10/99/781fe0c827be2742bcc775efefccb3b048a3a9c6ce9aec0cbf4a101677e5/pytz-2026.1.post1-py2.py3-none-any.whl", hash = "sha256:f2fd16142fda348286a75e1a524be810bb05d444e5a081f37f7affc635035f7a", size = 510489, upload-time = "2026-03-03T07:47:49.167Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/10/99/781fe0c827be2742bcc775efefccb3b048a3a9c6ce9aec0cbf4a101677e5/pytz-2026.1.post1-py2.py3-none-any.whl", hash = "sha256:f2fd16142fda348286a75e1a524be810bb05d444e5a081f37f7affc635035f7a" }, ] [[package]] name = "pywin32" version = "311" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" }, - { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" }, - { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852" }, ] [[package]] name = "pyyaml" version = "6.0.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, - { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, - { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, - { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, - { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, - { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, - { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, - { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, - { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, - { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196" }, + { url = "https://mirrors.aliyun.com/pypi/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28" }, + { url = "https://mirrors.aliyun.com/pypi/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea" }, + { url = "https://mirrors.aliyun.com/pypi/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd" }, ] [[package]] name = "referencing" version = "0.37.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "attrs" }, { name = "rpds-py" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231" }, ] [[package]] name = "regex" version = "2026.2.28" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8b/71/41455aa99a5a5ac1eaf311f5d8efd9ce6433c03ac1e0962de163350d0d97/regex-2026.2.28.tar.gz", hash = "sha256:a729e47d418ea11d03469f321aaf67cdee8954cde3ff2cf8403ab87951ad10f2", size = 415184, upload-time = "2026-02-28T02:19:42.792Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/07/42/9061b03cf0fc4b5fa2c3984cbbaed54324377e440a5c5a29d29a72518d62/regex-2026.2.28-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fcf26c3c6d0da98fada8ae4ef0aa1c3405a431c0a77eb17306d38a89b02adcd7", size = 489574, upload-time = "2026-02-28T02:16:50.455Z" }, - { url = "https://files.pythonhosted.org/packages/77/83/0c8a5623a233015595e3da499c5a1c13720ac63c107897a6037bb97af248/regex-2026.2.28-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02473c954af35dd2defeb07e44182f5705b30ea3f351a7cbffa9177beb14da5d", size = 291426, upload-time = "2026-02-28T02:16:52.52Z" }, - { url = "https://files.pythonhosted.org/packages/9e/06/3ef1ac6910dc3295ebd71b1f9bfa737e82cfead211a18b319d45f85ddd09/regex-2026.2.28-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9b65d33a17101569f86d9c5966a8b1d7fbf8afdda5a8aa219301b0a80f58cf7d", size = 289200, upload-time = "2026-02-28T02:16:54.08Z" }, - { url = "https://files.pythonhosted.org/packages/dd/c9/8cc8d850b35ab5650ff6756a1cb85286e2000b66c97520b29c1587455344/regex-2026.2.28-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71dcecaa113eebcc96622c17692672c2d104b1d71ddf7adeda90da7ddeb26fc", size = 796765, upload-time = "2026-02-28T02:16:55.905Z" }, - { url = "https://files.pythonhosted.org/packages/e9/5d/57702597627fc23278ebf36fbb497ac91c0ce7fec89ac6c81e420ca3e38c/regex-2026.2.28-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:481df4623fa4969c8b11f3433ed7d5e3dc9cec0f008356c3212b3933fb77e3d8", size = 863093, upload-time = "2026-02-28T02:16:58.094Z" }, - { url = "https://files.pythonhosted.org/packages/02/6d/f3ecad537ca2811b4d26b54ca848cf70e04fcfc138667c146a9f3157779c/regex-2026.2.28-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:64e7c6ad614573e0640f271e811a408d79a9e1fe62a46adb602f598df42a818d", size = 909455, upload-time = "2026-02-28T02:17:00.918Z" }, - { url = "https://files.pythonhosted.org/packages/9e/40/bb226f203caa22c1043c1ca79b36340156eca0f6a6742b46c3bb222a3a57/regex-2026.2.28-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6b08a06976ff4fb0d83077022fde3eca06c55432bb997d8c0495b9a4e9872f4", size = 802037, upload-time = "2026-02-28T02:17:02.842Z" }, - { url = "https://files.pythonhosted.org/packages/44/7c/c6d91d8911ac6803b45ca968e8e500c46934e58c0903cbc6d760ee817a0a/regex-2026.2.28-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:864cdd1a2ef5716b0ab468af40139e62ede1b3a53386b375ec0786bb6783fc05", size = 775113, upload-time = "2026-02-28T02:17:04.506Z" }, - { url = "https://files.pythonhosted.org/packages/dc/8d/4a9368d168d47abd4158580b8c848709667b1cd293ff0c0c277279543bd0/regex-2026.2.28-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:511f7419f7afab475fd4d639d4aedfc54205bcb0800066753ef68a59f0f330b5", size = 784194, upload-time = "2026-02-28T02:17:06.888Z" }, - { url = "https://files.pythonhosted.org/packages/cc/bf/2c72ab5d8b7be462cb1651b5cc333da1d0068740342f350fcca3bca31947/regex-2026.2.28-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b42f7466e32bf15a961cf09f35fa6323cc72e64d3d2c990b10de1274a5da0a59", size = 856846, upload-time = "2026-02-28T02:17:09.11Z" }, - { url = "https://files.pythonhosted.org/packages/7c/f4/6b65c979bb6d09f51bb2d2a7bc85de73c01ec73335d7ddd202dcb8cd1c8f/regex-2026.2.28-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8710d61737b0c0ce6836b1da7109f20d495e49b3809f30e27e9560be67a257bf", size = 763516, upload-time = "2026-02-28T02:17:11.004Z" }, - { url = "https://files.pythonhosted.org/packages/8e/32/29ea5e27400ee86d2cc2b4e80aa059df04eaf78b4f0c18576ae077aeff68/regex-2026.2.28-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4390c365fd2d45278f45afd4673cb90f7285f5701607e3ad4274df08e36140ae", size = 849278, upload-time = "2026-02-28T02:17:12.693Z" }, - { url = "https://files.pythonhosted.org/packages/1d/91/3233d03b5f865111cd517e1c95ee8b43e8b428d61fa73764a80c9bb6f537/regex-2026.2.28-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cb3b1db8ff6c7b8bf838ab05583ea15230cb2f678e569ab0e3a24d1e8320940b", size = 790068, upload-time = "2026-02-28T02:17:14.9Z" }, - { url = "https://files.pythonhosted.org/packages/76/92/abc706c1fb03b4580a09645b206a3fc032f5a9f457bc1a8038ac555658ab/regex-2026.2.28-cp312-cp312-win32.whl", hash = "sha256:f8ed9a5d4612df9d4de15878f0bc6aa7a268afbe5af21a3fdd97fa19516e978c", size = 266416, upload-time = "2026-02-28T02:17:17.15Z" }, - { url = "https://files.pythonhosted.org/packages/fa/06/2a6f7dff190e5fa9df9fb4acf2fdf17a1aa0f7f54596cba8de608db56b3a/regex-2026.2.28-cp312-cp312-win_amd64.whl", hash = "sha256:01d65fd24206c8e1e97e2e31b286c59009636c022eb5d003f52760b0f42155d4", size = 277297, upload-time = "2026-02-28T02:17:18.723Z" }, - { url = "https://files.pythonhosted.org/packages/b7/f0/58a2484851fadf284458fdbd728f580d55c1abac059ae9f048c63b92f427/regex-2026.2.28-cp312-cp312-win_arm64.whl", hash = "sha256:c0b5ccbb8ffb433939d248707d4a8b31993cb76ab1a0187ca886bf50e96df952", size = 270408, upload-time = "2026-02-28T02:17:20.328Z" }, +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/8b/71/41455aa99a5a5ac1eaf311f5d8efd9ce6433c03ac1e0962de163350d0d97/regex-2026.2.28.tar.gz", hash = "sha256:a729e47d418ea11d03469f321aaf67cdee8954cde3ff2cf8403ab87951ad10f2" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/07/42/9061b03cf0fc4b5fa2c3984cbbaed54324377e440a5c5a29d29a72518d62/regex-2026.2.28-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fcf26c3c6d0da98fada8ae4ef0aa1c3405a431c0a77eb17306d38a89b02adcd7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/77/83/0c8a5623a233015595e3da499c5a1c13720ac63c107897a6037bb97af248/regex-2026.2.28-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02473c954af35dd2defeb07e44182f5705b30ea3f351a7cbffa9177beb14da5d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9e/06/3ef1ac6910dc3295ebd71b1f9bfa737e82cfead211a18b319d45f85ddd09/regex-2026.2.28-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9b65d33a17101569f86d9c5966a8b1d7fbf8afdda5a8aa219301b0a80f58cf7d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/dd/c9/8cc8d850b35ab5650ff6756a1cb85286e2000b66c97520b29c1587455344/regex-2026.2.28-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71dcecaa113eebcc96622c17692672c2d104b1d71ddf7adeda90da7ddeb26fc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e9/5d/57702597627fc23278ebf36fbb497ac91c0ce7fec89ac6c81e420ca3e38c/regex-2026.2.28-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:481df4623fa4969c8b11f3433ed7d5e3dc9cec0f008356c3212b3933fb77e3d8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/02/6d/f3ecad537ca2811b4d26b54ca848cf70e04fcfc138667c146a9f3157779c/regex-2026.2.28-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:64e7c6ad614573e0640f271e811a408d79a9e1fe62a46adb602f598df42a818d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9e/40/bb226f203caa22c1043c1ca79b36340156eca0f6a6742b46c3bb222a3a57/regex-2026.2.28-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6b08a06976ff4fb0d83077022fde3eca06c55432bb997d8c0495b9a4e9872f4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/44/7c/c6d91d8911ac6803b45ca968e8e500c46934e58c0903cbc6d760ee817a0a/regex-2026.2.28-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:864cdd1a2ef5716b0ab468af40139e62ede1b3a53386b375ec0786bb6783fc05" }, + { url = "https://mirrors.aliyun.com/pypi/packages/dc/8d/4a9368d168d47abd4158580b8c848709667b1cd293ff0c0c277279543bd0/regex-2026.2.28-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:511f7419f7afab475fd4d639d4aedfc54205bcb0800066753ef68a59f0f330b5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cc/bf/2c72ab5d8b7be462cb1651b5cc333da1d0068740342f350fcca3bca31947/regex-2026.2.28-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b42f7466e32bf15a961cf09f35fa6323cc72e64d3d2c990b10de1274a5da0a59" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7c/f4/6b65c979bb6d09f51bb2d2a7bc85de73c01ec73335d7ddd202dcb8cd1c8f/regex-2026.2.28-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8710d61737b0c0ce6836b1da7109f20d495e49b3809f30e27e9560be67a257bf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8e/32/29ea5e27400ee86d2cc2b4e80aa059df04eaf78b4f0c18576ae077aeff68/regex-2026.2.28-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4390c365fd2d45278f45afd4673cb90f7285f5701607e3ad4274df08e36140ae" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1d/91/3233d03b5f865111cd517e1c95ee8b43e8b428d61fa73764a80c9bb6f537/regex-2026.2.28-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cb3b1db8ff6c7b8bf838ab05583ea15230cb2f678e569ab0e3a24d1e8320940b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/76/92/abc706c1fb03b4580a09645b206a3fc032f5a9f457bc1a8038ac555658ab/regex-2026.2.28-cp312-cp312-win32.whl", hash = "sha256:f8ed9a5d4612df9d4de15878f0bc6aa7a268afbe5af21a3fdd97fa19516e978c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fa/06/2a6f7dff190e5fa9df9fb4acf2fdf17a1aa0f7f54596cba8de608db56b3a/regex-2026.2.28-cp312-cp312-win_amd64.whl", hash = "sha256:01d65fd24206c8e1e97e2e31b286c59009636c022eb5d003f52760b0f42155d4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b7/f0/58a2484851fadf284458fdbd728f580d55c1abac059ae9f048c63b92f427/regex-2026.2.28-cp312-cp312-win_arm64.whl", hash = "sha256:c0b5ccbb8ffb433939d248707d4a8b31993cb76ab1a0187ca886bf50e96df952" }, ] [[package]] name = "requests" version = "2.32.5" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "certifi" }, { name = "charset-normalizer" }, { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6" }, ] [[package]] name = "requests-toolbelt" version = "1.0.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06" }, ] [[package]] name = "rich" version = "14.3.3" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "markdown-it-py" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b" } wheels = [ - { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d" }, ] [[package]] name = "rpds-py" version = "0.30.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, - { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, - { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, - { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, - { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, - { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, - { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, - { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, - { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, - { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, - { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, - { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, - { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, - { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, - { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05" }, + { url = "https://mirrors.aliyun.com/pypi/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51" }, + { url = "https://mirrors.aliyun.com/pypi/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e" }, ] [[package]] name = "ruff" version = "0.15.7" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a1/22/9e4f66ee588588dc6c9af6a994e12d26e19efbe874d1a909d09a6dac7a59/ruff-0.15.7.tar.gz", hash = "sha256:04f1ae61fc20fe0b148617c324d9d009b5f63412c0b16474f3d5f1a1a665f7ac", size = 4601277, upload-time = "2026-03-19T16:26:22.605Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/41/2f/0b08ced94412af091807b6119ca03755d651d3d93a242682bf020189db94/ruff-0.15.7-py3-none-linux_armv6l.whl", hash = "sha256:a81cc5b6910fb7dfc7c32d20652e50fa05963f6e13ead3c5915c41ac5d16668e", size = 10489037, upload-time = "2026-03-19T16:26:32.47Z" }, - { url = "https://files.pythonhosted.org/packages/91/4a/82e0fa632e5c8b1eba5ee86ecd929e8ff327bbdbfb3c6ac5d81631bef605/ruff-0.15.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:722d165bd52403f3bdabc0ce9e41fc47070ac56d7a91b4e0d097b516a53a3477", size = 10955433, upload-time = "2026-03-19T16:27:00.205Z" }, - { url = "https://files.pythonhosted.org/packages/ab/10/12586735d0ff42526ad78c049bf51d7428618c8b5c467e72508c694119df/ruff-0.15.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7fbc2448094262552146cbe1b9643a92f66559d3761f1ad0656d4991491af49e", size = 10269302, upload-time = "2026-03-19T16:26:26.183Z" }, - { url = "https://files.pythonhosted.org/packages/eb/5d/32b5c44ccf149a26623671df49cbfbd0a0ae511ff3df9d9d2426966a8d57/ruff-0.15.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b39329b60eba44156d138275323cc726bbfbddcec3063da57caa8a8b1d50adf", size = 10607625, upload-time = "2026-03-19T16:27:03.263Z" }, - { url = "https://files.pythonhosted.org/packages/5d/f1/f0001cabe86173aaacb6eb9bb734aa0605f9a6aa6fa7d43cb49cbc4af9c9/ruff-0.15.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87768c151808505f2bfc93ae44e5f9e7c8518943e5074f76ac21558ef5627c85", size = 10324743, upload-time = "2026-03-19T16:27:09.791Z" }, - { url = "https://files.pythonhosted.org/packages/7a/87/b8a8f3d56b8d848008559e7c9d8bf367934d5367f6d932ba779456e2f73b/ruff-0.15.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fb0511670002c6c529ec66c0e30641c976c8963de26a113f3a30456b702468b0", size = 11138536, upload-time = "2026-03-19T16:27:06.101Z" }, - { url = "https://files.pythonhosted.org/packages/e4/f2/4fd0d05aab0c5934b2e1464784f85ba2eab9d54bffc53fb5430d1ed8b829/ruff-0.15.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e0d19644f801849229db8345180a71bee5407b429dd217f853ec515e968a6912", size = 11994292, upload-time = "2026-03-19T16:26:48.718Z" }, - { url = "https://files.pythonhosted.org/packages/64/22/fc4483871e767e5e95d1622ad83dad5ebb830f762ed0420fde7dfa9d9b08/ruff-0.15.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4806d8e09ef5e84eb19ba833d0442f7e300b23fe3f0981cae159a248a10f0036", size = 11398981, upload-time = "2026-03-19T16:26:54.513Z" }, - { url = "https://files.pythonhosted.org/packages/b0/99/66f0343176d5eab02c3f7fcd2de7a8e0dd7a41f0d982bee56cd1c24db62b/ruff-0.15.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dce0896488562f09a27b9c91b1f58a097457143931f3c4d519690dea54e624c5", size = 11242422, upload-time = "2026-03-19T16:26:29.277Z" }, - { url = "https://files.pythonhosted.org/packages/5d/3a/a7060f145bfdcce4c987ea27788b30c60e2c81d6e9a65157ca8afe646328/ruff-0.15.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1852ce241d2bc89e5dc823e03cff4ce73d816b5c6cdadd27dbfe7b03217d2a12", size = 11232158, upload-time = "2026-03-19T16:26:42.321Z" }, - { url = "https://files.pythonhosted.org/packages/a7/53/90fbb9e08b29c048c403558d3cdd0adf2668b02ce9d50602452e187cd4af/ruff-0.15.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5f3e4b221fb4bd293f79912fc5e93a9063ebd6d0dcbd528f91b89172a9b8436c", size = 10577861, upload-time = "2026-03-19T16:26:57.459Z" }, - { url = "https://files.pythonhosted.org/packages/2f/aa/5f486226538fe4d0f0439e2da1716e1acf895e2a232b26f2459c55f8ddad/ruff-0.15.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b15e48602c9c1d9bdc504b472e90b90c97dc7d46c7028011ae67f3861ceba7b4", size = 10327310, upload-time = "2026-03-19T16:26:35.909Z" }, - { url = "https://files.pythonhosted.org/packages/99/9e/271afdffb81fe7bfc8c43ba079e9d96238f674380099457a74ccb3863857/ruff-0.15.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1b4705e0e85cedc74b0a23cf6a179dbb3df184cb227761979cc76c0440b5ab0d", size = 10840752, upload-time = "2026-03-19T16:26:45.723Z" }, - { url = "https://files.pythonhosted.org/packages/bf/29/a4ae78394f76c7759953c47884eb44de271b03a66634148d9f7d11e721bd/ruff-0.15.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:112c1fa316a558bb34319282c1200a8bf0495f1b735aeb78bfcb2991e6087580", size = 11336961, upload-time = "2026-03-19T16:26:39.076Z" }, - { url = "https://files.pythonhosted.org/packages/26/6b/8786ba5736562220d588a2f6653e6c17e90c59ced34a2d7b512ef8956103/ruff-0.15.7-py3-none-win32.whl", hash = "sha256:6d39e2d3505b082323352f733599f28169d12e891f7dd407f2d4f54b4c2886de", size = 10582538, upload-time = "2026-03-19T16:26:15.992Z" }, - { url = "https://files.pythonhosted.org/packages/2b/e9/346d4d3fffc6871125e877dae8d9a1966b254fbd92a50f8561078b88b099/ruff-0.15.7-py3-none-win_amd64.whl", hash = "sha256:4d53d712ddebcd7dace1bc395367aec12c057aacfe9adbb6d832302575f4d3a1", size = 11755839, upload-time = "2026-03-19T16:26:19.897Z" }, - { url = "https://files.pythonhosted.org/packages/8f/e8/726643a3ea68c727da31570bde48c7a10f1aa60eddd628d94078fec586ff/ruff-0.15.7-py3-none-win_arm64.whl", hash = "sha256:18e8d73f1c3fdf27931497972250340f92e8c861722161a9caeb89a58ead6ed2", size = 11023304, upload-time = "2026-03-19T16:26:51.669Z" }, +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/a1/22/9e4f66ee588588dc6c9af6a994e12d26e19efbe874d1a909d09a6dac7a59/ruff-0.15.7.tar.gz", hash = "sha256:04f1ae61fc20fe0b148617c324d9d009b5f63412c0b16474f3d5f1a1a665f7ac" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/41/2f/0b08ced94412af091807b6119ca03755d651d3d93a242682bf020189db94/ruff-0.15.7-py3-none-linux_armv6l.whl", hash = "sha256:a81cc5b6910fb7dfc7c32d20652e50fa05963f6e13ead3c5915c41ac5d16668e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/91/4a/82e0fa632e5c8b1eba5ee86ecd929e8ff327bbdbfb3c6ac5d81631bef605/ruff-0.15.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:722d165bd52403f3bdabc0ce9e41fc47070ac56d7a91b4e0d097b516a53a3477" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ab/10/12586735d0ff42526ad78c049bf51d7428618c8b5c467e72508c694119df/ruff-0.15.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7fbc2448094262552146cbe1b9643a92f66559d3761f1ad0656d4991491af49e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/eb/5d/32b5c44ccf149a26623671df49cbfbd0a0ae511ff3df9d9d2426966a8d57/ruff-0.15.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b39329b60eba44156d138275323cc726bbfbddcec3063da57caa8a8b1d50adf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5d/f1/f0001cabe86173aaacb6eb9bb734aa0605f9a6aa6fa7d43cb49cbc4af9c9/ruff-0.15.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87768c151808505f2bfc93ae44e5f9e7c8518943e5074f76ac21558ef5627c85" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7a/87/b8a8f3d56b8d848008559e7c9d8bf367934d5367f6d932ba779456e2f73b/ruff-0.15.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fb0511670002c6c529ec66c0e30641c976c8963de26a113f3a30456b702468b0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e4/f2/4fd0d05aab0c5934b2e1464784f85ba2eab9d54bffc53fb5430d1ed8b829/ruff-0.15.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e0d19644f801849229db8345180a71bee5407b429dd217f853ec515e968a6912" }, + { url = "https://mirrors.aliyun.com/pypi/packages/64/22/fc4483871e767e5e95d1622ad83dad5ebb830f762ed0420fde7dfa9d9b08/ruff-0.15.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4806d8e09ef5e84eb19ba833d0442f7e300b23fe3f0981cae159a248a10f0036" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b0/99/66f0343176d5eab02c3f7fcd2de7a8e0dd7a41f0d982bee56cd1c24db62b/ruff-0.15.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dce0896488562f09a27b9c91b1f58a097457143931f3c4d519690dea54e624c5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5d/3a/a7060f145bfdcce4c987ea27788b30c60e2c81d6e9a65157ca8afe646328/ruff-0.15.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1852ce241d2bc89e5dc823e03cff4ce73d816b5c6cdadd27dbfe7b03217d2a12" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a7/53/90fbb9e08b29c048c403558d3cdd0adf2668b02ce9d50602452e187cd4af/ruff-0.15.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5f3e4b221fb4bd293f79912fc5e93a9063ebd6d0dcbd528f91b89172a9b8436c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2f/aa/5f486226538fe4d0f0439e2da1716e1acf895e2a232b26f2459c55f8ddad/ruff-0.15.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b15e48602c9c1d9bdc504b472e90b90c97dc7d46c7028011ae67f3861ceba7b4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/99/9e/271afdffb81fe7bfc8c43ba079e9d96238f674380099457a74ccb3863857/ruff-0.15.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1b4705e0e85cedc74b0a23cf6a179dbb3df184cb227761979cc76c0440b5ab0d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bf/29/a4ae78394f76c7759953c47884eb44de271b03a66634148d9f7d11e721bd/ruff-0.15.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:112c1fa316a558bb34319282c1200a8bf0495f1b735aeb78bfcb2991e6087580" }, + { url = "https://mirrors.aliyun.com/pypi/packages/26/6b/8786ba5736562220d588a2f6653e6c17e90c59ced34a2d7b512ef8956103/ruff-0.15.7-py3-none-win32.whl", hash = "sha256:6d39e2d3505b082323352f733599f28169d12e891f7dd407f2d4f54b4c2886de" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2b/e9/346d4d3fffc6871125e877dae8d9a1966b254fbd92a50f8561078b88b099/ruff-0.15.7-py3-none-win_amd64.whl", hash = "sha256:4d53d712ddebcd7dace1bc395367aec12c057aacfe9adbb6d832302575f4d3a1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8f/e8/726643a3ea68c727da31570bde48c7a10f1aa60eddd628d94078fec586ff/ruff-0.15.7-py3-none-win_arm64.whl", hash = "sha256:18e8d73f1c3fdf27931497972250340f92e8c861722161a9caeb89a58ead6ed2" }, ] [[package]] name = "scipy" version = "1.17.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "numpy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0" } wheels = [ - { url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954, upload-time = "2026-02-23T00:17:49.855Z" }, - { url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662, upload-time = "2026-02-23T00:18:01.64Z" }, - { url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366, upload-time = "2026-02-23T00:18:12.015Z" }, - { url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017, upload-time = "2026-02-23T00:18:21.502Z" }, - { url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842, upload-time = "2026-02-23T00:18:35.367Z" }, - { url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890, upload-time = "2026-02-23T00:18:49.188Z" }, - { url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557, upload-time = "2026-02-23T00:18:54.74Z" }, - { url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856, upload-time = "2026-02-23T00:19:00.307Z" }, - { url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682, upload-time = "2026-02-23T00:19:07.67Z" }, - { url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340, upload-time = "2026-02-23T00:19:12.024Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086" }, + { url = "https://mirrors.aliyun.com/pypi/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21" }, + { url = "https://mirrors.aliyun.com/pypi/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3" }, ] [[package]] name = "shellingham" version = "1.5.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686" }, ] [[package]] name = "six" version = "1.17.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274" }, ] [[package]] name = "smmap" version = "5.0.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1f/ea/49c993d6dfdd7338c9b1000a0f36817ed7ec84577ae2e52f890d1a4ff909/smmap-5.0.3.tar.gz", hash = "sha256:4d9debb8b99007ae47165abc08670bd74cb74b5227dda7f643eccc4e9eb5642c", size = 22506, upload-time = "2026-03-09T03:43:26.1Z" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/1f/ea/49c993d6dfdd7338c9b1000a0f36817ed7ec84577ae2e52f890d1a4ff909/smmap-5.0.3.tar.gz", hash = "sha256:4d9debb8b99007ae47165abc08670bd74cb74b5227dda7f643eccc4e9eb5642c" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl", hash = "sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f", size = 24390, upload-time = "2026-03-09T03:43:24.361Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl", hash = "sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f" }, ] [[package]] name = "sniffio" version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2" }, ] [[package]] name = "socksio" version = "1.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f8/5c/48a7d9495be3d1c651198fd99dbb6ce190e2274d0f28b9051307bdec6b85/socksio-1.0.0.tar.gz", hash = "sha256:f88beb3da5b5c38b9890469de67d0cb0f9d494b78b106ca1845f96c10b91c4ac", size = 19055, upload-time = "2020-04-17T15:50:34.664Z" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/f8/5c/48a7d9495be3d1c651198fd99dbb6ce190e2274d0f28b9051307bdec6b85/socksio-1.0.0.tar.gz", hash = "sha256:f88beb3da5b5c38b9890469de67d0cb0f9d494b78b106ca1845f96c10b91c4ac" } wheels = [ - { url = "https://files.pythonhosted.org/packages/37/c3/6eeb6034408dac0fa653d126c9204ade96b819c936e136c5e8a6897eee9c/socksio-1.0.0-py3-none-any.whl", hash = "sha256:95dc1f15f9b34e8d7b16f06d74b8ccf48f609af32ab33c608d08761c5dcbb1f3", size = 12763, upload-time = "2020-04-17T15:50:31.878Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/37/c3/6eeb6034408dac0fa653d126c9204ade96b819c936e136c5e8a6897eee9c/socksio-1.0.0-py3-none-any.whl", hash = "sha256:95dc1f15f9b34e8d7b16f06d74b8ccf48f609af32ab33c608d08761c5dcbb1f3" }, ] [[package]] name = "soupsieve" version = "2.8.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349" } wheels = [ - { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95" }, ] [[package]] name = "sqlalchemy" version = "2.0.48" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1f/73/b4a9737255583b5fa858e0bb8e116eb94b88c910164ed2ed719147bde3de/sqlalchemy-2.0.48.tar.gz", hash = "sha256:5ca74f37f3369b45e1f6b7b06afb182af1fd5dde009e4ffd831830d98cbe5fe7", size = 9886075, upload-time = "2026-03-02T15:28:51.474Z" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/1f/73/b4a9737255583b5fa858e0bb8e116eb94b88c910164ed2ed719147bde3de/sqlalchemy-2.0.48.tar.gz", hash = "sha256:5ca74f37f3369b45e1f6b7b06afb182af1fd5dde009e4ffd831830d98cbe5fe7" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/91/a42ae716f8925e9659df2da21ba941f158686856107a61cc97a95e7647a3/sqlalchemy-2.0.48-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:348174f228b99f33ca1f773e85510e08927620caa59ffe7803b37170df30332b", size = 2155737, upload-time = "2026-03-02T15:49:13.207Z" }, - { url = "https://files.pythonhosted.org/packages/b9/52/f75f516a1f3888f027c1cfb5d22d4376f4b46236f2e8669dcb0cddc60275/sqlalchemy-2.0.48-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53667b5f668991e279d21f94ccfa6e45b4e3f4500e7591ae59a8012d0f010dcb", size = 3337020, upload-time = "2026-03-02T15:50:34.547Z" }, - { url = "https://files.pythonhosted.org/packages/37/9a/0c28b6371e0cdcb14f8f1930778cb3123acfcbd2c95bb9cf6b4a2ba0cce3/sqlalchemy-2.0.48-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34634e196f620c7a61d18d5cf7dc841ca6daa7961aed75d532b7e58b309ac894", size = 3349983, upload-time = "2026-03-02T15:53:25.542Z" }, - { url = "https://files.pythonhosted.org/packages/1c/46/0aee8f3ff20b1dcbceb46ca2d87fcc3d48b407925a383ff668218509d132/sqlalchemy-2.0.48-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:546572a1793cc35857a2ffa1fe0e58571af1779bcc1ffa7c9fb0839885ed69a9", size = 3279690, upload-time = "2026-03-02T15:50:36.277Z" }, - { url = "https://files.pythonhosted.org/packages/ce/8c/a957bc91293b49181350bfd55e6dfc6e30b7f7d83dc6792d72043274a390/sqlalchemy-2.0.48-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:07edba08061bc277bfdc772dd2a1a43978f5a45994dd3ede26391b405c15221e", size = 3314738, upload-time = "2026-03-02T15:53:27.519Z" }, - { url = "https://files.pythonhosted.org/packages/4b/44/1d257d9f9556661e7bdc83667cc414ba210acfc110c82938cb3611eea58f/sqlalchemy-2.0.48-cp312-cp312-win32.whl", hash = "sha256:908a3fa6908716f803b86896a09a2c4dde5f5ce2bb07aacc71ffebb57986ce99", size = 2115546, upload-time = "2026-03-02T15:54:31.591Z" }, - { url = "https://files.pythonhosted.org/packages/f2/af/c3c7e1f3a2b383155a16454df62ae8c62a30dd238e42e68c24cebebbfae6/sqlalchemy-2.0.48-cp312-cp312-win_amd64.whl", hash = "sha256:68549c403f79a8e25984376480959975212a670405e3913830614432b5daa07a", size = 2142484, upload-time = "2026-03-02T15:54:34.072Z" }, - { url = "https://files.pythonhosted.org/packages/46/2c/9664130905f03db57961b8980b05cab624afd114bf2be2576628a9f22da4/sqlalchemy-2.0.48-py3-none-any.whl", hash = "sha256:a66fe406437dd65cacd96a72689a3aaaecaebbcd62d81c5ac1c0fdbeac835096", size = 1940202, upload-time = "2026-03-02T15:52:43.285Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ef/91/a42ae716f8925e9659df2da21ba941f158686856107a61cc97a95e7647a3/sqlalchemy-2.0.48-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:348174f228b99f33ca1f773e85510e08927620caa59ffe7803b37170df30332b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b9/52/f75f516a1f3888f027c1cfb5d22d4376f4b46236f2e8669dcb0cddc60275/sqlalchemy-2.0.48-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53667b5f668991e279d21f94ccfa6e45b4e3f4500e7591ae59a8012d0f010dcb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/37/9a/0c28b6371e0cdcb14f8f1930778cb3123acfcbd2c95bb9cf6b4a2ba0cce3/sqlalchemy-2.0.48-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34634e196f620c7a61d18d5cf7dc841ca6daa7961aed75d532b7e58b309ac894" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1c/46/0aee8f3ff20b1dcbceb46ca2d87fcc3d48b407925a383ff668218509d132/sqlalchemy-2.0.48-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:546572a1793cc35857a2ffa1fe0e58571af1779bcc1ffa7c9fb0839885ed69a9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ce/8c/a957bc91293b49181350bfd55e6dfc6e30b7f7d83dc6792d72043274a390/sqlalchemy-2.0.48-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:07edba08061bc277bfdc772dd2a1a43978f5a45994dd3ede26391b405c15221e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4b/44/1d257d9f9556661e7bdc83667cc414ba210acfc110c82938cb3611eea58f/sqlalchemy-2.0.48-cp312-cp312-win32.whl", hash = "sha256:908a3fa6908716f803b86896a09a2c4dde5f5ce2bb07aacc71ffebb57986ce99" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f2/af/c3c7e1f3a2b383155a16454df62ae8c62a30dd238e42e68c24cebebbfae6/sqlalchemy-2.0.48-cp312-cp312-win_amd64.whl", hash = "sha256:68549c403f79a8e25984376480959975212a670405e3913830614432b5daa07a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/46/2c/9664130905f03db57961b8980b05cab624afd114bf2be2576628a9f22da4/sqlalchemy-2.0.48-py3-none-any.whl", hash = "sha256:a66fe406437dd65cacd96a72689a3aaaecaebbcd62d81c5ac1c0fdbeac835096" }, ] [[package]] name = "sse-starlette" version = "3.3.3" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "anyio" }, { name = "starlette" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/14/2f/9223c24f568bb7a0c03d751e609844dce0968f13b39a3f73fbb3a96cd27a/sse_starlette-3.3.3.tar.gz", hash = "sha256:72a95d7575fd5129bd0ae15275ac6432bb35ac542fdebb82889c24bb9f3f4049", size = 32420, upload-time = "2026-03-17T20:05:55.529Z" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/14/2f/9223c24f568bb7a0c03d751e609844dce0968f13b39a3f73fbb3a96cd27a/sse_starlette-3.3.3.tar.gz", hash = "sha256:72a95d7575fd5129bd0ae15275ac6432bb35ac542fdebb82889c24bb9f3f4049" } wheels = [ - { url = "https://files.pythonhosted.org/packages/78/e2/b8cff57a67dddf9a464d7e943218e031617fb3ddc133aeeb0602ff5f6c85/sse_starlette-3.3.3-py3-none-any.whl", hash = "sha256:c5abb5082a1cc1c6294d89c5290c46b5f67808cfdb612b7ec27e8ba061c22e8d", size = 14329, upload-time = "2026-03-17T20:05:54.35Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/78/e2/b8cff57a67dddf9a464d7e943218e031617fb3ddc133aeeb0602ff5f6c85/sse_starlette-3.3.3-py3-none-any.whl", hash = "sha256:c5abb5082a1cc1c6294d89c5290c46b5f67808cfdb612b7ec27e8ba061c22e8d" }, ] [[package]] name = "stack-data" version = "0.6.3" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "asttokens" }, { name = "executing" }, { name = "pure-eval" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", size = 44707, upload-time = "2023-09-30T13:58:05.479Z" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695" }, ] [[package]] name = "starlette" version = "1.2.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "anyio" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/25/44/ec35f1b6e83094b997da438a02c8c9b0ade2b1e84cfc48bd4656780760a6/starlette-1.2.1.tar.gz", hash = "sha256:9b9b5ebb992e67d6093741e63c2f59e4f6fff986f81163c087867bd7b924b3f6", size = 2701854, upload-time = "2026-05-31T01:07:51.847Z" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/25/44/ec35f1b6e83094b997da438a02c8c9b0ade2b1e84cfc48bd4656780760a6/starlette-1.2.1.tar.gz", hash = "sha256:9b9b5ebb992e67d6093741e63c2f59e4f6fff986f81163c087867bd7b924b3f6" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1c/54/196d0c1db10af76baa4f64894448505d60d3cdf70ef92cbb35f46a4e4c71/starlette-1.2.1-py3-none-any.whl", hash = "sha256:4de0082d08c8f6764a85a54cf1120d6939507a19905c7768acad2a9f875d2b89", size = 73350, upload-time = "2026-05-31T01:07:50.09Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1c/54/196d0c1db10af76baa4f64894448505d60d3cdf70ef92cbb35f46a4e4c71/starlette-1.2.1-py3-none-any.whl", hash = "sha256:4de0082d08c8f6764a85a54cf1120d6939507a19905c7768acad2a9f875d2b89" }, ] [[package]] name = "striprtf" version = "0.0.29" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f3/86/7154b7c625a3ff704581dab70c05389e1de90233b7a751f79f712c2ca0e9/striprtf-0.0.29.tar.gz", hash = "sha256:5a822d075e17417934ed3add6fc79b5fc8fb544fe4370b2f894cdd28f0ddd78e", size = 7533, upload-time = "2025-03-27T22:55:56.874Z" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/f3/86/7154b7c625a3ff704581dab70c05389e1de90233b7a751f79f712c2ca0e9/striprtf-0.0.29.tar.gz", hash = "sha256:5a822d075e17417934ed3add6fc79b5fc8fb544fe4370b2f894cdd28f0ddd78e" } wheels = [ - { url = "https://files.pythonhosted.org/packages/08/3e/1418afacc4aae04690cff282078f22620c89a99490499878ececc3021654/striprtf-0.0.29-py3-none-any.whl", hash = "sha256:0fc6a41999d015358d19627776b616424dd501ad698105c81d76734d1e14d91b", size = 7879, upload-time = "2025-03-27T22:55:55.977Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/08/3e/1418afacc4aae04690cff282078f22620c89a99490499878ececc3021654/striprtf-0.0.29-py3-none-any.whl", hash = "sha256:0fc6a41999d015358d19627776b616424dd501ad698105c81d76734d1e14d91b" }, ] [[package]] name = "sympy" version = "1.14.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "mpmath" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5" }, ] [[package]] name = "tenacity" version = "9.1.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55" }, ] [[package]] name = "tiktoken" version = "0.12.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "regex" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7d/ab/4d017d0f76ec3171d469d80fc03dfbb4e48a4bcaddaa831b31d526f05edc/tiktoken-0.12.0.tar.gz", hash = "sha256:b18ba7ee2b093863978fcb14f74b3707cdc8d4d4d3836853ce7ec60772139931", size = 37806, upload-time = "2025-10-06T20:22:45.419Z" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/7d/ab/4d017d0f76ec3171d469d80fc03dfbb4e48a4bcaddaa831b31d526f05edc/tiktoken-0.12.0.tar.gz", hash = "sha256:b18ba7ee2b093863978fcb14f74b3707cdc8d4d4d3836853ce7ec60772139931" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a4/85/be65d39d6b647c79800fd9d29241d081d4eeb06271f383bb87200d74cf76/tiktoken-0.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b97f74aca0d78a1ff21b8cd9e9925714c15a9236d6ceacf5c7327c117e6e21e8", size = 1050728, upload-time = "2025-10-06T20:21:52.756Z" }, - { url = "https://files.pythonhosted.org/packages/4a/42/6573e9129bc55c9bf7300b3a35bef2c6b9117018acca0dc760ac2d93dffe/tiktoken-0.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2b90f5ad190a4bb7c3eb30c5fa32e1e182ca1ca79f05e49b448438c3e225a49b", size = 994049, upload-time = "2025-10-06T20:21:53.782Z" }, - { url = "https://files.pythonhosted.org/packages/66/c5/ed88504d2f4a5fd6856990b230b56d85a777feab84e6129af0822f5d0f70/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:65b26c7a780e2139e73acc193e5c63ac754021f160df919add909c1492c0fb37", size = 1129008, upload-time = "2025-10-06T20:21:54.832Z" }, - { url = "https://files.pythonhosted.org/packages/f4/90/3dae6cc5436137ebd38944d396b5849e167896fc2073da643a49f372dc4f/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:edde1ec917dfd21c1f2f8046b86348b0f54a2c0547f68149d8600859598769ad", size = 1152665, upload-time = "2025-10-06T20:21:56.129Z" }, - { url = "https://files.pythonhosted.org/packages/a3/fe/26df24ce53ffde419a42f5f53d755b995c9318908288c17ec3f3448313a3/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:35a2f8ddd3824608b3d650a000c1ef71f730d0c56486845705a8248da00f9fe5", size = 1194230, upload-time = "2025-10-06T20:21:57.546Z" }, - { url = "https://files.pythonhosted.org/packages/20/cc/b064cae1a0e9fac84b0d2c46b89f4e57051a5f41324e385d10225a984c24/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83d16643edb7fa2c99eff2ab7733508aae1eebb03d5dfc46f5565862810f24e3", size = 1254688, upload-time = "2025-10-06T20:21:58.619Z" }, - { url = "https://files.pythonhosted.org/packages/81/10/b8523105c590c5b8349f2587e2fdfe51a69544bd5a76295fc20f2374f470/tiktoken-0.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffc5288f34a8bc02e1ea7047b8d041104791d2ddbf42d1e5fa07822cbffe16bd", size = 878694, upload-time = "2025-10-06T20:21:59.876Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a4/85/be65d39d6b647c79800fd9d29241d081d4eeb06271f383bb87200d74cf76/tiktoken-0.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b97f74aca0d78a1ff21b8cd9e9925714c15a9236d6ceacf5c7327c117e6e21e8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4a/42/6573e9129bc55c9bf7300b3a35bef2c6b9117018acca0dc760ac2d93dffe/tiktoken-0.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2b90f5ad190a4bb7c3eb30c5fa32e1e182ca1ca79f05e49b448438c3e225a49b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/66/c5/ed88504d2f4a5fd6856990b230b56d85a777feab84e6129af0822f5d0f70/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:65b26c7a780e2139e73acc193e5c63ac754021f160df919add909c1492c0fb37" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f4/90/3dae6cc5436137ebd38944d396b5849e167896fc2073da643a49f372dc4f/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:edde1ec917dfd21c1f2f8046b86348b0f54a2c0547f68149d8600859598769ad" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a3/fe/26df24ce53ffde419a42f5f53d755b995c9318908288c17ec3f3448313a3/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:35a2f8ddd3824608b3d650a000c1ef71f730d0c56486845705a8248da00f9fe5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/20/cc/b064cae1a0e9fac84b0d2c46b89f4e57051a5f41324e385d10225a984c24/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83d16643edb7fa2c99eff2ab7733508aae1eebb03d5dfc46f5565862810f24e3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/81/10/b8523105c590c5b8349f2587e2fdfe51a69544bd5a76295fc20f2374f470/tiktoken-0.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffc5288f34a8bc02e1ea7047b8d041104791d2ddbf42d1e5fa07822cbffe16bd" }, ] [[package]] name = "tokenizers" version = "0.22.2" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "huggingface-hub" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/73/6f/f80cfef4a312e1fb34baf7d85c72d4411afde10978d4657f8cdd811d3ccc/tokenizers-0.22.2.tar.gz", hash = "sha256:473b83b915e547aa366d1eee11806deaf419e17be16310ac0a14077f1e28f917", size = 372115, upload-time = "2026-01-05T10:45:15.988Z" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/73/6f/f80cfef4a312e1fb34baf7d85c72d4411afde10978d4657f8cdd811d3ccc/tokenizers-0.22.2.tar.gz", hash = "sha256:473b83b915e547aa366d1eee11806deaf419e17be16310ac0a14077f1e28f917" } wheels = [ - { url = "https://files.pythonhosted.org/packages/92/97/5dbfabf04c7e348e655e907ed27913e03db0923abb5dfdd120d7b25630e1/tokenizers-0.22.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:544dd704ae7238755d790de45ba8da072e9af3eea688f698b137915ae959281c", size = 3100275, upload-time = "2026-01-05T10:41:02.158Z" }, - { url = "https://files.pythonhosted.org/packages/2e/47/174dca0502ef88b28f1c9e06b73ce33500eedfac7a7692108aec220464e7/tokenizers-0.22.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1e418a55456beedca4621dbab65a318981467a2b188e982a23e117f115ce5001", size = 2981472, upload-time = "2026-01-05T10:41:00.276Z" }, - { url = "https://files.pythonhosted.org/packages/d6/84/7990e799f1309a8b87af6b948f31edaa12a3ed22d11b352eaf4f4b2e5753/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2249487018adec45d6e3554c71d46eb39fa8ea67156c640f7513eb26f318cec7", size = 3290736, upload-time = "2026-01-05T10:40:32.165Z" }, - { url = "https://files.pythonhosted.org/packages/78/59/09d0d9ba94dcd5f4f1368d4858d24546b4bdc0231c2354aa31d6199f0399/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25b85325d0815e86e0bac263506dd114578953b7b53d7de09a6485e4a160a7dd", size = 3168835, upload-time = "2026-01-05T10:40:38.847Z" }, - { url = "https://files.pythonhosted.org/packages/47/50/b3ebb4243e7160bda8d34b731e54dd8ab8b133e50775872e7a434e524c28/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bfb88f22a209ff7b40a576d5324bf8286b519d7358663db21d6246fb17eea2d5", size = 3521673, upload-time = "2026-01-05T10:40:56.614Z" }, - { url = "https://files.pythonhosted.org/packages/e0/fa/89f4cb9e08df770b57adb96f8cbb7e22695a4cb6c2bd5f0c4f0ebcf33b66/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c774b1276f71e1ef716e5486f21e76333464f47bece56bbd554485982a9e03e", size = 3724818, upload-time = "2026-01-05T10:40:44.507Z" }, - { url = "https://files.pythonhosted.org/packages/64/04/ca2363f0bfbe3b3d36e95bf67e56a4c88c8e3362b658e616d1ac185d47f2/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df6c4265b289083bf710dff49bc51ef252f9d5be33a45ee2bed151114a56207b", size = 3379195, upload-time = "2026-01-05T10:40:51.139Z" }, - { url = "https://files.pythonhosted.org/packages/2e/76/932be4b50ef6ccedf9d3c6639b056a967a86258c6d9200643f01269211ca/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:369cc9fc8cc10cb24143873a0d95438bb8ee257bb80c71989e3ee290e8d72c67", size = 3274982, upload-time = "2026-01-05T10:40:58.331Z" }, - { url = "https://files.pythonhosted.org/packages/1d/28/5f9f5a4cc211b69e89420980e483831bcc29dade307955cc9dc858a40f01/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:29c30b83d8dcd061078b05ae0cb94d3c710555fbb44861139f9f83dcca3dc3e4", size = 9478245, upload-time = "2026-01-05T10:41:04.053Z" }, - { url = "https://files.pythonhosted.org/packages/6c/fb/66e2da4704d6aadebf8cb39f1d6d1957df667ab24cff2326b77cda0dcb85/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:37ae80a28c1d3265bb1f22464c856bd23c02a05bb211e56d0c5301a435be6c1a", size = 9560069, upload-time = "2026-01-05T10:45:10.673Z" }, - { url = "https://files.pythonhosted.org/packages/16/04/fed398b05caa87ce9b1a1bb5166645e38196081b225059a6edaff6440fac/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:791135ee325f2336f498590eb2f11dc5c295232f288e75c99a36c5dbce63088a", size = 9899263, upload-time = "2026-01-05T10:45:12.559Z" }, - { url = "https://files.pythonhosted.org/packages/05/a1/d62dfe7376beaaf1394917e0f8e93ee5f67fea8fcf4107501db35996586b/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38337540fbbddff8e999d59970f3c6f35a82de10053206a7562f1ea02d046fa5", size = 10033429, upload-time = "2026-01-05T10:45:14.333Z" }, - { url = "https://files.pythonhosted.org/packages/fd/18/a545c4ea42af3df6effd7d13d250ba77a0a86fb20393143bbb9a92e434d4/tokenizers-0.22.2-cp39-abi3-win32.whl", hash = "sha256:a6bf3f88c554a2b653af81f3204491c818ae2ac6fbc09e76ef4773351292bc92", size = 2502363, upload-time = "2026-01-05T10:45:20.593Z" }, - { url = "https://files.pythonhosted.org/packages/65/71/0670843133a43d43070abeb1949abfdef12a86d490bea9cd9e18e37c5ff7/tokenizers-0.22.2-cp39-abi3-win_amd64.whl", hash = "sha256:c9ea31edff2968b44a88f97d784c2f16dc0729b8b143ed004699ebca91f05c48", size = 2747786, upload-time = "2026-01-05T10:45:18.411Z" }, - { url = "https://files.pythonhosted.org/packages/72/f4/0de46cfa12cdcbcd464cc59fde36912af405696f687e53a091fb432f694c/tokenizers-0.22.2-cp39-abi3-win_arm64.whl", hash = "sha256:9ce725d22864a1e965217204946f830c37876eee3b2ba6fc6255e8e903d5fcbc", size = 2612133, upload-time = "2026-01-05T10:45:17.232Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/92/97/5dbfabf04c7e348e655e907ed27913e03db0923abb5dfdd120d7b25630e1/tokenizers-0.22.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:544dd704ae7238755d790de45ba8da072e9af3eea688f698b137915ae959281c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2e/47/174dca0502ef88b28f1c9e06b73ce33500eedfac7a7692108aec220464e7/tokenizers-0.22.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1e418a55456beedca4621dbab65a318981467a2b188e982a23e117f115ce5001" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d6/84/7990e799f1309a8b87af6b948f31edaa12a3ed22d11b352eaf4f4b2e5753/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2249487018adec45d6e3554c71d46eb39fa8ea67156c640f7513eb26f318cec7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/78/59/09d0d9ba94dcd5f4f1368d4858d24546b4bdc0231c2354aa31d6199f0399/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25b85325d0815e86e0bac263506dd114578953b7b53d7de09a6485e4a160a7dd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/47/50/b3ebb4243e7160bda8d34b731e54dd8ab8b133e50775872e7a434e524c28/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bfb88f22a209ff7b40a576d5324bf8286b519d7358663db21d6246fb17eea2d5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e0/fa/89f4cb9e08df770b57adb96f8cbb7e22695a4cb6c2bd5f0c4f0ebcf33b66/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c774b1276f71e1ef716e5486f21e76333464f47bece56bbd554485982a9e03e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/64/04/ca2363f0bfbe3b3d36e95bf67e56a4c88c8e3362b658e616d1ac185d47f2/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df6c4265b289083bf710dff49bc51ef252f9d5be33a45ee2bed151114a56207b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2e/76/932be4b50ef6ccedf9d3c6639b056a967a86258c6d9200643f01269211ca/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:369cc9fc8cc10cb24143873a0d95438bb8ee257bb80c71989e3ee290e8d72c67" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1d/28/5f9f5a4cc211b69e89420980e483831bcc29dade307955cc9dc858a40f01/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:29c30b83d8dcd061078b05ae0cb94d3c710555fbb44861139f9f83dcca3dc3e4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6c/fb/66e2da4704d6aadebf8cb39f1d6d1957df667ab24cff2326b77cda0dcb85/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:37ae80a28c1d3265bb1f22464c856bd23c02a05bb211e56d0c5301a435be6c1a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/16/04/fed398b05caa87ce9b1a1bb5166645e38196081b225059a6edaff6440fac/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:791135ee325f2336f498590eb2f11dc5c295232f288e75c99a36c5dbce63088a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/05/a1/d62dfe7376beaaf1394917e0f8e93ee5f67fea8fcf4107501db35996586b/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38337540fbbddff8e999d59970f3c6f35a82de10053206a7562f1ea02d046fa5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fd/18/a545c4ea42af3df6effd7d13d250ba77a0a86fb20393143bbb9a92e434d4/tokenizers-0.22.2-cp39-abi3-win32.whl", hash = "sha256:a6bf3f88c554a2b653af81f3204491c818ae2ac6fbc09e76ef4773351292bc92" }, + { url = "https://mirrors.aliyun.com/pypi/packages/65/71/0670843133a43d43070abeb1949abfdef12a86d490bea9cd9e18e37c5ff7/tokenizers-0.22.2-cp39-abi3-win_amd64.whl", hash = "sha256:c9ea31edff2968b44a88f97d784c2f16dc0729b8b143ed004699ebca91f05c48" }, + { url = "https://mirrors.aliyun.com/pypi/packages/72/f4/0de46cfa12cdcbcd464cc59fde36912af405696f687e53a091fb432f694c/tokenizers-0.22.2-cp39-abi3-win_arm64.whl", hash = "sha256:9ce725d22864a1e965217204946f830c37876eee3b2ba6fc6255e8e903d5fcbc" }, ] [[package]] name = "toml" version = "0.10.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f", size = 22253, upload-time = "2020-11-01T01:40:22.204Z" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f" } wheels = [ - { url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588, upload-time = "2020-11-01T01:40:20.672Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b" }, ] [[package]] name = "tqdm" version = "4.67.3" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb" } wheels = [ - { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf" }, ] [[package]] name = "traitlets" version = "5.14.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/eb/79/72064e6a701c2183016abbbfedaba506d81e30e232a68c9f0d6f6fcd1574/traitlets-5.14.3.tar.gz", hash = "sha256:9ed0579d3502c94b4b3732ac120375cda96f923114522847de4b3bb98b96b6b7", size = 161621, upload-time = "2024-04-19T11:11:49.746Z" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/eb/79/72064e6a701c2183016abbbfedaba506d81e30e232a68c9f0d6f6fcd1574/traitlets-5.14.3.tar.gz", hash = "sha256:9ed0579d3502c94b4b3732ac120375cda96f923114522847de4b3bb98b96b6b7" } wheels = [ - { url = "https://files.pythonhosted.org/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f", size = 85359, upload-time = "2024-04-19T11:11:46.763Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f" }, ] [[package]] name = "tree-sitter" version = "0.25.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/66/7c/0350cfc47faadc0d3cf7d8237a4e34032b3014ddf4a12ded9933e1648b55/tree-sitter-0.25.2.tar.gz", hash = "sha256:fe43c158555da46723b28b52e058ad444195afd1db3ca7720c59a254544e9c20", size = 177961, upload-time = "2025-09-25T17:37:59.751Z" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/66/7c/0350cfc47faadc0d3cf7d8237a4e34032b3014ddf4a12ded9933e1648b55/tree-sitter-0.25.2.tar.gz", hash = "sha256:fe43c158555da46723b28b52e058ad444195afd1db3ca7720c59a254544e9c20" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3c/9e/20c2a00a862f1c2897a436b17edb774e831b22218083b459d0d081c9db33/tree_sitter-0.25.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ddabfff809ffc983fc9963455ba1cecc90295803e06e140a4c83e94c1fa3d960", size = 146941, upload-time = "2025-09-25T17:37:34.813Z" }, - { url = "https://files.pythonhosted.org/packages/ef/04/8512e2062e652a1016e840ce36ba1cc33258b0dcc4e500d8089b4054afec/tree_sitter-0.25.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c0c0ab5f94938a23fe81928a21cc0fac44143133ccc4eb7eeb1b92f84748331c", size = 137699, upload-time = "2025-09-25T17:37:36.349Z" }, - { url = "https://files.pythonhosted.org/packages/47/8a/d48c0414db19307b0fb3bb10d76a3a0cbe275bb293f145ee7fba2abd668e/tree_sitter-0.25.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dd12d80d91d4114ca097626eb82714618dcdfacd6a5e0955216c6485c350ef99", size = 607125, upload-time = "2025-09-25T17:37:37.725Z" }, - { url = "https://files.pythonhosted.org/packages/39/d1/b95f545e9fc5001b8a78636ef942a4e4e536580caa6a99e73dd0a02e87aa/tree_sitter-0.25.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b43a9e4c89d4d0839de27cd4d6902d33396de700e9ff4c5ab7631f277a85ead9", size = 635418, upload-time = "2025-09-25T17:37:38.922Z" }, - { url = "https://files.pythonhosted.org/packages/de/4d/b734bde3fb6f3513a010fa91f1f2875442cdc0382d6a949005cd84563d8f/tree_sitter-0.25.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fbb1706407c0e451c4f8cc016fec27d72d4b211fdd3173320b1ada7a6c74c3ac", size = 631250, upload-time = "2025-09-25T17:37:40.039Z" }, - { url = "https://files.pythonhosted.org/packages/46/f2/5f654994f36d10c64d50a192239599fcae46677491c8dd53e7579c35a3e3/tree_sitter-0.25.2-cp312-cp312-win_amd64.whl", hash = "sha256:6d0302550bbe4620a5dc7649517c4409d74ef18558276ce758419cf09e578897", size = 127156, upload-time = "2025-09-25T17:37:41.132Z" }, - { url = "https://files.pythonhosted.org/packages/67/23/148c468d410efcf0a9535272d81c258d840c27b34781d625f1f627e2e27d/tree_sitter-0.25.2-cp312-cp312-win_arm64.whl", hash = "sha256:0c8b6682cac77e37cfe5cf7ec388844957f48b7bd8d6321d0ca2d852994e10d5", size = 113984, upload-time = "2025-09-25T17:37:42.074Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3c/9e/20c2a00a862f1c2897a436b17edb774e831b22218083b459d0d081c9db33/tree_sitter-0.25.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ddabfff809ffc983fc9963455ba1cecc90295803e06e140a4c83e94c1fa3d960" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ef/04/8512e2062e652a1016e840ce36ba1cc33258b0dcc4e500d8089b4054afec/tree_sitter-0.25.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c0c0ab5f94938a23fe81928a21cc0fac44143133ccc4eb7eeb1b92f84748331c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/47/8a/d48c0414db19307b0fb3bb10d76a3a0cbe275bb293f145ee7fba2abd668e/tree_sitter-0.25.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dd12d80d91d4114ca097626eb82714618dcdfacd6a5e0955216c6485c350ef99" }, + { url = "https://mirrors.aliyun.com/pypi/packages/39/d1/b95f545e9fc5001b8a78636ef942a4e4e536580caa6a99e73dd0a02e87aa/tree_sitter-0.25.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b43a9e4c89d4d0839de27cd4d6902d33396de700e9ff4c5ab7631f277a85ead9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/de/4d/b734bde3fb6f3513a010fa91f1f2875442cdc0382d6a949005cd84563d8f/tree_sitter-0.25.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fbb1706407c0e451c4f8cc016fec27d72d4b211fdd3173320b1ada7a6c74c3ac" }, + { url = "https://mirrors.aliyun.com/pypi/packages/46/f2/5f654994f36d10c64d50a192239599fcae46677491c8dd53e7579c35a3e3/tree_sitter-0.25.2-cp312-cp312-win_amd64.whl", hash = "sha256:6d0302550bbe4620a5dc7649517c4409d74ef18558276ce758419cf09e578897" }, + { url = "https://mirrors.aliyun.com/pypi/packages/67/23/148c468d410efcf0a9535272d81c258d840c27b34781d625f1f627e2e27d/tree_sitter-0.25.2-cp312-cp312-win_arm64.whl", hash = "sha256:0c8b6682cac77e37cfe5cf7ec388844957f48b7bd8d6321d0ca2d852994e10d5" }, ] [[package]] name = "typer" version = "0.23.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "annotated-doc" }, { name = "click" }, { name = "rich" }, { name = "shellingham" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fd/07/b822e1b307d40e263e8253d2384cf98c51aa2368cc7ba9a07e523a1d964b/typer-0.23.1.tar.gz", hash = "sha256:2070374e4d31c83e7b61362fd859aa683576432fd5b026b060ad6b4cd3b86134", size = 120047, upload-time = "2026-02-13T10:04:30.984Z" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/fd/07/b822e1b307d40e263e8253d2384cf98c51aa2368cc7ba9a07e523a1d964b/typer-0.23.1.tar.gz", hash = "sha256:2070374e4d31c83e7b61362fd859aa683576432fd5b026b060ad6b4cd3b86134" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d5/91/9b286ab899c008c2cb05e8be99814807e7fbbd33f0c0c960470826e5ac82/typer-0.23.1-py3-none-any.whl", hash = "sha256:3291ad0d3c701cbf522012faccfbb29352ff16ad262db2139e6b01f15781f14e", size = 56813, upload-time = "2026-02-13T10:04:32.008Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d5/91/9b286ab899c008c2cb05e8be99814807e7fbbd33f0c0c960470826e5ac82/typer-0.23.1-py3-none-any.whl", hash = "sha256:3291ad0d3c701cbf522012faccfbb29352ff16ad262db2139e6b01f15781f14e" }, ] [[package]] name = "typing-extensions" version = "4.15.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466" } wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548" }, ] [[package]] name = "typing-inspection" version = "0.4.2" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464" } wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7" }, ] [[package]] name = "ulid-py" version = "1.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3b/53/d14a8ec344048e21431821cb49e9a6722384f982b889c2dd449428dbdcc1/ulid-py-1.1.0.tar.gz", hash = "sha256:dc6884be91558df077c3011b9fb0c87d1097cb8fc6534b11f310161afd5738f0", size = 22514, upload-time = "2020-09-15T15:35:09.414Z" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/3b/53/d14a8ec344048e21431821cb49e9a6722384f982b889c2dd449428dbdcc1/ulid-py-1.1.0.tar.gz", hash = "sha256:dc6884be91558df077c3011b9fb0c87d1097cb8fc6534b11f310161afd5738f0" } wheels = [ - { url = "https://files.pythonhosted.org/packages/42/7c/a12c879fe6c2b136a718c142115ff99397fbf62b4929d970d58ae386d55f/ulid_py-1.1.0-py2.py3-none-any.whl", hash = "sha256:b56a0f809ef90d6020b21b89a87a48edc7c03aea80e5ed5174172e82d76e3987", size = 25753, upload-time = "2020-09-15T15:35:08.075Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/42/7c/a12c879fe6c2b136a718c142115ff99397fbf62b4929d970d58ae386d55f/ulid_py-1.1.0-py2.py3-none-any.whl", hash = "sha256:b56a0f809ef90d6020b21b89a87a48edc7c03aea80e5ed5174172e82d76e3987" }, ] [[package]] name = "urllib3" version = "2.6.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed" } wheels = [ - { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4" }, ] [[package]] name = "uvicorn" version = "0.42.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "click" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e3/ad/4a96c425be6fb67e0621e62d86c402b4a17ab2be7f7c055d9bd2f638b9e2/uvicorn-0.42.0.tar.gz", hash = "sha256:9b1f190ce15a2dd22e7758651d9b6d12df09a13d51ba5bf4fc33c383a48e1775", size = 85393, upload-time = "2026-03-16T06:19:50.077Z" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/e3/ad/4a96c425be6fb67e0621e62d86c402b4a17ab2be7f7c055d9bd2f638b9e2/uvicorn-0.42.0.tar.gz", hash = "sha256:9b1f190ce15a2dd22e7758651d9b6d12df09a13d51ba5bf4fc33c383a48e1775" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0a/89/f8827ccff89c1586027a105e5630ff6139a64da2515e24dafe860bd9ae4d/uvicorn-0.42.0-py3-none-any.whl", hash = "sha256:96c30f5c7abe6f74ae8900a70e92b85ad6613b745d4879eb9b16ccad15645359", size = 68830, upload-time = "2026-03-16T06:19:48.325Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0a/89/f8827ccff89c1586027a105e5630ff6139a64da2515e24dafe860bd9ae4d/uvicorn-0.42.0-py3-none-any.whl", hash = "sha256:96c30f5c7abe6f74ae8900a70e92b85ad6613b745d4879eb9b16ccad15645359" }, ] [package.optional-dependencies] @@ -2387,159 +2433,159 @@ standard = [ [[package]] name = "uvloop" version = "0.22.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, - { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, - { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, - { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, - { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, - { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0" }, ] [[package]] name = "watchdog" version = "6.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282" } wheels = [ - { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, - { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, - { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, - { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, - { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, - { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, - { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, - { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, - { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, - { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, - { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, - { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, - { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948" }, + { url = "https://mirrors.aliyun.com/pypi/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26" }, + { url = "https://mirrors.aliyun.com/pypi/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680" }, + { url = "https://mirrors.aliyun.com/pypi/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f" }, ] [[package]] name = "watchfiles" version = "1.1.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "anyio" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2" } wheels = [ - { url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745, upload-time = "2025-10-14T15:04:46.731Z" }, - { url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769, upload-time = "2025-10-14T15:04:48.003Z" }, - { url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374, upload-time = "2025-10-14T15:04:49.179Z" }, - { url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485, upload-time = "2025-10-14T15:04:50.155Z" }, - { url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813, upload-time = "2025-10-14T15:04:51.059Z" }, - { url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816, upload-time = "2025-10-14T15:04:52.031Z" }, - { url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186, upload-time = "2025-10-14T15:04:53.064Z" }, - { url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812, upload-time = "2025-10-14T15:04:55.174Z" }, - { url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196, upload-time = "2025-10-14T15:04:56.22Z" }, - { url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657, upload-time = "2025-10-14T15:04:57.521Z" }, - { url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042, upload-time = "2025-10-14T15:04:59.046Z" }, - { url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410, upload-time = "2025-10-14T15:05:00.081Z" }, - { url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209, upload-time = "2025-10-14T15:05:01.168Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803" }, + { url = "https://mirrors.aliyun.com/pypi/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94" }, + { url = "https://mirrors.aliyun.com/pypi/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404" }, ] [[package]] name = "wcwidth" version = "0.6.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/35/a2/8e3becb46433538a38726c948d3399905a4c7cabd0df578ede5dc51f0ec2/wcwidth-0.6.0.tar.gz", hash = "sha256:cdc4e4262d6ef9a1a57e018384cbeb1208d8abbc64176027e2c2455c81313159", size = 159684, upload-time = "2026-02-06T19:19:40.919Z" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/35/a2/8e3becb46433538a38726c948d3399905a4c7cabd0df578ede5dc51f0ec2/wcwidth-0.6.0.tar.gz", hash = "sha256:cdc4e4262d6ef9a1a57e018384cbeb1208d8abbc64176027e2c2455c81313159" } wheels = [ - { url = "https://files.pythonhosted.org/packages/68/5a/199c59e0a824a3db2b89c5d2dade7ab5f9624dbf6448dc291b46d5ec94d3/wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad", size = 94189, upload-time = "2026-02-06T19:19:39.646Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/68/5a/199c59e0a824a3db2b89c5d2dade7ab5f9624dbf6448dc291b46d5ec94d3/wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad" }, ] [[package]] name = "websockets" version = "16.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5" } wheels = [ - { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, - { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, - { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, - { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, - { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, - { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, - { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, - { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, - { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, - { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79" }, + { url = "https://mirrors.aliyun.com/pypi/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39" }, + { url = "https://mirrors.aliyun.com/pypi/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec" }, ] [[package]] name = "wecom-aibot-sdk" version = "1.0.5" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "cryptography" }, { name = "httpx" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/04/f3/3809ff4562145094f29e96e8b4e0267a54941c40d26579a82d9c5928ad33/wecom_aibot_sdk-1.0.5.tar.gz", hash = "sha256:78b0d748f945fd877ca94a8e369d13033028dc3d758fe1635945df31bf984e1a", size = 58176, upload-time = "2026-03-24T04:05:34.801Z" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/04/f3/3809ff4562145094f29e96e8b4e0267a54941c40d26579a82d9c5928ad33/wecom_aibot_sdk-1.0.5.tar.gz", hash = "sha256:78b0d748f945fd877ca94a8e369d13033028dc3d758fe1635945df31bf984e1a" } wheels = [ - { url = "https://files.pythonhosted.org/packages/25/c2/e3a681e0cbaa4394fca003aa0f47a212665264ae49c343e144b458dd5ef0/wecom_aibot_sdk-1.0.5-py3-none-any.whl", hash = "sha256:3f23c7eca0bf8092244840c4563d7fda6aa6741604ff4c329f85c2e6bb47bf05", size = 26229, upload-time = "2026-03-24T04:05:33.298Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/25/c2/e3a681e0cbaa4394fca003aa0f47a212665264ae49c343e144b458dd5ef0/wecom_aibot_sdk-1.0.5-py3-none-any.whl", hash = "sha256:3f23c7eca0bf8092244840c4563d7fda6aa6741604ff4c329f85c2e6bb47bf05" }, ] [[package]] name = "wrapt" version = "1.17.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547, upload-time = "2025-08-12T05:53:21.714Z" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9f/41/cad1aba93e752f1f9268c77270da3c469883d56e2798e7df6240dcb2287b/wrapt-1.17.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ab232e7fdb44cdfbf55fc3afa31bcdb0d8980b9b95c38b6405df2acb672af0e0", size = 53998, upload-time = "2025-08-12T05:51:47.138Z" }, - { url = "https://files.pythonhosted.org/packages/60/f8/096a7cc13097a1869fe44efe68dace40d2a16ecb853141394047f0780b96/wrapt-1.17.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9baa544e6acc91130e926e8c802a17f3b16fbea0fd441b5a60f5cf2cc5c3deba", size = 39020, upload-time = "2025-08-12T05:51:35.906Z" }, - { url = "https://files.pythonhosted.org/packages/33/df/bdf864b8997aab4febb96a9ae5c124f700a5abd9b5e13d2a3214ec4be705/wrapt-1.17.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6b538e31eca1a7ea4605e44f81a48aa24c4632a277431a6ed3f328835901f4fd", size = 39098, upload-time = "2025-08-12T05:51:57.474Z" }, - { url = "https://files.pythonhosted.org/packages/9f/81/5d931d78d0eb732b95dc3ddaeeb71c8bb572fb01356e9133916cd729ecdd/wrapt-1.17.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:042ec3bb8f319c147b1301f2393bc19dba6e176b7da446853406d041c36c7828", size = 88036, upload-time = "2025-08-12T05:52:34.784Z" }, - { url = "https://files.pythonhosted.org/packages/ca/38/2e1785df03b3d72d34fc6252d91d9d12dc27a5c89caef3335a1bbb8908ca/wrapt-1.17.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3af60380ba0b7b5aeb329bc4e402acd25bd877e98b3727b0135cb5c2efdaefe9", size = 88156, upload-time = "2025-08-12T05:52:13.599Z" }, - { url = "https://files.pythonhosted.org/packages/b3/8b/48cdb60fe0603e34e05cffda0b2a4adab81fd43718e11111a4b0100fd7c1/wrapt-1.17.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b02e424deef65c9f7326d8c19220a2c9040c51dc165cddb732f16198c168396", size = 87102, upload-time = "2025-08-12T05:52:14.56Z" }, - { url = "https://files.pythonhosted.org/packages/3c/51/d81abca783b58f40a154f1b2c56db1d2d9e0d04fa2d4224e357529f57a57/wrapt-1.17.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:74afa28374a3c3a11b3b5e5fca0ae03bef8450d6aa3ab3a1e2c30e3a75d023dc", size = 87732, upload-time = "2025-08-12T05:52:36.165Z" }, - { url = "https://files.pythonhosted.org/packages/9e/b1/43b286ca1392a006d5336412d41663eeef1ad57485f3e52c767376ba7e5a/wrapt-1.17.3-cp312-cp312-win32.whl", hash = "sha256:4da9f45279fff3543c371d5ababc57a0384f70be244de7759c85a7f989cb4ebe", size = 36705, upload-time = "2025-08-12T05:53:07.123Z" }, - { url = "https://files.pythonhosted.org/packages/28/de/49493f962bd3c586ab4b88066e967aa2e0703d6ef2c43aa28cb83bf7b507/wrapt-1.17.3-cp312-cp312-win_amd64.whl", hash = "sha256:e71d5c6ebac14875668a1e90baf2ea0ef5b7ac7918355850c0908ae82bcb297c", size = 38877, upload-time = "2025-08-12T05:53:05.436Z" }, - { url = "https://files.pythonhosted.org/packages/f1/48/0f7102fe9cb1e8a5a77f80d4f0956d62d97034bbe88d33e94699f99d181d/wrapt-1.17.3-cp312-cp312-win_arm64.whl", hash = "sha256:604d076c55e2fdd4c1c03d06dc1a31b95130010517b5019db15365ec4a405fc6", size = 36885, upload-time = "2025-08-12T05:52:54.367Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9f/41/cad1aba93e752f1f9268c77270da3c469883d56e2798e7df6240dcb2287b/wrapt-1.17.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ab232e7fdb44cdfbf55fc3afa31bcdb0d8980b9b95c38b6405df2acb672af0e0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/60/f8/096a7cc13097a1869fe44efe68dace40d2a16ecb853141394047f0780b96/wrapt-1.17.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9baa544e6acc91130e926e8c802a17f3b16fbea0fd441b5a60f5cf2cc5c3deba" }, + { url = "https://mirrors.aliyun.com/pypi/packages/33/df/bdf864b8997aab4febb96a9ae5c124f700a5abd9b5e13d2a3214ec4be705/wrapt-1.17.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6b538e31eca1a7ea4605e44f81a48aa24c4632a277431a6ed3f328835901f4fd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9f/81/5d931d78d0eb732b95dc3ddaeeb71c8bb572fb01356e9133916cd729ecdd/wrapt-1.17.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:042ec3bb8f319c147b1301f2393bc19dba6e176b7da446853406d041c36c7828" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ca/38/2e1785df03b3d72d34fc6252d91d9d12dc27a5c89caef3335a1bbb8908ca/wrapt-1.17.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3af60380ba0b7b5aeb329bc4e402acd25bd877e98b3727b0135cb5c2efdaefe9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b3/8b/48cdb60fe0603e34e05cffda0b2a4adab81fd43718e11111a4b0100fd7c1/wrapt-1.17.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b02e424deef65c9f7326d8c19220a2c9040c51dc165cddb732f16198c168396" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3c/51/d81abca783b58f40a154f1b2c56db1d2d9e0d04fa2d4224e357529f57a57/wrapt-1.17.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:74afa28374a3c3a11b3b5e5fca0ae03bef8450d6aa3ab3a1e2c30e3a75d023dc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9e/b1/43b286ca1392a006d5336412d41663eeef1ad57485f3e52c767376ba7e5a/wrapt-1.17.3-cp312-cp312-win32.whl", hash = "sha256:4da9f45279fff3543c371d5ababc57a0384f70be244de7759c85a7f989cb4ebe" }, + { url = "https://mirrors.aliyun.com/pypi/packages/28/de/49493f962bd3c586ab4b88066e967aa2e0703d6ef2c43aa28cb83bf7b507/wrapt-1.17.3-cp312-cp312-win_amd64.whl", hash = "sha256:e71d5c6ebac14875668a1e90baf2ea0ef5b7ac7918355850c0908ae82bcb297c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f1/48/0f7102fe9cb1e8a5a77f80d4f0956d62d97034bbe88d33e94699f99d181d/wrapt-1.17.3-cp312-cp312-win_arm64.whl", hash = "sha256:604d076c55e2fdd4c1c03d06dc1a31b95130010517b5019db15365ec4a405fc6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22" }, ] [[package]] name = "yarl" version = "1.23.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "idna" }, { name = "multidict" }, { name = "propcache" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/23/6e/beb1beec874a72f23815c1434518bfc4ed2175065173fb138c3705f658d4/yarl-1.23.0.tar.gz", hash = "sha256:53b1ea6ca88ebd4420379c330aea57e258408dd0df9af0992e5de2078dc9f5d5", size = 194676, upload-time = "2026-03-01T22:07:53.373Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/88/8a/94615bc31022f711add374097ad4144d569e95ff3c38d39215d07ac153a0/yarl-1.23.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1932b6b8bba8d0160a9d1078aae5838a66039e8832d41d2992daa9a3a08f7860", size = 124737, upload-time = "2026-03-01T22:05:12.897Z" }, - { url = "https://files.pythonhosted.org/packages/e3/6f/c6554045d59d64052698add01226bc867b52fe4a12373415d7991fdca95d/yarl-1.23.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:411225bae281f114067578891bc75534cfb3d92a3b4dfef7a6ca78ba354e6069", size = 87029, upload-time = "2026-03-01T22:05:14.376Z" }, - { url = "https://files.pythonhosted.org/packages/19/2a/725ecc166d53438bc88f76822ed4b1e3b10756e790bafd7b523fe97c322d/yarl-1.23.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:13a563739ae600a631c36ce096615fe307f131344588b0bc0daec108cdb47b25", size = 86310, upload-time = "2026-03-01T22:05:15.71Z" }, - { url = "https://files.pythonhosted.org/packages/99/30/58260ed98e6ff7f90ba84442c1ddd758c9170d70327394a6227b310cd60f/yarl-1.23.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cbf44c5cb4a7633d078788e1b56387e3d3cf2b8139a3be38040b22d6c3221c8", size = 97587, upload-time = "2026-03-01T22:05:17.384Z" }, - { url = "https://files.pythonhosted.org/packages/76/0a/8b08aac08b50682e65759f7f8dde98ae8168f72487e7357a5d684c581ef9/yarl-1.23.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53ad387048f6f09a8969631e4de3f1bf70c50e93545d64af4f751b2498755072", size = 92528, upload-time = "2026-03-01T22:05:18.804Z" }, - { url = "https://files.pythonhosted.org/packages/52/07/0b7179101fe5f8385ec6c6bb5d0cb9f76bd9fb4a769591ab6fb5cdbfc69a/yarl-1.23.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4a59ba56f340334766f3a4442e0efd0af895fae9e2b204741ef885c446b3a1a8", size = 105339, upload-time = "2026-03-01T22:05:20.235Z" }, - { url = "https://files.pythonhosted.org/packages/d3/8a/36d82869ab5ec829ca8574dfcb92b51286fcfb1e9c7a73659616362dc880/yarl-1.23.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:803a3c3ce4acc62eaf01eaca1208dcf0783025ef27572c3336502b9c232005e7", size = 105061, upload-time = "2026-03-01T22:05:22.268Z" }, - { url = "https://files.pythonhosted.org/packages/66/3e/868e5c3364b6cee19ff3e1a122194fa4ce51def02c61023970442162859e/yarl-1.23.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3d2bff8f37f8d0f96c7ec554d16945050d54462d6e95414babaa18bfafc7f51", size = 100132, upload-time = "2026-03-01T22:05:23.638Z" }, - { url = "https://files.pythonhosted.org/packages/cf/26/9c89acf82f08a52cb52d6d39454f8d18af15f9d386a23795389d1d423823/yarl-1.23.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c75eb09e8d55bceb4367e83496ff8ef2bc7ea6960efb38e978e8073ea59ecb67", size = 99289, upload-time = "2026-03-01T22:05:25.749Z" }, - { url = "https://files.pythonhosted.org/packages/6f/54/5b0db00d2cb056922356104468019c0a132e89c8d3ab67d8ede9f4483d2a/yarl-1.23.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877b0738624280e34c55680d6054a307aa94f7d52fa0e3034a9cc6e790871da7", size = 96950, upload-time = "2026-03-01T22:05:27.318Z" }, - { url = "https://files.pythonhosted.org/packages/f6/40/10fa93811fd439341fad7e0718a86aca0de9548023bbb403668d6555acab/yarl-1.23.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b5405bb8f0e783a988172993cfc627e4d9d00432d6bbac65a923041edacf997d", size = 93960, upload-time = "2026-03-01T22:05:28.738Z" }, - { url = "https://files.pythonhosted.org/packages/bc/d2/8ae2e6cd77d0805f4526e30ec43b6f9a3dfc542d401ac4990d178e4bf0cf/yarl-1.23.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c3a3598a832590c5a3ce56ab5576361b5688c12cb1d39429cf5dba30b510760", size = 104703, upload-time = "2026-03-01T22:05:30.438Z" }, - { url = "https://files.pythonhosted.org/packages/2f/0c/b3ceacf82c3fe21183ce35fa2acf5320af003d52bc1fcf5915077681142e/yarl-1.23.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8419ebd326430d1cbb7efb5292330a2cf39114e82df5cc3d83c9a0d5ebeaf2f2", size = 98325, upload-time = "2026-03-01T22:05:31.835Z" }, - { url = "https://files.pythonhosted.org/packages/9d/e0/12900edd28bdab91a69bd2554b85ad7b151f64e8b521fe16f9ad2f56477a/yarl-1.23.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:be61f6fff406ca40e3b1d84716fde398fc08bc63dd96d15f3a14230a0973ed86", size = 105067, upload-time = "2026-03-01T22:05:33.358Z" }, - { url = "https://files.pythonhosted.org/packages/15/61/74bb1182cf79c9bbe4eb6b1f14a57a22d7a0be5e9cedf8e2d5c2086474c3/yarl-1.23.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3ceb13c5c858d01321b5d9bb65e4cf37a92169ea470b70fec6f236b2c9dd7e34", size = 100285, upload-time = "2026-03-01T22:05:35.4Z" }, - { url = "https://files.pythonhosted.org/packages/69/7f/cd5ef733f2550de6241bd8bd8c3febc78158b9d75f197d9c7baa113436af/yarl-1.23.0-cp312-cp312-win32.whl", hash = "sha256:fffc45637bcd6538de8b85f51e3df3223e4ad89bccbfca0481c08c7fc8b7ed7d", size = 82359, upload-time = "2026-03-01T22:05:36.811Z" }, - { url = "https://files.pythonhosted.org/packages/f5/be/25216a49daeeb7af2bec0db22d5e7df08ed1d7c9f65d78b14f3b74fd72fc/yarl-1.23.0-cp312-cp312-win_amd64.whl", hash = "sha256:f69f57305656a4852f2a7203efc661d8c042e6cc67f7acd97d8667fb448a426e", size = 87674, upload-time = "2026-03-01T22:05:38.171Z" }, - { url = "https://files.pythonhosted.org/packages/d2/35/aeab955d6c425b227d5b7247eafb24f2653fedc32f95373a001af5dfeb9e/yarl-1.23.0-cp312-cp312-win_arm64.whl", hash = "sha256:6e87a6e8735b44816e7db0b2fbc9686932df473c826b0d9743148432e10bb9b9", size = 81879, upload-time = "2026-03-01T22:05:40.006Z" }, - { url = "https://files.pythonhosted.org/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", size = 48288, upload-time = "2026-03-01T22:07:51.388Z" }, +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/23/6e/beb1beec874a72f23815c1434518bfc4ed2175065173fb138c3705f658d4/yarl-1.23.0.tar.gz", hash = "sha256:53b1ea6ca88ebd4420379c330aea57e258408dd0df9af0992e5de2078dc9f5d5" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/88/8a/94615bc31022f711add374097ad4144d569e95ff3c38d39215d07ac153a0/yarl-1.23.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1932b6b8bba8d0160a9d1078aae5838a66039e8832d41d2992daa9a3a08f7860" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e3/6f/c6554045d59d64052698add01226bc867b52fe4a12373415d7991fdca95d/yarl-1.23.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:411225bae281f114067578891bc75534cfb3d92a3b4dfef7a6ca78ba354e6069" }, + { url = "https://mirrors.aliyun.com/pypi/packages/19/2a/725ecc166d53438bc88f76822ed4b1e3b10756e790bafd7b523fe97c322d/yarl-1.23.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:13a563739ae600a631c36ce096615fe307f131344588b0bc0daec108cdb47b25" }, + { url = "https://mirrors.aliyun.com/pypi/packages/99/30/58260ed98e6ff7f90ba84442c1ddd758c9170d70327394a6227b310cd60f/yarl-1.23.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cbf44c5cb4a7633d078788e1b56387e3d3cf2b8139a3be38040b22d6c3221c8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/76/0a/8b08aac08b50682e65759f7f8dde98ae8168f72487e7357a5d684c581ef9/yarl-1.23.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53ad387048f6f09a8969631e4de3f1bf70c50e93545d64af4f751b2498755072" }, + { url = "https://mirrors.aliyun.com/pypi/packages/52/07/0b7179101fe5f8385ec6c6bb5d0cb9f76bd9fb4a769591ab6fb5cdbfc69a/yarl-1.23.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4a59ba56f340334766f3a4442e0efd0af895fae9e2b204741ef885c446b3a1a8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d3/8a/36d82869ab5ec829ca8574dfcb92b51286fcfb1e9c7a73659616362dc880/yarl-1.23.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:803a3c3ce4acc62eaf01eaca1208dcf0783025ef27572c3336502b9c232005e7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/66/3e/868e5c3364b6cee19ff3e1a122194fa4ce51def02c61023970442162859e/yarl-1.23.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3d2bff8f37f8d0f96c7ec554d16945050d54462d6e95414babaa18bfafc7f51" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cf/26/9c89acf82f08a52cb52d6d39454f8d18af15f9d386a23795389d1d423823/yarl-1.23.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c75eb09e8d55bceb4367e83496ff8ef2bc7ea6960efb38e978e8073ea59ecb67" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6f/54/5b0db00d2cb056922356104468019c0a132e89c8d3ab67d8ede9f4483d2a/yarl-1.23.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877b0738624280e34c55680d6054a307aa94f7d52fa0e3034a9cc6e790871da7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f6/40/10fa93811fd439341fad7e0718a86aca0de9548023bbb403668d6555acab/yarl-1.23.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b5405bb8f0e783a988172993cfc627e4d9d00432d6bbac65a923041edacf997d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bc/d2/8ae2e6cd77d0805f4526e30ec43b6f9a3dfc542d401ac4990d178e4bf0cf/yarl-1.23.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c3a3598a832590c5a3ce56ab5576361b5688c12cb1d39429cf5dba30b510760" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2f/0c/b3ceacf82c3fe21183ce35fa2acf5320af003d52bc1fcf5915077681142e/yarl-1.23.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8419ebd326430d1cbb7efb5292330a2cf39114e82df5cc3d83c9a0d5ebeaf2f2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9d/e0/12900edd28bdab91a69bd2554b85ad7b151f64e8b521fe16f9ad2f56477a/yarl-1.23.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:be61f6fff406ca40e3b1d84716fde398fc08bc63dd96d15f3a14230a0973ed86" }, + { url = "https://mirrors.aliyun.com/pypi/packages/15/61/74bb1182cf79c9bbe4eb6b1f14a57a22d7a0be5e9cedf8e2d5c2086474c3/yarl-1.23.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3ceb13c5c858d01321b5d9bb65e4cf37a92169ea470b70fec6f236b2c9dd7e34" }, + { url = "https://mirrors.aliyun.com/pypi/packages/69/7f/cd5ef733f2550de6241bd8bd8c3febc78158b9d75f197d9c7baa113436af/yarl-1.23.0-cp312-cp312-win32.whl", hash = "sha256:fffc45637bcd6538de8b85f51e3df3223e4ad89bccbfca0481c08c7fc8b7ed7d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f5/be/25216a49daeeb7af2bec0db22d5e7df08ed1d7c9f65d78b14f3b74fd72fc/yarl-1.23.0-cp312-cp312-win_amd64.whl", hash = "sha256:f69f57305656a4852f2a7203efc661d8c042e6cc67f7acd97d8667fb448a426e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d2/35/aeab955d6c425b227d5b7247eafb24f2653fedc32f95373a001af5dfeb9e/yarl-1.23.0-cp312-cp312-win_arm64.whl", hash = "sha256:6e87a6e8735b44816e7db0b2fbc9686932df473c826b0d9743148432e10bb9b9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f" }, ] [[package]] name = "zipp" version = "3.23.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e" }, ] diff --git a/webui/index.html b/webui/index.html index c5a921e2d..a62686330 100644 --- a/webui/index.html +++ b/webui/index.html @@ -4,7 +4,7 @@ - Flocks - AI Native SecOps Platform + Console