This commit is contained in:
Typer_Body
2026-06-05 21:20:46 +08:00
parent cb0bb44db8
commit cb0cc4d06a
9 changed files with 100 additions and 204 deletions
@@ -62,16 +62,24 @@ class EmbedRouterGroup(group.RouterGroup):
"""Resolve *bot_uuid* to ``(runtime_bot, pipeline_uuid)``. """Resolve *bot_uuid* to ``(runtime_bot, pipeline_uuid)``.
Returns ``(None, None)`` when the bot does not exist, is not a Returns ``(None, None)`` when the bot does not exist, is not a
``web_page_bot``, is disabled, or has no pipeline bound. ``web_page_bot``, is disabled, or has no pipeline/workflow bound.
""" """
for bot in self.ap.platform_mgr.bots: for bot in self.ap.platform_mgr.bots:
if ( if (
bot.bot_entity.uuid == bot_uuid bot.bot_entity.uuid == bot_uuid
and bot.bot_entity.adapter == 'web_page_bot' and bot.bot_entity.adapter == 'web_page_bot'
and bot.bot_entity.enable and bot.bot_entity.enable
and bot.bot_entity.use_pipeline_uuid
): ):
return bot, bot.bot_entity.use_pipeline_uuid # Check for workflow binding first
binding_type = getattr(bot.bot_entity, 'binding_type', 'pipeline') or 'pipeline'
binding_uuid = getattr(bot.bot_entity, 'binding_uuid', None)
if binding_type == 'workflow' and binding_uuid:
# For workflow binding, return workflow UUID
return bot, binding_uuid
elif bot.bot_entity.use_pipeline_uuid:
# For pipeline binding, return pipeline UUID
return bot, bot.bot_entity.use_pipeline_uuid
return None, None return None, None
def _get_bot_config(self, bot_uuid: str) -> dict: def _get_bot_config(self, bot_uuid: str) -> dict:
@@ -61,6 +61,7 @@ class WorkflowsRouterGroup(group.RouterGroup):
elif quart.request.method == 'DELETE': elif quart.request.method == 'DELETE':
await self.ap.workflow_service.delete_workflow(workflow_uuid) await self.ap.workflow_service.delete_workflow(workflow_uuid)
return self.success() return self.success()
return self.http_status(405, -1, 'method not allowed')
# Publish workflow (enable) # Publish workflow (enable)
@self.route('/<workflow_uuid>/publish', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) @self.route('/<workflow_uuid>/publish', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
@@ -193,6 +194,7 @@ class WorkflowsRouterGroup(group.RouterGroup):
return self.success() return self.success()
except ValueError as e: except ValueError as e:
return self.http_status(404, -1, str(e)) return self.http_status(404, -1, str(e))
return self.http_status(405, -1, 'method not allowed')
# Debug API - Start debug execution # Debug API - Start debug execution
@self.route('/<workflow_uuid>/debug/start', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) @self.route('/<workflow_uuid>/debug/start', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
+1 -1
View File
@@ -6,7 +6,7 @@ import asyncio
import logging import logging
import uuid import uuid
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Optional, TYPE_CHECKING from typing import Optional
import sqlalchemy import sqlalchemy
+28 -131
View File
@@ -10,10 +10,9 @@ Debug execution support has been moved to the ``debug`` module.
from __future__ import annotations from __future__ import annotations
import ast
import asyncio import asyncio
import logging import logging
import operator import re
import uuid import uuid
from datetime import datetime from datetime import datetime
from typing import Any, Optional, TYPE_CHECKING from typing import Any, Optional, TYPE_CHECKING
@@ -32,6 +31,7 @@ from .entities import (
) )
from ..entity.persistence import workflow as persistence_workflow from ..entity.persistence import workflow as persistence_workflow
from .registry import NodeTypeRegistry from .registry import NodeTypeRegistry
from .safe_eval import safe_eval_with_vars
if TYPE_CHECKING: if TYPE_CHECKING:
from ..core import app from ..core import app
@@ -39,108 +39,6 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# ─── Safe expression evaluator (replaces eval()) ─────────────────────
# Uses Python's ast module to whitelist only comparison / boolean / arithmetic
# operations. No function calls, attribute access, or subscript injection.
_SAFE_OPS = {
ast.Add: operator.add,
ast.Sub: operator.sub,
ast.Mult: operator.mul,
ast.Div: operator.truediv,
ast.FloorDiv: operator.floordiv,
ast.Mod: operator.mod,
ast.Pow: operator.pow,
ast.USub: operator.neg,
ast.UAdd: operator.pos,
ast.Not: operator.not_,
ast.Eq: operator.eq,
ast.NotEq: operator.ne,
ast.Lt: operator.lt,
ast.LtE: operator.le,
ast.Gt: operator.gt,
ast.GtE: operator.ge,
ast.Is: operator.is_,
ast.IsNot: operator.is_not,
ast.In: lambda a, b: a in b,
ast.NotIn: lambda a, b: a not in b,
}
def _safe_eval(expr: str) -> Any:
"""Evaluate a simple expression safely via AST whitelist.
Supports: literals, comparisons (==, !=, <, >, <=, >=, in, not in, is, is not),
boolean logic (and, or, not), arithmetic (+, -, *, /, //, %, **), and
string operations (contains via ``in``).
Raises ``ValueError`` on any disallowed construct (function calls,
attribute access, imports, etc.).
"""
tree = ast.parse(expr.strip(), mode='eval')
return _eval_node(tree.body)
def _eval_node(node: ast.AST) -> Any:
# Literals: numbers, strings, True/False/None
if isinstance(node, ast.Constant):
return node.value
# Unary operators: -x, +x, not x
if isinstance(node, ast.UnaryOp):
op_fn = _SAFE_OPS.get(type(node.op))
if op_fn is None:
raise ValueError(f'Unsupported unary op: {type(node.op).__name__}')
return op_fn(_eval_node(node.operand))
# Binary operators: x + y, x * y, etc.
if isinstance(node, ast.BinOp):
op_fn = _SAFE_OPS.get(type(node.op))
if op_fn is None:
raise ValueError(f'Unsupported binary op: {type(node.op).__name__}')
return op_fn(_eval_node(node.left), _eval_node(node.right))
# Comparisons: x == y, x > y, x in y, etc. (chained)
if isinstance(node, ast.Compare):
left = _eval_node(node.left)
for op, comparator in zip(node.ops, node.comparators):
op_fn = _SAFE_OPS.get(type(op))
if op_fn is None:
raise ValueError(f'Unsupported comparison: {type(op).__name__}')
right = _eval_node(comparator)
if not op_fn(left, right):
return False
left = right
return True
# Boolean operators: x and y, x or y
if isinstance(node, ast.BoolOp):
if isinstance(node.op, ast.And):
return all(_eval_node(v) for v in node.values)
if isinstance(node.op, ast.Or):
return any(_eval_node(v) for v in node.values)
# Ternary: x if cond else y
if isinstance(node, ast.IfExp):
return _eval_node(node.body) if _eval_node(node.test) else _eval_node(node.orelse)
# Tuples / Lists (used in "x in [1,2,3]")
if isinstance(node, (ast.Tuple, ast.List)):
return [_eval_node(e) for e in node.elts]
# Name lookup only allow None, True, False
if isinstance(node, ast.Name):
if node.id == 'None':
return None
if node.id == 'True':
return True
if node.id == 'False':
return False
raise ValueError(f'Unsupported variable reference: {node.id}')
raise ValueError(f'Unsupported expression node: {type(node).__name__}')
class WorkflowExecutor: class WorkflowExecutor:
""" """
Workflow execution engine. Workflow execution engine.
@@ -169,10 +67,6 @@ class WorkflowExecutor:
context.status = ExecutionStatus.RUNNING context.status = ExecutionStatus.RUNNING
context.start_time = datetime.now() context.start_time = datetime.now()
# Note: Frontend panel logging has been removed.
# A new solution will be implemented separately.
monitoring_message_id = ''
try: try:
# Build execution graph # Build execution graph
node_map = {node.id: node for node in workflow.nodes} node_map = {node.id: node for node in workflow.nodes}
@@ -575,42 +469,40 @@ class WorkflowExecutor:
return None return None
async def _evaluate_condition(self, condition: str, context: ExecutionContext) -> bool: async def _evaluate_condition(self, condition: str, context: ExecutionContext) -> bool:
"""Evaluate a condition expression safely using AST whitelist""" """Evaluate a condition expression safely.
try:
# Resolve variable references in condition
if '{{' in condition:
import re
Any ``{{ ... }}`` references are resolved against the execution context
and bound as **variables** that are passed to :func:`safe_eval_with_vars`.
Values are never string-concatenated into the expression, which avoids
broken parsing (e.g. values containing quotes) and any injection risk
from non-literal value types (lists, dicts, etc.).
"""
variables: dict[str, Any] = {}
try:
# Resolve variable references in condition into bound variables.
if '{{' in condition:
pattern = r'\{\{([^}]+)\}\}' pattern = r'\{\{([^}]+)\}\}'
# First pass: replace all variable references with placeholders placeholders: dict[str, str] = {}
placeholders = {}
placeholder_idx = 0 placeholder_idx = 0
def replace_with_placeholder(match): def replace_with_placeholder(match: re.Match[str]) -> str:
nonlocal placeholder_idx nonlocal placeholder_idx
var_expr = match.group(1) var_expr = match.group(1)
placeholder = f'__PH{placeholder_idx}__' placeholder = f'__ph{placeholder_idx}__'
placeholders[placeholder] = var_expr placeholders[placeholder] = var_expr
placeholder_idx += 1 placeholder_idx += 1
return placeholder return placeholder
condition_with_placeholders = re.sub(pattern, replace_with_placeholder, condition) condition = re.sub(pattern, replace_with_placeholder, condition)
# Second pass: resolve each placeholder asynchronously # Resolve each placeholder and bind it as a variable, so the
# actual value (of any type) is passed through unchanged.
for placeholder, var_expr in placeholders.items(): for placeholder, var_expr in placeholders.items():
value = await self._resolve_expression(var_expr, context) variables[placeholder] = await self._resolve_expression(var_expr, context)
if isinstance(value, str):
condition_with_placeholders = condition_with_placeholders.replace(placeholder, f'"{value}"')
elif value is None:
condition_with_placeholders = condition_with_placeholders.replace(placeholder, 'None')
else:
condition_with_placeholders = condition_with_placeholders.replace(placeholder, str(value))
condition = condition_with_placeholders # Safe expression evaluation with bound variables (AST whitelist).
result = safe_eval_with_vars(condition, variables)
# Safe expression evaluation using AST whitelist
result = _safe_eval(condition)
return bool(result) return bool(result)
except Exception as e: except Exception as e:
@@ -753,8 +645,13 @@ class ParallelExecutor:
results = await asyncio.gather(*tasks, return_exceptions=True) results = await asyncio.gather(*tasks, return_exceptions=True)
processed_results = [] processed_results = []
for result in results: for index, result in enumerate(results):
if isinstance(result, Exception): if isinstance(result, Exception):
logger.error(
f'Parallel branch {index} failed: {result}',
exc_info=result,
extra={'branch_index': index, 'execution_id': context.execution_id},
)
processed_results.append({'error': str(result)}) processed_results.append({'error': str(result)})
else: else:
processed_results.append(result) processed_results.append(result)
@@ -1,41 +0,0 @@
# """Plugin Call Node - invoke a plugin
# Node metadata is loaded from: ../../templates/metadata/nodes/plugin_call.yaml
# """
# from __future__ import annotations
# from typing import Any
# from langbot_plugin.api.entities.builtin.workflow.entities import ExecutionContext
# from ..node import WorkflowNode, workflow_node
# @workflow_node('plugin_call')
# class PluginCallNode(WorkflowNode):
# """Plugin call node - invoke a plugin"""
# type_name = "plugin_call"
# category = "action"
# icon = "🔌"
# name = "plugin_call"
# description = "plugin_call"
# inputs: ClassVar[list[NodePort]] = []
# outputs: ClassVar[list[NodePort]] = []
# config_schema: ClassVar[list[NodeConfig]] = []
# async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
# plugin_name = self.get_config("plugin_name", "")
# method_name = self.get_config("method_name", "")
# arguments = inputs.get("arguments", {})
# return {
# "result": None,
# "success": False,
# "error": f"Plugin call '{plugin_name}/{method_name}' not implemented yet",
# "_debug": {
# "plugin_name": plugin_name,
# "method_name": method_name,
# "arguments": arguments,
# },
# }
+50 -12
View File
@@ -1,4 +1,9 @@
"""Tests for the safe expression evaluator that replaced eval().""" """Tests for the safe expression evaluator that replaced eval().
The workflow engine now uses :func:`safe_eval_with_vars` everywhere (the old
``executor._safe_eval`` helper was removed to eliminate duplication). These
tests exercise that single, shared evaluator.
"""
import sys import sys
import os import os
@@ -7,7 +12,12 @@ import pytest
# Add project root to path # Add project root to path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..', 'src')) sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..', 'src'))
from langbot.pkg.workflow.executor import _safe_eval from langbot.pkg.workflow.safe_eval import safe_eval_with_vars
def _safe_eval(expr):
"""Backwards-compatible shim: evaluate an expression with no bound vars."""
return safe_eval_with_vars(expr)
class TestSafeEvalLiterals: class TestSafeEvalLiterals:
@@ -147,8 +157,39 @@ class TestSafeEvalArithmetic:
assert _safe_eval("1 + 2 == 3") is True assert _safe_eval("1 + 2 == 3") is True
class TestSafeEvalVariables:
"""Test variable binding (the feature that justified the unification)."""
def test_bound_variable(self):
assert safe_eval_with_vars("x + 1", {"x": 41}) == 42
def test_bound_string_compare(self):
assert safe_eval_with_vars('input == "hello"', {"input": "hello"}) is True
def test_bound_dict_attribute(self):
assert safe_eval_with_vars("data.name == 'a'", {"data": {"name": "a"}}) is True
def test_bound_subscript(self):
assert safe_eval_with_vars('data["k"] > 3', {"data": {"k": 5}}) is True
def test_value_with_quotes_does_not_break(self):
# The whole point of binding variables instead of string-concatenation:
# a value containing a double quote must not corrupt parsing.
assert safe_eval_with_vars('v == val', {"v": 'a"b', "val": 'a"b'}) is True
def test_unbound_variable_raises(self):
with pytest.raises(ValueError):
safe_eval_with_vars("x + 1", {})
class TestSafeEvalSecurity: class TestSafeEvalSecurity:
"""Ensure dangerous constructs are rejected.""" """Ensure dangerous constructs are rejected.
The shared evaluator rejects **all function calls**, which blocks every
known sandbox-escape vector (``__import__``, ``exec``, ``eval``,
``__subclasses__()`` chains, etc.). Bare attribute/subscript *reads* are
permitted but are inert without a call, so they cannot escalate.
"""
def test_import_blocked(self): def test_import_blocked(self):
with pytest.raises((ValueError, SyntaxError)): with pytest.raises((ValueError, SyntaxError)):
@@ -162,14 +203,6 @@ class TestSafeEvalSecurity:
with pytest.raises(ValueError): with pytest.raises(ValueError):
_safe_eval('open("/etc/passwd")') _safe_eval('open("/etc/passwd")')
def test_attribute_access_blocked(self):
with pytest.raises(ValueError):
_safe_eval('"hello".__class__')
def test_subscript_blocked(self):
with pytest.raises(ValueError):
_safe_eval('[1,2,3][0]')
def test_class_subclasses_blocked(self): def test_class_subclasses_blocked(self):
with pytest.raises((ValueError, SyntaxError)): with pytest.raises((ValueError, SyntaxError)):
_safe_eval('().__class__.__subclasses__()') _safe_eval('().__class__.__subclasses__()')
@@ -186,6 +219,11 @@ class TestSafeEvalSecurity:
with pytest.raises((ValueError, SyntaxError)): with pytest.raises((ValueError, SyntaxError)):
_safe_eval('lambda: 1') _safe_eval('lambda: 1')
def test_variable_reference_blocked(self): def test_any_call_blocked(self):
# Even a call on an otherwise-safe attribute read must be rejected.
with pytest.raises(ValueError):
_safe_eval('"hello".upper()')
def test_unknown_variable_reference_blocked(self):
with pytest.raises(ValueError): with pytest.raises(ValueError):
_safe_eval('x + 1') _safe_eval('x + 1')
+4
View File
@@ -177,3 +177,7 @@
transform: scale(0.95) rotate(-4deg); transform: scale(0.95) rotate(-4deg);
} }
} }
.react-flow__attribution {
opacity: 0.2 !important;
}
@@ -358,7 +358,10 @@ export default function WorkflowDetailContent({ id }: { id: string }) {
<Upload className="size-4 mr-1" /> <Upload className="size-4 mr-1" />
{t('workflows.import')} {t('workflows.import')}
</Button> </Button>
<Button onClick={handleSave} disabled={isSaving || !basicInfo.name.trim()}> <Button
onClick={handleSave}
disabled={isSaving || !basicInfo.name.trim()}
>
{isSaving ? t('common.saving') : t('common.create')} {isSaving ? t('common.saving') : t('common.create')}
</Button> </Button>
</div> </div>
@@ -83,7 +83,6 @@ export default function WorkflowDebugger({
const [activeTab, setActiveTab] = useState<string>('context'); const [activeTab, setActiveTab] = useState<string>('context');
const [autoScroll, setAutoScroll] = useState(true); const [autoScroll, setAutoScroll] = useState(true);
const [newVariable, setNewVariable] = useState({ key: '', value: '' }); const [newVariable, setNewVariable] = useState({ key: '', value: '' });
const [, setExpandedNodes] = useState<Set<string>>(new Set());
const pollCancelledRef = useRef(false); const pollCancelledRef = useRef(false);
const { const {
@@ -107,7 +106,6 @@ export default function WorkflowDebugger({
clearDebugLogs, clearDebugLogs,
setDebugContext, setDebugContext,
resetDebugContext, resetDebugContext,
addWatchedVariable,
removeWatchedVariable, removeWatchedVariable,
resetDebugState, resetDebugState,
} = useWorkflowStore(); } = useWorkflowStore();
@@ -311,19 +309,6 @@ export default function WorkflowDebugger({
} }
}, [workflowId, debugExecutionId, t, resetDebugState]); }, [workflowId, debugExecutionId, t, resetDebugState]);
// Toggle node expansion
const toggleNodeExpanded = (nodeId: string) => {
setExpandedNodes((prev) => {
const newSet = new Set(prev);
if (newSet.has(nodeId)) {
newSet.delete(nodeId);
} else {
newSet.add(nodeId);
}
return newSet;
});
};
// Add custom variable // Add custom variable
const handleAddVariable = () => { const handleAddVariable = () => {
if (newVariable.key.trim()) { if (newVariable.key.trim()) {