DeerFlow Lark CLI Broker(Pattern B):用 Sidecar 镜像把飞书凭据从 Agent 沙箱中隔离出去
在 DeerFlow 的 K8s 沙箱部署中,Agent 需要在沙箱里调用 lark-cli 完成飞书集成,而每个用户的 appSecret 与 OAuth token 又绝不能出现在沙箱文件系统里——否则一个被提示注入的 Agent 就能直接 cat 走凭据。本文以 docker/lark-cli-broker/README.md 为主线,完整拆解 Pattern B(Broker)方案的镜像构建、双模入口、shim 转发机制、provisioner 接线、loopback HTTP 契约与子命令拒绝清单,并结合 lark_broker.py 与 provisioner/app.py 的源码实现说明其底层原理与生产加固要点。
1. 为什么需要 Broker:Pattern A 的沙箱凭据暴露面
背景来自 issue #4338 的修复方案。DeerFlow 此前采用 Pattern A:把 lark-cli 二进制预置进沙箱运行时目录,但每用户的凭据目录仍然以卷挂载方式进入沙箱容器——config 目录里有长期有效的 appSecret,data 目录里有 OAuth token。由于 Agent 在沙箱内拥有 bash 工具,任何拿到 shell 的进程(包括被恶意网页内容提示注入的 Agent)都能直接读取这些明文文件。
Pattern B 的核心思路是:凭据留在外面,命令面留在里面。一个长驻的 sidecar 容器持有真实的 lark-cli 二进制和凭据目录,只通过 loopback HTTP 提供“命令执行”这一最小接口;沙箱里放一个极小的 lark-cli shim(垫片),把 argv/stdin 原样转发给 sidecar。结果是:
- 原始的
appSecret/ OAuth token 文件在沙箱文件系统中根本不存在,被攻陷的沙箱无法cat/外泄它们; - 但任何合法的
lark-cli子命令仍然可以正常执行——对用户和 Agent 来说命令面完全无感。
这正是 lark_broker.py 模块开头文档化的设计目标:broker 侧半部分是一个“拥有 lark-cli + 凭据、只暴露命令面的长驻进程”,且整个模块仅用 Python 3 标准库实现,这样同一个模块既能跑在最小化的 broker sidecar 镜像里,也无需安装任何第三方依赖。
2. 一个镜像、两种角色:install-shim 与 serve
Broker 镜像通过第一个 CLI 参数分发两种模式(见 entrypoint.sh 中的 case 分发逻辑):
| 模式 | 角色 | 行为 |
|---|---|---|
install-shim <dest> |
init 容器 | 把 launcher + Python shim + 运行时标记文件 .deerflow-lark-cli-runtime.json(内容为 {"version": ..., "kind": "shim"})写入共享 emptyDir(<dest>,默认 /mnt/integrations/lark-cli/runtime),然后以退出码 0 结束 |
serve(默认 CMD) |
sidecar | 在 127.0.0.1:8788 上运行 broker HTTP 服务,加载真实 lark-cli,凭据环境变量指向 sidecar 独占的 /var/lark/{config,data} 挂载 |
两种模式最终都收敛到同一个标准库模块:entrypoint 只是 exec python3 /opt/broker/lark_broker.py [install-shim ...],镜像里没有其它运行时代码。
2.1 install-shim:产出与 Pattern A 相同的布局
install-shim 的实现是 lark_broker.py 中的 install_shim(),它向目标目录写入三个东西:
bin/lark-cli—— 位于PATH上的可执行文件,实际是一个/bin/shlauncher;bin/lark-cli-shim.py—— 真正干活的 Python shim 本体,与 launcher 同目录;.deerflow-lark-cli-runtime.json—— 运行时标记文件,kind字段为"shim",让运行时校验器知道linux-*真实二进制有意缺席(真实二进制在 sidecar 里),避免误判为安装失败。
这个布局与 Pattern A 的 init 镜像产物完全同构,因此沙箱里 lark-cli 出现在 lark_cli_env_overlay(sandbox_paths=True) 所指向 PATH 的同一位置,上层代码无需为两种模式分叉。
2.2 Launcher 与 Shim 分离:为什么 bin/lark-cli 不是 Python 脚本
README 强调了一个工程细节:PATH 上的可执行文件 bin/lark-cli 是一个 /bin/sh launcher,它负责解析出 Python 3 解释器后 exec 同目录下的 shim 本体 bin/lark-cli-shim.py。分离的动机在 lark_broker.py 的 LARK_CLI_BROKER_LAUNCHER_TEMPLATE 常量中写得很清楚:
- shim 本体需要发 HTTP 请求,必须是 Python;但 Broker 模式是 opt-in 的,如果直接把 shim 做成
#!/usr/bin/env python3脚本,任何PATH上没有python3的沙箱镜像都会让每次lark-cli调用变成晦涩的 ENOEXEC/exit 127,而且这种故障可能在 CI 中漏网、只在运维侧暴露; - launcher 只用 shell 内建命令(
command -v逐个探测python3、python),即使沙箱PATH为空、仅靠DEERFLOW_LARK_BROKER_PYTHON环境变量钉住解释器路径也能工作; - 找不到解释器时,launcher 大声失败:打印可操作的错误信息(提示设置
DEERFLOW_LARK_BROKER_PYTHON)并以127退出,而不是留一个不透明的 ENOEXEC; - launcher 里 shim 本体的路径是安装时烘焙进去的绝对路径(
render_launcher_script()把占位符@@LARK_CLI_BROKER_SHIM_PATH@@替换为实际路径)。因为从PATH上直接运行lark-cli时$0只是裸命令名、没有目录信息,靠$0做同目录查找会失败;而安装目录是 init 容器与沙箱共享的稳定挂载,绝对路径在两侧都有效。
标准 all-in-one-sandbox 镜像自带 Python 3,所以默认路径无需任何额外配置。两个脚本模板都以进程内常量(LARK_CLI_BROKER_LAUNCHER_TEMPLATE / LARK_CLI_BROKER_SHIM_SCRIPT)作为唯一事实来源,由 broker 镜像构建时的 install-shim 生成——镜像里的副本永远不可能与 Gateway 进程内的副本漂移。
2.3 Shim 本体的转发语义
shim 脚本本身(LARK_CLI_BROKER_SHIM_SCRIPT,lark_broker.py)逻辑非常克制:
- 读取
DEERFLOW_LARK_BROKER_URL(默认http://127.0.0.1:8788),把sys.argv[1:]与 base64 编码的 stdin 打包成 JSON,POST /v1/exec; - 把 broker 返回的
stdout_b64/stderr_b64原样写回 stdout/stderr,并以 broker 返回的exit_code退出; - 任何传输层故障(broker 不可达、HTTP 错误、JSON 解析失败)都以非零码大声失败,保证 broker 宕机永远不会看起来像一次“成功”的
lark-cli执行——HTTP 错误还会把 broker 返回的error字段透传出来(broker rejected request (HTTP 500: ...))。
3. 构建 Broker 镜像
构建上下文是仓库根目录(broker 模块位于 backend/ 之下),构建命令见 README:
docker build -t deer-flow/lark-cli-broker:v1.0.65 \
--build-arg LARK_CLI_VERSION=v1.0.65 \
-f docker/lark-cli-broker/Dockerfile .
镜像 tag 应当编码 lark-cli 版本,使其可以独立于上游 all-in-one-sandbox 镜像单独升级。Dockerfile 的关键事实:
- builder 阶段(
debian:bookworm-slim):复用 Pattern A 的共享脚本 build-runtime.sh 下载官方larksuite/cli的 Linux 发布二进制,并做 SHA-256 校验后放入/opt/lark-cli——sidecar 拿到的是与 Pattern A 完全同一下载+校验路径产出的真实二进制;支持APT_MIRROR构建参数用于镜像源替换; - 最终阶段(
python:3.12-slim):拷贝构建好的二进制、单文件模块lark_broker.py与 entrypoint,并预置环境变量:
| 环境变量 | 默认值 | 作用 |
|---|---|---|
DEERFLOW_LARK_BROKER_CLI |
/opt/lark-cli/bin/lark-cli |
broker 实际调用的二进制路径 |
LARKSUITE_CLI_CONFIG_DIR |
/var/lark/config |
sidecar 侧凭据 config 目录 |
LARKSUITE_CLI_DATA_DIR |
/var/lark/data |
sidecar 侧 OAuth token 等 data 目录 |
DEERFLOW_LARK_BROKER_HOST / _PORT |
127.0.0.1 / 8788 |
loopback 绑定地址 |
LARK_CLI_RUNTIME_DEST |
/mnt/integrations/lark-cli/runtime |
install-shim 缺省写入位置 |
CI 会以多架构(linux/amd64,linux/arm64)发布到 ghcr.io/<owner>/deer-flow-lark-cli-broker:<lark-cli-version>,由 lark-cli-images.yaml workflow 触发(带 lark_cli_version 输入运行,或推送 lark-cli-v* tag)。这条流水线与 DeerFlow 主 v* 发布解耦,因为该镜像跟踪的是上游 larksuite/cli 的版本节奏。
4. 接入 Provisioner:Opt-in、容器拓扑与就绪信号
Broker 模式是可选开启(opt-in)的,默认关闭。启用方式是发布镜像并在 provisioner 上配置 LARK_CLI_BROKER_IMAGE。源码中 app.py 的默认值是空字符串,_lark_cli_broker_enabled() 要求镜像配置与请求标志同时成立:
LARK_CLI_BROKER_IMAGE = os.environ.get("LARK_CLI_BROKER_IMAGE", "")
def _lark_cli_broker_enabled(provision_lark_cli_broker: bool) -> bool:
return bool(LARK_CLI_BROKER_IMAGE) and provision_lark_cli_broker
因此镜像未发布或未配置时就是 no-op——走 Pattern A / 遗留路径,行为零变化。当配置生效且 Gateway 在建沙箱请求中携带 provision_lark_cli_broker 时,provisioner 会追加以下资源:
| 资源 | 说明 |
|---|---|
lark-cli-runtime emptyDir |
init 容器与沙箱共享;沙箱侧为只读挂载 |
lark-cli-shim-init init 容器 |
运行 broker 镜像的 install-shim 模式,把 shim 预置进共享卷 |
lark-cli-broker sidecar |
运行 serve 模式,per-user 的 config(只读)/ data(可写)凭据挂载只进 sidecar;另有嵌套的 locks 挂载保持可写 |
| 沙箱容器 | 只拿到 runtime 卷的只读挂载 + DEERFLOW_LARK_BROKER_URL 环境变量,没有任何 config/data 挂载 |
几个值得注意的实现细节(均可在 app.py 中核对):
_build_lark_cli_init_containers()(L839-L883)中,broker 启用时返回的是lark-cli-shim-init容器(args=["install-shim", <runtime 路径>],privileged=False),取代 Pattern A 的lark-cli-init容器——即“两者同时配置时 Broker 模式优先(supersedes)”;_build_lark_cli_broker_sidecars()(L886-L948)把config/locks/data三个挂载只挂到 sidecar 的/var/lark/*路径上(PVC 模式下带 per-mountsub_path隔离),并注入LARKSUITE_CLI_CONFIG_DIR/LARKSUITE_CLI_DATA_DIR指向 sidecar 内路径;如果 provisioner 侧配置了DEERFLOW_LARK_BROKER_DENY_SUBCOMMANDS,还会把它转发为 sidecar 环境变量;- 沙箱侧的环境覆盖由 lark_cli.py 的
lark_cli_env_overlay(user_id, broker=True)生成:只包含PATH(把 runtime 的bin/排到最前)和DEERFLOW_LARK_BROKER_URL,绝不携带LARKSUITE_CLI_CONFIG_DIR/DATA_DIR——这是“明文凭据路径不出现在沙箱环境里”的代码级保证。
4.1 就绪信号:/api/capabilities 与 Lark 集成状态
provisioner 通过 GET /api/capabilities 上报自身能力(app.py):
{"lark_cli_init_image": true, "lark_cli_broker_image": true}
Gateway 据此把 Lark 集成的沙箱运行时就绪状态暴露为 /api/integrations/lark/status 中的 sandbox_runtime_mode: "broker" 信号。其目的在源码注释里写得很直白:让前端 UI 变绿的同时,不能掩盖聊天时的 command not found——即 UI 状态与运行时真实能力对齐。
5. Broker HTTP 契约(仅 loopback)
Broker 服务只用标准库 ThreadingHTTPServer 实现,绑定 loopback。在 K8s 中沙箱与 sidecar 共享 Pod 网络命名空间,所以 127.0.0.1 能打到 sidecar,而 Pod 外的任何容器都不可达——这本身是一道网络层隔离。
5.1 POST /v1/exec
- 请求体:
{"args": [...], "stdin_b64": "..."},其中args必须是字符串列表,broker 侧会做类型校验,非法请求返回400 {"error": "invalid request"}; - 响应体:
{"exit_code", "stdout_b64", "stderr_b64", "truncated"}; - 执行语义在 lark_broker.py 的
run_lark_cli()中:args以 argv 列表 +shell=False交给subprocess.run,所以沙箱侧提供的任何参数不可能被 shell 解释注入第二条命令; - 凭据环境变量由 broker 通过
BrokerConfig.credential_env()单方面注入(含LARKSUITE_CLI_CONFIG_DIR、LARKSUITE_CLI_DATA_DIR以及LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1/NO_SKILLS_NOTIFIER=1),客户端无法覆盖——沙箱进程不能把lark-cli指向别的 profile; - 超时返回
exit_code 124+lark-cli: broker timed out;二进制缺失返回127;其它意外异常(OSError等)统一收敛为500 {"error": "broker exec failed"},保证 shim 侧总能拿到结构化响应而不是不透明的传输失败。
5.2 防御纵深:资源限制一览
这些常量在 lark_broker.py 中集中定义,针对的是“被攻陷沙箱恶意消耗自己 broker”的场景:
| 常量 | 值 | 防御目标 |
|---|---|---|
LARK_BROKER_MAX_REQUEST_BYTES |
1 MiB | 超大请求体直接 413 |
LARK_BROKER_MAX_OUTPUT_BYTES |
4 MiB | stdout/stderr 截断并置 truncated: true |
LARK_BROKER_DEFAULT_TIMEOUT_SECONDS |
120 | 单次 lark-cli 执行超时(可用 DEERFLOW_LARK_BROKER_TIMEOUT 覆盖) |
LARK_BROKER_MAX_CONCURRENCY |
8 | BoundedSemaphore 限制并发子进程数,打满即 503 {"error": "broker busy"} |
LARK_BROKER_SOCKET_TIMEOUT_SECONDS |
30 | 防止客户端声明大 Content-Length 却不发 body、永久挂住 per-connection 线程 |
5.3 GET /v1/health
返回 {"ok": true},供 sidecar 就绪探测使用;其余路径返回 404。
6. 使用边界:只有命令面,没有文件系统桥
这是启用 Broker 模式前必须理解的语义收缩:broker 在 sidecar 自己的工作目录里运行 lark-cli,看不到沙箱文件系统,因此沙箱的 cwd 有意不被转发(shim 模块 docstring 亦有说明)。后果是:
- 依赖“相对沙箱 cwd 的路径”读/写文件的子命令(例如上传某个本地文件)在 Broker 模式下不受支持;
- 绝对路径仍然有效,但指向的是 sidecar 的文件系统,不是沙箱的;
- 定位上,这是一座纯命令面桥(command-surface-only bridge),不是文件系统桥。
7. 可选子命令拒绝清单(DEERFLOW_LARK_BROKER_DENY_SUBCOMMANDS)
Broker 移除了凭据文件,但完整 lark-cli 命令面仍然可达——任何能打印/导出 token 的子命令(如 config show、auth token)依旧能把凭据“读进 stdout”再外泄。因此 Broker 提供一个硬化开关:在 sidecar 上设置
DEERFLOW_LARK_BROKER_DENY_SUBCOMMANDS="config show, auth token"
实现要点(lark_broker.py):
parse_deny_subcommands()把逗号分隔串解析为命令前缀元组,"config show, auth token"→(("config", "show"), ("auth", "token")),空白项被丢弃;- 匹配针对 leading non-flag tokens:先过滤掉所有以
-开头的选项及其值,因此config --json show同样会被config show规则命中; - 命中后不会 spawn 二进制,直接返回退出码
126与subcommand 'config show' is disabled in broker mode消息; - 默认值为空(行为零变化)。测试覆盖见 test_lark_broker.py(含解析、匹配与拒绝路径)。
README 给出的生产建议:在启用前,先确认所部署 lark-cli 版本的子命令面中不存在其它“唾手可得”的密钥导出命令;provisioner 侧配置了该变量后会自动注入到 sidecar 容器(app.py),未配置则不注入。
8. 小结与延伸阅读
Pattern B 用“sidecar 持有凭据 + loopback 命令面 + 沙箱 shim”的组合,把凭据暴露面从“沙箱文件系统”收缩到“Pod 内 loopback + 显式命令白名单”,同时通过单模块单标准库、双模式分发、进程内模板常量三个设计保证了镜像与 Gateway 永不漂移、失败永远可诊断。关键路径速查:
| 关注点 | 路径 |
|---|---|
| 方案说明(本文主体) | docker/lark-cli-broker/README.md |
| 镜像定义 | docker/lark-cli-broker/Dockerfile / docker/lark-cli-broker/entrypoint.sh |
| Broker 核心实现(标准库单模块) | backend/packages/harness/deerflow/integrations/lark_broker.py |
| 沙箱环境覆盖(broker 模式) | backend/packages/harness/deerflow/integrations/lark_cli.py |
| K8s 接线(init/sidecar/挂载/能力上报) | docker/provisioner/app.py 与 docker/provisioner/README.md |
| 行为验证 | backend/tests/test_lark_broker.py、backend/tests/test_provisioner_pvc_volumes.py |
适用前提:该方案面向 K8s 多容器 Pod 拓扑(依赖沙箱与 sidecar 共享网络命名空间实现 loopback 互通),且需要运营方自行构建/发布 broker 镜像并配置 LARK_CLI_BROKER_IMAGE;未配置时系统自动回退 Pattern A,不会改变既有行为。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0622
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00