Coze 源码解析:代码块节点是如何设计与运行的?

317 阅读7分钟

👋 大家好,我是十三!

最近在研究研发提效,和同事聊到了低代码平台,而这其中一个较难实现的部分就是“代码块”节点:用户在前端输入一段代码,服务端就能动态执行并返回结果,这要怎么实现代码块的执行以及如何保证它的安全和稳定,一直让我深感困惑。

但我的运气也是极好,就在我们聊完这个话题的第三天,字节的 Coze 宣布开源了!这无疑提供了一个绝佳的机会,去学习顶尖团队是如何设计这套系统的。

今天,就让我们一起深入 Coze 的源码,看看它是如何设计远程代码执行的安全架构的。

一、核心实现:两种模式的权衡

深入源码后,我发现 Coze 并没有采用"一刀切"的方案,而是设计了两套截然不同的执行引擎。

问题来了:为什么要设计两套方案呢?

这其实体现了工程设计中的一个重要原则——场景分离。在生产环境中,安全是第一要务;而在开发测试环境中,便捷性更为重要。

1. 生产环境的安全方案:Sandbox Runner

这是 Coze 用于生产环境的 Runner 实现,其核心是通过构建一个多层嵌套的沙箱,来实现安全隔离。

其执行链条如下: Go 主进程 -> Python 调度脚本 -> Deno 运行时 -> Pyodide (Wasm)

这种分层设计,确保了每一层都有独立的、明确的安全职责,从而构建起坚固的防御体系。

第一层:Go 调度层

Go 在这个架构中负责最高层的调度。它不直接执行用户的代码,而是通过 os/exec 启动一个独立的 Python 子进程,并利用 os.Pipe 创建的管道进行高效、安全的进程间通信,用于传递代码和参数。

// file: coze/coze-studio/backend/infra/impl/coderunner/sandbox/runner.go

func (runner *runner) Run(ctx context.Context, request *coderunner.RunRequest) (*coderunner.RunResponse, error) {
	if request.Language == coderunner.JavaScript {
		return nil, fmt.Errorf("js not supported yet")  // JS被明确禁用
	}
	
	// 1. 将所有请求信息序列化为 JSON
	b, err := json.Marshal(req{
		Config: runner.config,
		Code:   request.Code,
		Params: request.Params,
	})
	if err != nil {
		return nil, err
	}
	
	// 2. 创建用于 "Go -> Python" 和 "Python -> Go" 的双向管道
	pr, pw, err := os.Pipe() // pw (Go写) -> pr (Python读)
	if err != nil {
		return nil, err
	}
	r, w, err := os.Pipe()   // w (Python写) -> r (Go读)
	if err != nil {
		return nil, err
	}
	
	// 3. 将数据写入管道并立即关闭写端
	if _, err = pw.Write(b); err != nil {
		return nil, err
	}
	if err = pw.Close(); err != nil {
		return nil, err
	}
	
	// 4. 启动 Python 脚本子进程
	cmd := exec.Command(runner.pyPath, runner.scriptPath)
	// 5. 【关键】通过 `ExtraFiles` 将管道的文件描述符传递给子进程
	cmd.ExtraFiles = []*os.File{w, pr}
	
	if err = cmd.Start(); err != nil {
		return nil, err
	}
	if err = w.Close(); err != nil { // 关闭Go端的写管道
		return nil, err
	}
	
	// 6. 从输出管道中解码 Python 脚本返回的 JSON 结果
	result := &resp{}
	d := json.NewDecoder(r)
	d.UseNumber() // 确保数字精度
	if err = d.Decode(result); err != nil {
		return nil, err
	}
	
	if err = cmd.Wait(); err != nil { // 等待子进程结束,回收资源
		return nil, err
	}
	
	// 7. 检查执行状态
	if result.Status != "success" {
		return nil, fmt.Errorf("exec failed, stdout=%s, stderr=%s, sandbox_err=%s", 
			result.Stdout, result.Stderr, result.SandboxError)
	}
	
	return &coderunner.RunResponse{Result: result.Result}, nil
}

第二、三、四层:Python 调度与 Deno 执行

这个 Python 脚本是整个沙箱架构的核心。它接收到 Go 传来的数据后,并不会直接执行代码,而是继续将任务委托给 Deno 运行时。

它通过组装和执行一条 deno run 命令,并利用 Deno 强大的权限控制能力,对文件、网络、环境等进行精细化管理。

# file: coze/coze-studio/backend/infra/impl/coderunner/script/sandbox.py

class Sandbox:
    def __init__(self, *, allow_env=False, allow_read=False, allow_write=False, 
                 allow_net=False, allow_run=False, allow_ffi=False, 
                 node_modules_dir="auto", **kwargs) -> None:
        # 1. 根据 Go 传来的配置,生成 Deno 的权限参数列表
        self.permissions = []
        perm_defs = [
            ("--allow-env", allow_env, None),
            ("--allow-read", allow_read, ["node_modules"]),
            ("--allow-write", allow_write, ["node_modules"]),
            ("--allow-net", allow_net, None),
            ("--allow-run", allow_run, None),
            ("--allow-ffi", allow_ffi, None),
        ]
        
        for flag, value, defaults in perm_defs:
            perm = self._build_permission_flag(flag, value=value)
            if perm is None and defaults is not None:
                perm = f"{flag}={','.join(defaults)}"
            if perm:
                self.permissions.append(perm)
        
        self.permissions.append(f"--node-modules-dir={node_modules_dir}")

    def execute(self, code, *, timeout_seconds=None, memory_limit_mb=100, **kwargs) -> Output:
        # 2. 组装 deno run 命令
        cmd = ["deno", "run"]
        cmd.extend(self.permissions)
        
        # 3. 设置 V8 引擎参数(内存限制等)
        v8_flags = ["--experimental-wasm-stack-switching"]
        if memory_limit_mb is not None and memory_limit_mb > 0:
            v8_flags.append(f"--max-old-space-size={memory_limit_mb}")
        cmd.append(f"--v8-flags={','.join(v8_flags)}")
        
        # 4. 运行一个基于 Pyodide (Python in Wasm) 的 JSR 包
        cmd.append("jsr:@langchain/pyodide-sandbox@0.0.4")
        cmd.extend(["--code", code])
        
        # 5. 通过子进程运行 Deno,并捕获输出
        try:
            process = subprocess.run(cmd, capture_output=True, text=False, 
                                   timeout=timeout_seconds, check=False)
            stdout = process.stdout.decode("utf-8", errors="replace")
            
            if stdout:
                full_result = json.loads(stdout)
                result = full_result.get("result", None)
                status = "success" if full_result.get("success", False) else "error"
            else:
                stderr = process.stderr.decode("utf-8", errors="replace")
                status = "error"
        except subprocess.TimeoutExpired:
            status = "error"
            stderr = f"Execution timed out after {timeout_seconds} seconds"
        
        return Output(status=status, result=result, ...)

if __name__ == "__main__":
    # 从 Go 进程传递来的文件描述符中读取输入
    w = os.fdopen(3, "wb")  # 写给 Go
    r = os.fdopen(4, "rb")  # 从 Go 读
    
    try:
        req = json.load(r)
        user_code, params, config = req["code"], req["params"], req["config"] or {}
        
        # 创建沙箱实例并执行
        sandbox = Sandbox(**config)
        
        # 包装用户代码为完整的 Python 脚本
        if params is not None:
            code = prefix + f'args={json.dumps(params)}\n' + user_code + suffix
        else:
            code = prefix + user_code + suffix
            
        resp = sandbox.execute(code, **config)
        
        # 将最终结果写回给 Go 进程
        result = json.dumps(dataclasses.asdict(resp), ensure_ascii=False)
        w.write(str.encode(result))
        w.flush()
        w.close()
    except Exception as e:
        # 异常情况下也要回传错误信息
        w.write(str.encode(json.dumps({"sandbox_error": str(e)})))
        w.flush()
        w.close()

这意味着用户的 Python 代码,最终是在一个由 Deno 严格限制权限、并运行在 WebAssembly 虚拟机内的 Pyodide 解释器中执行的。这四层嵌套构建了极为坚固的安全壁垒。

2. 开发环境的便捷方案:Direct Runner

除了相对复杂的沙箱执行器,Coze 还提供了一个简单的 direct/runner.go 实现。它没有层层嵌套,而是直接通过 exec.Command 调用系统安装的 Python 解释器来执行代码。

源码中 // ignore_security_alert RCE 的注释清晰地表明,这种方式存在远程代码执行风险,主要用于本地开发或测试等受信任的场景。

3. 统一的安全前置校验

无论使用哪种 Runner,代码在被执行前,都会经过上层业务逻辑的严格校验,特别是针对 Python 的模块导入。

就像机场安检一样,危险物品根本不允许带上飞机

// file: coze/coze-studio/backend/domain/workflow/internal/nodes/code/code.go

// 完整的模块黑名单
var pythonBuiltinBlacklist = map[string]struct{}{
	"curses": {}, "dbm": {}, "multiprocessing": {}, "threading": {}, 
	"socket": {}, "pty": {}, "tty": {}, "fcntl": {}, "grp": {}, 
	"pwd": {}, "resource": {}, "syslog": {}, "termios": {}, // ... 更多模块
}

// 第三方库白名单(非常有限)
var pythonThirdPartyWhitelist = map[string]struct{}{
	"requests_async": {},
	"numpy":          {},
}

func validatePythonImports(code string) error {
	imports := parsePythonImports(code) // 解析代码中的所有 import 语句
	importErrors := make([]string, 0)

	var blacklistedModules []string
	var nonWhitelistedModules []string
	
	for _, imp := range imports {
		if _, ok := pythonBuiltinModules[imp]; ok {
			// 检查是否在内置模块的黑名单中
			if _, blacklisted := pythonBuiltinBlacklist[imp]; blacklisted {
				blacklistedModules = append(blacklistedModules, imp)
			}
		} else {
			// 检查是否在第三方库的白名单中
			if _, whitelisted := pythonThirdPartyWhitelist[imp]; !whitelisted {
				nonWhitelistedModules = append(nonWhitelistedModules, imp)
			}
		}
	}

	// 生成具体的错误消息
	if len(blacklistedModules) > 0 {
		moduleNames := fmt.Sprintf("'%s'", strings.Join(blacklistedModules, "', '"))
		importErrors = append(importErrors, fmt.Sprintf(
			"ModuleNotFoundError: The module(s) %s are removed from the Python standard library for security reasons", 
			moduleNames))
	}
	if len(nonWhitelistedModules) > 0 {
		moduleNames := fmt.Sprintf("'%s'", strings.Join(nonWhitelistedModules, "', '"))
		importErrors = append(importErrors, fmt.Sprintf(
			"ModuleNotFoundError: No module named %s", moduleNames))
	}

	if len(importErrors) > 0 {
		return errors.New(strings.Join(importErrors, ","))
	}
	return nil
}

这一机制与 Coze 官方文档 的说明一致,是独立于 Runner 实现的、前置的第一道安全防线。

二、执行流程

下图清晰地展示了 Sandbox Runner 的完整执行流程:

sequenceDiagram
    participant User as "用户"
    participant GoBackend as "Coze Go 后端"
    participant PyScheduler as "sandbox.py 调度进程"
    participant DenoWasm as "Deno & Pyodide (Wasm)"

    User->>GoBackend: 触发包含代码块的工作流
    GoBackend->>GoBackend: 1. 前置安全校验 (模块导入等)
    GoBackend->>PyScheduler: 2. exec() 启动子进程<br/>并通过 os.Pipe 传递代码和配置
    PyScheduler->>DenoWasm: 3. subprocess() 启动 Deno 进程<br/>并设置权限 flags
    DenoWasm->>DenoWasm: 4. 在 Wasm 虚拟机中<br/>执行 Python 代码
    DenoWasm-->>PyScheduler: 5. 返回执行结果
    PyScheduler-->>GoBackend: 6. 将结果通过管道返回
    GoBackend->>GoBackend: 7. 格式化结果
    GoBackend-->>User: 8. 返回最终输出

三、回到最初的问题

现在,我们可以回答文章开头提出的那个问题了:面对低代码平台中那个棘手的"代码块"功能,我们该如何保证其安全与稳定?

通过对 Coze 源码的这次探索,我找到了一个堪称教科书级别的答案。其核心设计思想如下:

  • 极致分层的安全沙箱:用 Go -> Python -> Deno -> Wasm 的层层嵌套,构建起纵深防御体系,将风险隔离在最小的单元内。
  • 兼顾安全与效率的场景化设计:为生产环境和开发环境提供不同的执行器(Sandbox vs Direct),在不同场景下做出了最优的平衡。
  • 从源头拦截风险的前置校验:在代码真正执行前,通过严格的模块黑白名单机制,提前拆除"炸弹",而不是等它爆炸。

这套集隔离、分离、前置于一体的完整方案非常值得学习!

Make Open Source Great Again!


👨‍💻 关于十三 Tech

资深服务端研发工程师,AI 编程实践者。
专注分享真实的技术实践经验,相信 AI 是程序员的最佳搭档。
希望能和大家一起写出更优雅的代码!

📧 联系方式569893882@qq.com
🌟 GitHub@TriTechAI
💬 VX:TriTechAI(备注:十三 Tech)