harness 怎么驱动宿主软件:四种方式与各自代价

2026-08-10

读 CLI-Anything 的某个 harness,最有信息量的问题不是它注册了多少条命令,而是这条命令跑到最后,到底怎么碰到那个软件。我们采集时(2026-08-10,对应仓库快照 39634a6)逐层精读了 blenderaudacityadguardhomebrowser 四个样本,它们分别用了四种完全不同的做法,另外还有一个不在规范里的第五形态。

先把贯穿全篇的一件事说在前面:这些 harness 的工作方式就是在你本机启动外部程序、执行临时脚本文件,或者向本机 / 内网的服务发请求,宿主软件要你自己装。这不是背景交代,是下面每一节「代价」的来源。仓库根 registry.json 里我们数到 79 条记录,但记录数跟「装上就能用」是两码事——能不能驱动起来,取决于本篇讲的这一层。

规范这边给 backend 留的位置只有一句:utils/<software>_backend.py,注释原文是 “Backend: invokes the real software”(cli-anything-plugin/HARNESS.md:692-695)。目录结构本身另有一篇专门讲,本篇只关心这个文件里到底写了什么。

四种方式的对照

方式样本 harness怎么找到宿主超时口径你得先装什么
A 子进程调宿主二进制blendershutil.which("blender")subprocess.run 默认 timeout=300Blender 本体
B 子进程调配套 CLI 工具audacityshutil.which("sox")由调用方传入 timeoutSoX
C HTTP REST APIadguardhome连接参数四级解析GET/POST 统一 timeout=10一个跑着的 AdGuardHome 服务
D MCP 服务器browsershutil.which("npx") + 版本探测文档称冷启动 1–3 秒Node 环境与对应 MCP 服务端

这张表最该横着读的一列是「你得先装什么」。四种方式的差别,本质上是把「依赖」放在了不同的位置:A 和 B 依赖一个本机可执行文件,C 依赖一个已经在跑的服务,D 依赖另一套包管理器。

方式 A:起子进程调宿主二进制

blender 这条路最直白。blender/agent-harness/cli_anything/blender/utils/blender_backend.py 第 14 到 24 行的 find_blender()shutil.which("blender") 定位可执行文件,找不到就抛 RuntimeError,并在错误消息里附上 apt / brew 的安装指令。定位到之后,第 37 到 60 行拼出 cmd = [blender, "--background", "--python", script_path],交给 subprocess.run(capture_output=True, text=True, timeout=timeout),默认 timeout=300 秒。

有意思的是脚本是怎么递进去的:生成的 bpy 脚本内容先写进一个 tempfile.NamedTemporaryFile(suffix=".py", prefix="blender_render_"),渲染完在 finallyos.unlink(script_path) 删掉(同文件 85 到 89、127 到 128 行)。也就是说这条路径会在你本机落一个临时 .py 文件、然后让 Blender 去执行它——这是「执行外部脚本」最字面的意思。

代价除了要装宿主,还有一处在产物校验上:返回码非 0 时抛错并截取 stderr 末 500 字符;输出文件不存在时,会依次去试 000100001 这几个帧号后缀(Blender 单帧渲染会追加帧号),都不在就抛 “produced no output file”(94 到 118 行)。这段兜底逻辑本身就说明:子进程这条路,「命令返回 0」和「东西真的生成了」是两件要分开确认的事。

方式 B:子进程调配套 CLI 工具

audacity 走的不是 Audacity 本体,而是 SoX。audacity/.../utils/sox_backend.py 第 16 到 24 行的 find_sox() 同样是 shutil.which("sox")generate_tone / apply_effect / convert_format 三个函数各自 subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)(61、100、138 行)。

从代码形态上看,B 和 A 是一回事,差别在于被调的不是宿主软件自己,而是一个能干同类活的命令行工具。这里的代价是多一层不确定:你装的是 SoX,不是 Audacity,两者的行为口径要不要对齐、对齐到什么程度,属于这个 harness 自己的选择。至于这个 backend 在实际命令路径上被调用到什么程度,是本篇后半最反直觉的那一处,先按下。

方式 C:直接打 HTTP REST API

adguardhome 这一路根本没有本机二进制可调。它的 <APP>.md 把宿主形态写得很清楚:**Real software:** The running AdGuardHome HTTP API (not a binary to invoke directly).,紧跟着一句 CLI role: Generate structured commands - call the real API - verify responsesadguardhome/agent-harness/ADGUARDHOME.md:8-9)。

实现在 utils/adguardhome_backend.pyAdGuardHomeClient 基于 requests.Session(),base_url 拼成 {scheme}://{host}:{port}/control,端口 443 自动切 https,80 和 443 时省略端口号(11 到 23 行);认证是 Basic Auth,直接 self.session.auth = (username, password)(21 到 22 行);GET 与 POST 统一 timeout=10(48、58、63、66 行);连接失败时,把 curl 安装脚本与 docker run 命令写进错误消息里(36 到 43 行)。

连接参数按四级优先级解析:CLI flags → 环境变量 AGH_HOST/AGH_PORT/AGH_USERNAME/AGH_PASSWORD → 配置文件 ~/.config/cli-anything-adguardhome.json → 默认 localhost:3000ADGUARDHOME.md:56-60,代码在 adguardhome_cli.py:70-79)。

这条路的代价换了个方向:不用装宿主二进制,但你得先有一个跑着的服务,而且用户名与口令可以来自环境变量 AGH_USERNAME/AGH_PASSWORD,也可以来自配置文件 ~/.config/cli-anything-adguardhome.json。这是本机敏感数据,具体怎么存放要按你自己的环境评估,仓库没有对此给出方案,我们也不给。

方式 D:把 MCP 服务器当 backend

browser 是四个样本里最特别的一个。它的顶层文档自述这是 “the first CLI-Anything harness to use an MCP server as a backend”(browser/agent-harness/HARNESS.md:110)。实现用 mcp Python SDK 的 stdio 传输,from mcp import ClientSession, StdioServerParametersfrom mcp.client.stdio import stdio_clientbrowser/.../utils/domshell_backend.py:29-30),默认服务端命令是 DEFAULT_SERVER_CMD = "npx"(同文件 40 行),异步接口再用 asyncio.run() 包成同步(HARNESS.md:117)。

可用性自检分两步:先 shutil.which("npx"),再起一个子进程跑 npx @apireno/domshell --versiondomshell_backend.py:93-102,130-141)。

代价在进程模型上。默认每条命令新起一个 MCP 进程,文档称冷启动 1–3 秒;带 --daemon 则复用一个持久连接(HARNESS.md:122-129,297-300)。也就是说这一路的开销不在宿主软件本身,而在「每次都要把 backend 重新拉起来」。daemon 启动失败不致命:browser_cli.py 141 到 150 行捕获 RuntimeError 后打印 “Falling back to per-command mode” 继续跑。

browser 也是唯一一个把安全边界单独成文件的样本:utils/security.py 我们采集时是 237 行,validate_url 校验 scheme 白名单(默认 http,https,可用 CLI_ANYTHING_BROWSER_ALLOWED_SCHEMES 覆盖)、拒绝没有 scheme 的 URL,另有可选的内网拦截开关 CLI_ANYTHING_BROWSER_BLOCK_PRIVATE,以及 sanitize_dom_text(text, max_length=10000)(22、27 到 31、37、92、133 到 150、168 行)。这几个环境变量是这一路值得单独记一笔的东西。

规范之外的第五形态:三十来行的薄转发

intelwatch 不属于上面任何一种。它的 intelwatch_cli.py 全文 32 行,用 @click.command(ignore_unknown_options=True, allow_extra_args=True) 把收到的所有参数原样拼成 ["npx", "intelwatch"] + ctx.args,交给 subprocess.call,再 sys.exit() 把返回码透传出去(6 到 22 行)。

它本身不解析命令、不做 JSON 输出、也不建会话,就是一层壳。和这个形态相配的是,我们采集时 intelwatch 同时出现在几份缺件名单里:它没有 TEST.md、没有包内 README.md*_cli.py 里搜不到字面量 "--json",也没写 invoke_without_command。这些是可以逐条核对的事实,我们陈述到这里。

最反直觉的一处:backend 文件在,不代表主路径会调它

前面四种方式看着像是并列的四条路,但如果你据此以为「有 <software>_backend.py 就等于这个 harness 会去驱动宿主」,会踩空。我们对三个样本各跑了一次 grep -rn '<name>_backend' <harness> --include='*.py',注入的深浅差了三档:

harnessbackend 被谁 import
adguardhome9 个 core 模块与 CLI 主文件直接 import
blender只被 core/preview.py 与测试 import
audacity只被 tests/test_full_e2e.py import,生产代码零引用

第一档是「backend 就是主路径」,第三档是「backend 只活在测试里」。

blender 这一档还有一处名字容易骗人:render execute 这条命令并不启动 Blender。它只写出脚本文件,并返回一个 "command": "blender --background --python <script>" 的字符串(blender/.../core/render.py:182-238),CLI 侧打印的提示语是 “Render script generated: …”(blender_cli.py:858-871),包内 README 也是让你自己再去跑一遍 blender --background --python /path/to/_render_script.pyREADME.md:44-46)。这一处文档写得如实,但命令名叫 execute,容易读反。

audacity 那一档还牵着另一条可核对的差异。HARNESS.md 486 到 488 行写的是 Do NOT reimplement rendering in Python. Do NOT gracefully degrade to a fallback library.;而 AUDACITY.md 自述 “Python stdlib (wave, struct, math) handles WAV I/O and audio processing”、“Basic effects (gain, fade, reverse, echo, filters) implemented in pure Python”(36、143 行),core/export.py 也只 import 了 os/wave/math/struct 与本地的 audio_utils(10 到 16 行)。规范一处、这个 harness 的实现一处,两者不一致;以我们实读的仓库状态为准,我们只陈述到这里,不推断哪一处该改。

顺带说清这一档在代码上意味着什么:一个 backend 只被 e2e 测试引用的 harness,说明它的生产代码路径里没有对这个外部工具的调用点;core/export.py 只 import 了 os/wave/math/struct 与本地 audio_utils。至于不装 SoX 时哪些命令还能跑、跑出来是什么,我们没有运行过,不做判断。能确定的只是:这一层的存在与否,跟「主命令路径会不会驱动宿主软件」是两件事。

依赖缺失时会长什么样

驱动方式决定了「装不全」时的表现,这一层各家也不统一。

约一半 harness 走一个 handle_error 装饰器:捕获 FileNotFoundError(type 标 file_not_found)、(ValueError, IndexError, RuntimeError)(type 标异常类名)、FileExistsError(type 标 file_exists)三组,--json 模式下把 {"error": ..., "type": ...} 打到 stdout,否则把 Error: ... 打到 stderr,并且只有非 REPL 模式才 sys.exit(1)blender_cli.py:160-186)。我们采集时数到 32 个 *_cli.py 定义了 handle_error,也就是约一半 harness 走这套。

browser 则把依赖检查提到了根组:先跑 backend.is_available(),不可用时 JSON 模式输出 {"error": msg, "type": "dependency_error"},人类模式打印错误加扩展安装地址,然后 sys.exit(1)--help--version 跳过这项检查(browser_cli.py:118-132)。

还有一条会直接影响你怎么读「测试都过了」这句话:我们采集时有 32 个测试文件用了 pytest.mark.skipif,也就是宿主软件缺失时跳过而不是失败。规范那边的要求是 “E2E tests MUST invoke the real software and produce real output files”(HARNESS.md:538-547),而 blender 的 TEST.md 写的是 test_full_e2e.py “No Blender binary required”(blender/.../tests/TEST.md:113)。两处口径不一致,摆在这里;一份绿色的测试输出,不能直接读成「这个 harness 在你机器上能驱动宿主」。

最后一个背景数:我们采集时,45 处打包文件把 Development Status 标为 4 - Beta,2 处标 3 - Alpha,全仓没有一个标 5 - Production/Stable

你可以自己跑一遍的核对动作

判断一个 harness 走的是哪条路、注入到多深,不用读完整个目录,三步就够:

ls <software>/agent-harness/cli_anything/<software>/utils/
grep -rn '<software>_backend' <software>/agent-harness --include='*.py'
grep -rn 'shutil.which\|requests.Session\|StdioServerParameters' <software>/agent-harness --include='*.py'

第一条看有没有 backend 文件;第二条看它被谁 import——只出现在 tests/ 里,就说明主命令路径不走它;第三条看它属于哪一类:命中 shutil.which 是子进程路线,命中 requests.Session 是 HTTP 路线,命中 StdioServerParameters 是 MCP 路线。以上三条是按我们采集时所用命令改写成的通用形式,未经实测,路径请替换成你要查的那个 harness,以官方文档与 --help 的实际输出为准。

顺带一句提醒:这三条命令只读文件,不会启动任何东西。真要把某个 harness 跑起来,前面说的那件事仍然成立——它会在你本机执行外部程序与脚本,宿主软件也要你自己装。这条路要不要走,按你自己的环境来判断。


本文依据 CLI-Anything 官方仓库(github.com/HKUDS/CLI-Anything)的 README、registry.jsondocs/ 与各 agent-harness/ 下的源码整理,核对日 2026-08-10,对应仓库快照 39634a6。 本文内容为仓库源码与文档口径,我们没有安装或运行过其中任何一个 harness, 也没有在本机驱动过任何一款宿主软件,因此不涉及实际操控效果的任何描述。 这类 harness 会在本机执行外部程序与脚本,是否使用请结合自身环境评估。 注册表与 harness 内容随上游更新而变动,请以仓库最新内容为准。 安全相关做法请结合自身环境评估,本文不构成安全方案建议。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。