deerflow-code/offline-backend-20260512/backend/packages/harness/deerflow/workflows/nodes/integrations.py
2026-09-07 18:24:55 +08:00

277 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""External-effect nodes: ``http``, ``sql_read``, ``code``."""
from __future__ import annotations
import json
import logging
from typing import Any
from deerflow.config.workflow_config import WorkflowConfig
from deerflow.workflows.errors import WorkflowError
from deerflow.workflows.expressions import render_template, render_value
from deerflow.workflows.runtime.code_runner import run_python_snippet
from deerflow.workflows.runtime.context import RunContext
from deerflow.workflows.runtime.sql_runner import execute_read_query
from deerflow.workflows.schemas import NodeResult, WorkflowNode
from deerflow.workflows.security.http_policy import enforce_http_target, enforce_redirect
from deerflow.workflows.security.sql_policy import (
assert_parameters_bound,
assert_read_only,
assert_tables_allowed,
enforce_limit,
)
logger = logging.getLogger(__name__)
# Response headers safe to echo back into the event log.
_SAFE_RESPONSE_HEADERS = ("content-type", "content-length", "date", "etag", "location")
_REDACTED_REQUEST_HEADERS = ("authorization", "cookie", "x-api-key", "proxy-authorization")
class HttpNodeExecutor:
def __init__(self, config: WorkflowConfig) -> None:
self._config = config
async def execute(self, node: WorkflowNode, ctx: RunContext) -> NodeResult:
policy = self._config.http
state = ctx.state()
url = render_template(str(node.config.get("url") or ""), state).strip()
if not url:
raise WorkflowError("WORKFLOW_HTTP_FAILED", "HTTP 节点缺少 URL", node_id=node.id)
method = str(node.config.get("method") or "GET").upper()
headers = {str(k): str(v) for k, v in (render_value(node.config.get("headers") or {}, state) or {}).items()}
query = render_value(node.config.get("query") or {}, state) or {}
body = render_value(node.config.get("body"), state)
credential_ref = node.config.get("credentialRef") or node.config.get("credential_ref")
if credential_ref:
headers.update(await self._credential_headers(str(credential_ref), node, ctx))
try:
target = await enforce_http_target(url, policy)
except WorkflowError as exc:
exc.node_id = exc.node_id or node.id
raise
timeout = min(
int(node.config.get("timeoutSeconds") or node.config.get("timeout_seconds") or policy.timeout_seconds),
policy.timeout_seconds,
)
max_bytes = min(
int(node.config.get("maxResponseBytes") or node.config.get("max_response_bytes") or policy.max_response_bytes),
policy.max_response_bytes,
)
allow_redirects = bool(node.config.get("allowRedirects") or node.config.get("allow_redirects"))
max_hops = policy.max_redirects if allow_redirects else 0
await ctx.emit_event(
"node.progress",
data={
"phase": "http.request",
"method": method,
"host": target["host"],
"headers": [k for k in headers if k.lower() not in _REDACTED_REQUEST_HEADERS],
},
node_id=node.id,
)
import httpx
current_url = url
hops = 0
raw = b""
truncated = False
async with httpx.AsyncClient(timeout=timeout, follow_redirects=False) as client:
while True:
ctx.cancel.raise_if_cancelled(node.id)
try:
request = client.build_request(
method,
current_url,
headers=headers,
params=query or None,
json=body if isinstance(body, (dict, list)) else None,
content=body if isinstance(body, (str, bytes)) else None,
)
response = await client.send(request, stream=True)
except httpx.HTTPError as exc:
raise WorkflowError(
"WORKFLOW_HTTP_FAILED",
"HTTP 请求失败",
retryable=True,
node_id=node.id,
details={"reason": type(exc).__name__},
) from exc
if response.status_code in (301, 302, 303, 307, 308) and hops < max_hops:
location = response.headers.get("location") or ""
await response.aclose()
if not location:
break
current_url = await enforce_redirect(location, current_url, policy)
hops += 1
continue
chunks: list[bytes] = []
size = 0
try:
async for chunk in response.aiter_bytes():
size += len(chunk)
if size > max_bytes:
truncated = True
break
chunks.append(chunk)
finally:
await response.aclose()
raw = b"".join(chunks)
break
text_body = raw.decode("utf-8", errors="replace")
parsed: Any = None
content_type = response.headers.get("content-type", "")
if "json" in content_type.lower():
try:
parsed = json.loads(text_body)
except json.JSONDecodeError:
parsed = None
data = {
"status": response.status_code,
"ok": 200 <= response.status_code < 300,
"headers": {k: v for k, v in response.headers.items() if k.lower() in _SAFE_RESPONSE_HEADERS},
"json": parsed,
"text": None if parsed is not None else text_body,
"truncated": truncated,
"redirects": hops,
}
if not data["ok"]:
raise WorkflowError(
"WORKFLOW_HTTP_FAILED",
f"HTTP 请求返回 {response.status_code}",
retryable=response.status_code >= 500 or response.status_code == 429,
node_id=node.id,
details={"status": response.status_code, "preview": text_body[:500]},
)
return NodeResult(data=data, metadata={"host": target["host"]})
async def _credential_headers(self, ref: str, node: WorkflowNode, ctx: RunContext) -> dict[str, str]:
if ctx.deps.resolve_credential is None:
raise WorkflowError(
"WORKFLOW_RESOURCE_MISSING",
"运行环境未配置凭据解析能力",
node_id=node.id,
)
credential = await ctx.deps.resolve_credential(ref)
if not credential:
raise WorkflowError(
"WORKFLOW_RESOURCE_MISSING",
"引用的凭据不存在或无权访问",
node_id=node.id,
details={"credentialRef": ref},
)
headers = credential.get("headers")
return {str(k): str(v) for k, v in headers.items()} if isinstance(headers, dict) else {}
class SqlReadNodeExecutor:
def __init__(self, config: WorkflowConfig) -> None:
self._config = config
async def execute(self, node: WorkflowNode, ctx: RunContext) -> NodeResult:
policy = self._config.sql
if not policy.enabled:
raise WorkflowError("WORKFLOW_SQL_POLICY_DENIED", "SQL 节点已被系统禁用", node_id=node.id)
data_source_id = str(node.config.get("dataSourceId") or node.config.get("data_source_id") or "")
if not data_source_id:
raise WorkflowError("WORKFLOW_SQL_FAILED", "SQL 节点未选择数据源", node_id=node.id)
if ctx.deps.resolve_data_source is None:
raise WorkflowError("WORKFLOW_RESOURCE_MISSING", "运行环境未配置数据源解析能力", node_id=node.id)
source = await ctx.deps.resolve_data_source(data_source_id)
if not source or not source.get("dsn"):
raise WorkflowError(
"WORKFLOW_RESOURCE_MISSING",
"数据源不存在或无权访问",
node_id=node.id,
details={"dataSourceId": data_source_id},
)
statement = assert_read_only(str(node.config.get("statement") or ""))
parameters = render_value(node.config.get("parameters") or {}, ctx.state()) or {}
if not isinstance(parameters, dict):
raise WorkflowError("WORKFLOW_SQL_FAILED", "SQL 参数必须是对象", node_id=node.id)
assert_parameters_bound(statement, parameters)
assert_tables_allowed(statement, list(source.get("allowed_tables") or []))
max_rows = min(
int(node.config.get("maxRows") or node.config.get("max_rows") or policy.max_rows),
policy.max_rows,
int(source.get("max_rows") or policy.max_rows),
)
statement = enforce_limit(statement, max_rows)
await ctx.emit_event(
"node.progress",
data={"phase": "sql.query", "dataSourceId": data_source_id, "maxRows": max_rows},
node_id=node.id,
)
try:
result = await execute_read_query(
dsn=str(source["dsn"]),
statement=statement,
parameters={str(k): v for k, v in parameters.items()},
max_rows=max_rows,
timeout_seconds=policy.statement_timeout_seconds,
max_cell_chars=policy.max_cell_chars,
)
except WorkflowError as exc:
exc.node_id = exc.node_id or node.id
raise
return NodeResult(data=result)
class CodeNodeExecutor:
def __init__(self, config: WorkflowConfig) -> None:
self._config = config
async def execute(self, node: WorkflowNode, ctx: RunContext) -> NodeResult:
policy = self._config.code
if not policy.enabled:
raise WorkflowError(
"WORKFLOW_CODE_SANDBOX_FAILED",
"代码节点未启用(需管理员在 config.yaml 中开启 workflows.code.enabled)",
node_id=node.id,
)
source = str(node.config.get("source") or "")
if not source.strip():
raise WorkflowError("WORKFLOW_CODE_SANDBOX_FAILED", "代码节点没有源代码", node_id=node.id)
if len(source) > policy.max_source_chars:
raise WorkflowError(
"WORKFLOW_LIMIT_EXCEEDED",
f"代码长度超过上限 {policy.max_source_chars}",
node_id=node.id,
)
inputs = render_value(node.config.get("inputs") or {}, ctx.state()) or {}
timeout = min(
int(node.config.get("timeoutSeconds") or node.config.get("timeout_seconds") or policy.timeout_seconds),
policy.timeout_seconds,
)
allow_network = bool((node.config.get("allowNetwork") or node.config.get("allow_network")) and policy.allow_network)
try:
outcome = await run_python_snippet(
source=source,
inputs=inputs if isinstance(inputs, dict) else {"value": inputs},
timeout_seconds=timeout,
max_output_chars=policy.max_output_chars,
memory_limit_mb=policy.memory_limit_mb,
allow_network=allow_network,
)
except WorkflowError as exc:
exc.node_id = exc.node_id or node.id
raise
return NodeResult(
data=outcome["result"],
metadata={"stdout": outcome["stdout"][-4000:], "stderr": outcome["stderr"][-2000:]},
)
__all__ = ["CodeNodeExecutor", "HttpNodeExecutor", "SqlReadNodeExecutor"]