From 3b4b474ce869e4416db314df8669671a1c931a6f Mon Sep 17 00:00:00 2001 From: Eternity <1533512157@qq.com> Date: Mon, 26 Jan 2026 16:32:58 +0800 Subject: [PATCH 1/3] fix(sandbox): prevent imports from being blocked when network is disabled --- sandbox/lib/seccomp_python/src/syscalls.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sandbox/lib/seccomp_python/src/syscalls.rs b/sandbox/lib/seccomp_python/src/syscalls.rs index 07070d22..961fffac 100644 --- a/sandbox/lib/seccomp_python/src/syscalls.rs +++ b/sandbox/lib/seccomp_python/src/syscalls.rs @@ -10,6 +10,7 @@ pub static ALLOW_SYSCALLS: &[i32] = &[ libc::SYS_ioctl as i32, libc::SYS_lseek as i32, libc::SYS_getdents64 as i32, + libc::SYS_fstat as i32, // thread libc::SYS_futex as i32, @@ -77,7 +78,6 @@ pub static ALLOW_NETWORK_SYSCALLS: &[i32] = &[ libc::SYS_sendmsg as i32, libc::SYS_sendmmsg as i32, libc::SYS_getsockopt as i32, - libc::SYS_fstat as i32, libc::SYS_fcntl as i32, libc::SYS_fstatfs as i32, libc::SYS_poll as i32, From f1f887faaebc3caa74feb05730a76faae6bb30f3 Mon Sep 17 00:00:00 2001 From: Eternity <1533512157@qq.com> Date: Mon, 26 Jan 2026 17:29:44 +0800 Subject: [PATCH 2/3] feat(workflow): Add a new node for executing code --- api/app/core/workflow/nodes/code/__init__.py | 3 + api/app/core/workflow/nodes/code/config.py | 50 +++++++ api/app/core/workflow/nodes/code/node.py | 122 ++++++++++++++++++ api/app/core/workflow/nodes/configs.py | 14 +- api/app/core/workflow/nodes/node_factory.py | 5 +- sandbox/app/core/executor.py | 1 - .../app/core/runners/python/python_runner.py | 5 +- sandbox/app/services/python_service.py | 4 +- 8 files changed, 193 insertions(+), 11 deletions(-) create mode 100644 api/app/core/workflow/nodes/code/config.py create mode 100644 api/app/core/workflow/nodes/code/node.py diff --git a/api/app/core/workflow/nodes/code/__init__.py b/api/app/core/workflow/nodes/code/__init__.py index e69de29b..e42af93d 100644 --- a/api/app/core/workflow/nodes/code/__init__.py +++ b/api/app/core/workflow/nodes/code/__init__.py @@ -0,0 +1,3 @@ +from app.core.workflow.nodes.code.node import CodeNode + +__all__ = ["CodeNode"] \ No newline at end of file diff --git a/api/app/core/workflow/nodes/code/config.py b/api/app/core/workflow/nodes/code/config.py new file mode 100644 index 00000000..35b757e9 --- /dev/null +++ b/api/app/core/workflow/nodes/code/config.py @@ -0,0 +1,50 @@ +from typing import Literal +from pydantic import Field, BaseModel + +from app.core.workflow.nodes.base_config import BaseNodeConfig, VariableType + + +class InputVariable(BaseModel): + name: str = Field( + ..., + description="variable name" + ) + + variable: str = Field( + ..., + description="variable selector" + ) + + +class OutputVariable(BaseModel): + name: str = Field( + ..., + description="variable name" + ) + + type: VariableType = Field( + ..., + description="variable selector" + ) + + +class CodeNodeConfig(BaseNodeConfig): + input_variables: list[InputVariable] = Field( + default_factory=list, + description="input variables" + ) + + output_variables: list[OutputVariable] = Field( + default_factory=list, + description="output variables" + ) + + code_content: str = Field( + default="", + description="code content" + ) + + language: Literal['python3', 'nodejs'] = Field( + ..., + description="language" + ) diff --git a/api/app/core/workflow/nodes/code/node.py b/api/app/core/workflow/nodes/code/node.py new file mode 100644 index 00000000..3e15089b --- /dev/null +++ b/api/app/core/workflow/nodes/code/node.py @@ -0,0 +1,122 @@ +import base64 +import json +import logging +import re +from string import Template +from textwrap import dedent +from typing import Any + +import httpx +from sympy.physics.vector import vlatex + +from app.core.workflow.nodes import BaseNode, WorkflowState +from app.core.workflow.nodes.base_config import VariableType +from app.core.workflow.nodes.code.config import CodeNodeConfig + +logger = logging.getLogger(__name__) + +SCRIPT_TEMPLATE = Template(dedent(""" +$code + +import json +from base64 import b64decode + +# decode and prepare input dict +inputs_obj = json.loads(b64decode('$inputs_variable').decode('utf-8')) + +# execute main function +output_obj = main(**inputs_obj) + +# convert output to json and print +output_json = json.dumps(output_obj, indent=4) +result = "<>" + output_json + "<>" +print(result) +""")) + + +class CodeNode(BaseNode): + def __init__(self, node_config: dict[str, Any], workflow_config: dict[str, Any]): + super().__init__(node_config, workflow_config) + self.typed_config: CodeNodeConfig | None = None + + def extract_result(self, content: str): + match = re.search(r'<>(.*?)<>', content, re.DOTALL) + if match: + extracted = match.group(1) + exec_result = json.loads(extracted) + result = {} + for output in self.typed_config.output_variables: + value = exec_result.get(output.name) + if not value: + raise RuntimeError(f"Return value {output.name} does not exist") + match output.type: + case VariableType.STRING: + if not isinstance(value, str): + raise RuntimeError(f"Return value {output.name} should be a string") + case VariableType.BOOLEAN: + if not isinstance(value, bool): + raise RuntimeError(f"Return value {output.name} should be a boolean") + case VariableType.NUMBER: + if not isinstance(value, (int, float)): + raise RuntimeError(f"Return value {output.name} should be a number") + case VariableType.OBJECT: + if not isinstance(value, dict): + raise RuntimeError(f"Return value {output.name} should be a dictionary") + case VariableType.ARRAY_STRING: + if not isinstance(value, list) or not all(isinstance(v, str) for v in value): + raise RuntimeError(f"Return value {output.name} should be a list of strings") + case VariableType.ARRAY_NUMBER: + if not isinstance(value, list) or not all(isinstance(v, (int, float)) for v in value): + raise RuntimeError(f"Return value {output.name} should be a list of numbers") + case VariableType.ARRAY_OBJECT: + if not isinstance(value, list) or not all(isinstance(v, dict) for v in value): + raise RuntimeError(f"Return value {output.name} should be a list of dictionaries") + case VariableType.ARRAY_BOOLEAN: + if not isinstance(value, list) or not all(isinstance(v, bool) for v in value): + raise RuntimeError(f"Return value {output.name} should be a list of booleans") + result[output.name] = value + return result + else: + raise RuntimeError("The output of main must be a dictionary") + + async def execute(self, state: WorkflowState) -> Any: + self.typed_config = CodeNodeConfig(**self.config) + input_variable_dict = {} + for input_variable in self.typed_config.input_variables: + input_variable_dict[input_variable.name] = self.get_variable(input_variable.variable, state) + code = base64.b64decode( + self.typed_config.code + ).decode("utf-8") + + input_variable_dict = base64.b64encode( + json.dumps(input_variable_dict).encode("utf-8") + ).decode("utf-8") + + final_script = SCRIPT_TEMPLATE.substitute( + code=code, + inputs_variable=input_variable_dict, + ) + + async with httpx.AsyncClient() as client: + response = await client.post( + "http://sandbox:8194/v1/sandbox/run", + headers={ + "x-api-key": 'redbear-sandbox' + }, + json={ + "language": "python3", + "code": base64.b64encode(final_script.encode("utf-8")).decode("utf-8"), + "options": { + "enable_network": True + } + } + ) + resp = response.json() + + match resp['code']: + case 31: + raise RuntimeError("Operation not permitted") + case 0: + return self.extract_result(resp["data"]["stdout"]) + case _: + raise Exception(resp["message"]) diff --git a/api/app/core/workflow/nodes/configs.py b/api/app/core/workflow/nodes/configs.py index 4d31efaa..d73754f6 100644 --- a/api/app/core/workflow/nodes/configs.py +++ b/api/app/core/workflow/nodes/configs.py @@ -10,21 +10,22 @@ from app.core.workflow.nodes.base_config import ( VariableDefinition, VariableType, ) +from app.core.workflow.nodes.code.config import CodeNodeConfig +from app.core.workflow.nodes.cycle_graph.config import LoopNodeConfig, IterationNodeConfig from app.core.workflow.nodes.end.config import EndNodeConfig from app.core.workflow.nodes.http_request.config import HttpRequestNodeConfig from app.core.workflow.nodes.if_else.config import IfElseNodeConfig from app.core.workflow.nodes.jinja_render.config import JinjaRenderNodeConfig from app.core.workflow.nodes.knowledge.config import KnowledgeRetrievalNodeConfig from app.core.workflow.nodes.llm.config import LLMNodeConfig, MessageConfig -from app.core.workflow.nodes.start.config import StartNodeConfig -from app.core.workflow.nodes.transform.config import TransformNodeConfig -from app.core.workflow.nodes.variable_aggregator.config import VariableAggregatorNodeConfig +from app.core.workflow.nodes.memory.config import MemoryReadNodeConfig, MemoryWriteNodeConfig from app.core.workflow.nodes.parameter_extractor.config import ParameterExtractorNodeConfig from app.core.workflow.nodes.question_classifier.config import QuestionClassifierNodeConfig +from app.core.workflow.nodes.start.config import StartNodeConfig from app.core.workflow.nodes.tool.config import ToolNodeConfig -from app.core.workflow.nodes.memory.config import MemoryReadNodeConfig, MemoryWriteNodeConfig +from app.core.workflow.nodes.transform.config import TransformNodeConfig +from app.core.workflow.nodes.variable_aggregator.config import VariableAggregatorNodeConfig -from app.core.workflow.nodes.cycle_graph.config import LoopNodeConfig, IterationNodeConfig __all__ = [ # 基础类 "BaseNodeConfig", @@ -49,5 +50,6 @@ __all__ = [ "QuestionClassifierNodeConfig", "ToolNodeConfig", "MemoryReadNodeConfig", - "MemoryWriteNodeConfig" + "MemoryWriteNodeConfig", + "CodeNodeConfig" ] diff --git a/api/app/core/workflow/nodes/node_factory.py b/api/app/core/workflow/nodes/node_factory.py index 9fca8d7a..fb2fe00f 100644 --- a/api/app/core/workflow/nodes/node_factory.py +++ b/api/app/core/workflow/nodes/node_factory.py @@ -10,6 +10,7 @@ from typing import Any, Union from app.core.workflow.nodes.agent import AgentNode from app.core.workflow.nodes.assigner import AssignerNode from app.core.workflow.nodes.base_node import BaseNode +from app.core.workflow.nodes.code import CodeNode from app.core.workflow.nodes.cycle_graph.node import CycleGraphNode from app.core.workflow.nodes.end import EndNode from app.core.workflow.nodes.enums import NodeType @@ -49,7 +50,8 @@ WorkflowNode = Union[ QuestionClassifierNode, ToolNode, MemoryReadNode, - MemoryWriteNode + MemoryWriteNode, + CodeNode ] @@ -81,6 +83,7 @@ class NodeFactory: NodeType.TOOL: ToolNode, NodeType.MEMORY_READ: MemoryReadNode, NodeType.MEMORY_WRITE: MemoryWriteNode, + NodeType.CODE: CodeNode, } @classmethod diff --git a/sandbox/app/core/executor.py b/sandbox/app/core/executor.py index 6edc48c0..e87b510c 100644 --- a/sandbox/app/core/executor.py +++ b/sandbox/app/core/executor.py @@ -15,7 +15,6 @@ class ExecutionResult: self.stdout = stdout self.stderr = stderr self.exit_code = exit_code - self.error = error class CodeExecutor(ABC): diff --git a/sandbox/app/core/runners/python/python_runner.py b/sandbox/app/core/runners/python/python_runner.py index faac5f0c..30792b91 100644 --- a/sandbox/app/core/runners/python/python_runner.py +++ b/sandbox/app/core/runners/python/python_runner.py @@ -9,12 +9,15 @@ from app.config import SANDBOX_USER_ID, SANDBOX_GROUP_ID, get_config from app.core.encryption import generate_key, encrypt_code from app.core.executor import CodeExecutor, ExecutionResult from app.core.runners.python.settings import check_lib_avaiable, release_lib_binary, LIB_PATH +from app.logger import get_logger from app.models import RunnerOptions # Python sandbox prescript template with open("app/core/runners/python/prescript.py") as f: PYTHON_PRESCRIPT = f.read() +logger = get_logger() + class PythonRunner(CodeExecutor): """Python code runner with security isolation""" @@ -106,6 +109,7 @@ class PythonRunner(CodeExecutor): env["ALLOWED_SYSCALLS"] = ",".join(map(str, config.allowed_syscalls)) # Execute with Python interpreter + logger.info(encoded_key) process = await asyncio.create_subprocess_exec( config.python_path, @@ -143,7 +147,6 @@ class PythonRunner(CodeExecutor): stdout="", stderr="Execution timeout", exit_code=-1, - error="Execution timeout" ) finally: diff --git a/sandbox/app/services/python_service.py b/sandbox/app/services/python_service.py index 71cfda0d..5700841d 100644 --- a/sandbox/app/services/python_service.py +++ b/sandbox/app/services/python_service.py @@ -37,8 +37,8 @@ async def run_python_code(code: str, preload: str, options: RunnerOptions): if result.exit_code == -signal.SIGSYS: return error_response(31, "sandbox security policy violation") - if result.error: - return error_response(-500, result.error) + if result.stderr: + return error_response(500, result.stderr) return success_response(RunCodeResponse( stdout=result.stdout, From 1f615a06add14d193f7d2840bddefb53712ced32 Mon Sep 17 00:00:00 2001 From: Eternity <1533512157@qq.com> Date: Mon, 26 Jan 2026 18:50:22 +0800 Subject: [PATCH 3/3] fix(sandbox): treat non-zero exit codes as errors instead of relying only on stderr --- api/app/core/workflow/nodes/code/config.py | 2 +- api/app/core/workflow/nodes/code/node.py | 4 ++-- sandbox/app/services/python_service.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/api/app/core/workflow/nodes/code/config.py b/api/app/core/workflow/nodes/code/config.py index 35b757e9..8af13f12 100644 --- a/api/app/core/workflow/nodes/code/config.py +++ b/api/app/core/workflow/nodes/code/config.py @@ -39,7 +39,7 @@ class CodeNodeConfig(BaseNodeConfig): description="output variables" ) - code_content: str = Field( + code: str = Field( default="", description="code content" ) diff --git a/api/app/core/workflow/nodes/code/node.py b/api/app/core/workflow/nodes/code/node.py index 3e15089b..5262a7e2 100644 --- a/api/app/core/workflow/nodes/code/node.py +++ b/api/app/core/workflow/nodes/code/node.py @@ -47,7 +47,7 @@ class CodeNode(BaseNode): result = {} for output in self.typed_config.output_variables: value = exec_result.get(output.name) - if not value: + if value is None: raise RuntimeError(f"Return value {output.name} does not exist") match output.type: case VariableType.STRING: @@ -104,7 +104,7 @@ class CodeNode(BaseNode): "x-api-key": 'redbear-sandbox' }, json={ - "language": "python3", + "language": self.typed_config.language, "code": base64.b64encode(final_script.encode("utf-8")).decode("utf-8"), "options": { "enable_network": True diff --git a/sandbox/app/services/python_service.py b/sandbox/app/services/python_service.py index 5700841d..210b2086 100644 --- a/sandbox/app/services/python_service.py +++ b/sandbox/app/services/python_service.py @@ -37,7 +37,7 @@ async def run_python_code(code: str, preload: str, options: RunnerOptions): if result.exit_code == -signal.SIGSYS: return error_response(31, "sandbox security policy violation") - if result.stderr: + if result.stderr and result.exit_code != 0: return error_response(500, result.stderr) return success_response(RunCodeResponse(