目录

LocalHands 技术手记

2 8.0~10.3 分钟 3594

前言

前段时间 Kimi K3 发布,综合表现已经贴近目前最强的 Fable 5,在前端与多模态方向上甚至更强。

飞书 Aily 的智能体随后接入了 Kimi K3。但飞书智能体本质上运行在飞书服务器的云端沙箱里:它可以随时随地持续工作,也能直接调用飞书自身的一整套接口,代价是它碰不到你真实的电脑。这是设计取舍,不是缺陷。

为了让它能操作我这台机器,我调研并实现了一套方案,以 MCP 的形式提供给飞书智能体,让它通过调用工具完成读写文件、执行命令、搬运二进制文件等操作。项目为 LocalHands

这篇文章记录了过程中做过的判断:哪些取舍是必须的,哪些是踩过坑之后才补上的,以及这套方案明确不解决什么问题。

技术路线简述

想让飞书智能体访问你的真实电脑,需要一道桥梁进行连接,我选择的方案是:

  1. 本地启动一个 MCP 服务,提供文件读写等操作。

  2. 将服务注册到本地网络端口上。

  3. 使用反向代理内网穿透工具,将本地网络暴露在公网上,以供有网络能力的飞书智能体将服务作为 MCP 接入。

这三步一个下午就能跑通。花时间的是之后的部分:一个能演示的 demo,和一个可以长期挂在公网上、被一个你无法控制的模型驱动的守护进程,中间隔着相当多具体问题。

本地是"手"

现在多数 AI 集成方案的形态是"本地智能体主导 + 调用云端工具":Agent 跑在你的机器上,云端提供搜索、模型、存储。LocalHands 把这个方向反了过来——推理跑在云端平台上,这个守护进程是它在你工作站上借用的那双手。

   云端智能体  ──►  MCP over HTTPS  ──►  隧道  ──►  LocalHands  ──►  你的机器
                                                     │
                          限流 → Bearer 令牌 → 路径白名单 → 命令护栏 → 审计日志

这个方向决定了后面所有设计的前提:发起调用的一方不受你控制。它可能读到一份文档里夹带的注入指令,可能把参数填错,可能在一个循环里连续发二十次请求。所以本地这一侧的每个决定,都要按"对面是一个善意但不可靠的调用方"来做,而不是按"对面是我自己"来做。

请求路径

所有请求经过同一条路径,顺序是从外到内的:

隧道 → 限流 → Bearer 认证 → ASGI 路由
                              ├─ /mcp                → Streamable HTTP
                              ├─ /sse, /messages/    → SSE
                              ├─ /download/<票据>, /upload/<票据> → 二进制端点
                              └─ 其余                → /health, /

限流放在最外层,在解析任何内容之前生效,用的是令牌桶(默认 60 请求/分钟,同时也是突发容量)。认证用 secrets.compare_digest 做常数时间比对,同时接受 Authorization: Bearer 头和 ?token= 查询参数——后者是为只能填一个 URL 的客户端(例如飞书)准备的,可以用配置关掉。

有两个地方刻意绕开了认证:

  • /health 完全公开。 它是隧道和外部监控唯一能确认"服务真的活着"的手段,加上令牌就等于让监控也要持有 shell 级别的凭证。它只返回版本、实际通告的工具名和几个状态字段。

  • /download/upload 不校验主令牌,改用路径里的一次性票据。原因下一节讲。

SSEStreamable HTTP 两条路由是手写 ASGI 路由分发的,没有用 StarletteMount。因为 Mount("/sse") 会让传输层把 POST 地址通告成 /sse/messages/,客户端照着这个地址发就 404。两种传输同时开着,是为了让已经注册在 /sse 上的客户端继续可用,同时把新客户端引到 /mcp

工具面

claude mcp serve 本身就能把本地机器通过 MCP 暴露出去,对很多场景已经够用。我仍然写了一个,理由是用覆盖面换一个更窄、更受约束的暴露面:

claude mcp serve

LocalHands

每轮对话的工具 schema

30 个工具、约 70 000 字符

23 个工具,全部围绕"操作工作站"

身份认证

Bearer 令牌,常数时间比对

文件系统范围

不受限

白名单,先解析软链接再校验

审计留痕

JSONL,每次调用一行,自动轮转

二进制传输

经过模型上下文

带外 HTTP,一次性 URL

第一行那个数字比看上去要紧:工具 schema 是每一轮都要重新付一次的上下文,而其中绝大部分与"操作一台机器"无关(仅 Workflow 一个工具的描述就有 19 378 字符)。把工具面收窄到 23 个,不是为了少写代码,是为了让每一轮的固定开销落在真正会被调用的东西上。

23 个工具的分布:文件 8 个、搜索 5 个、shell 1 个、二进制传输 2 个、图像 4 个、网络下载 1 个、桌面交互 2 个。其中只有三个会被坐在机器前的人察觉到——notify 弹对话框、open_path 把窗口拉到前台、screenshot 抓当前屏幕。这个区分不在 MCP 规范里,所以它同时写在三个地方:标准 annotations、_meta 里的自定义提示、以及工具描述正文——因为客户端可能忽略前两者中的任何一个。

所有这些元数据集中在一张表里(tools/policy.py),而不是散落在 23 个构造函数中。这样"这个服务不经询问就能对我的机器做什么"这个问题,答案是一屏文字:

"read_file":   P("Read file", read_only=True, idempotent=True),
"write_file":  P("Write file", destructive=True, idempotent=True),
"run_bash":    P("Run shell command", destructive=True),
"screenshot":  P("Capture the screen", read_only=True, open_world=True, reads_screen=True),
"notify":      P("Alert the user", user_visible=True),

新增工具的流程因此有一条硬性要求:在这张表里加一行。漏掉的工具不会报错,但会在没有任何 annotation 的情况下发出去——这比崩溃更难发现。

二进制

MCP 把工具参数和结果都当文本传递。对源码没问题,对字节流,例如图像是灾难:

  • 模型 → 本地。 模型把 base64 生成出来作为工具参数,于是每个字节都是输出 token。一张 500 KB 的图片约 680 000 字符:几十万输出 token,几十秒生成时间。这条路实际上不可用。

  • 本地 → 模型。 字节作为工具结果返回。如果客户端能把 MCP ImageContent 解码成真正的图像,只需付图像本身的视觉 token;但有些客户端会把工具结果落盘成 JSON 再当文本读回来,那就是全额文本价。这属于客户端行为,不能假定。

所以传输走带外通道:prepare_download / prepare_upload 只签发一次性 URL,并返回一条可直接执行的 curl 命令,智能体在自己的沙箱里搬文件,进入上下文的只有一个短 URL。read_image 对任意尺寸的图片,返回的载荷都在 760 字符左右。

比省 token 更大的收益是这条通道与格式无关:PDF、压缩包、电子表格走同一条路。于是"read_file 只支持文本"这个限制就不再要紧了——需要看内容的东西,让智能体自己取走用自己的工具看。

反方向也补齐了:download_file 由守护进程自己去拉公网 URL 落到本地磁盘,字节同样不经过上下文。这个工具是天然的 SSRF 入口——URL 由远端模型指定,而 http://127.0.0.1:8765/http://192.168.1.1/ 这类地址对本进程可达、对智能体不可达。所以每个 URL 都要过一遍校验:协议、主机、以及解析出的每一个 IP;重定向不交给 httpx 自动跟随,而是手工逐跳重新校验(最多 5 跳),否则下一跳会在代码看清它指向哪里之前就已经被请求了。回环、链路本地、私有网段一律拒绝,只有配置里显式写进白名单的主机例外——毕竟把它写进配置的人就是这个意思。

这里有一个我没有解决的问题,:校验时解析一次 DNS,httpx 建连时会再解析一次,两次之间存在 rebinding 窗口。要关掉它需要在连接层面做地址钉定,而 httpx 没有暴露这个能力。

一次性票据

/download/upload 绕开 Bearer 认证,是因为这个 URL 会被交给远端沙箱用 curl 去取——它携带的任何凭证都会留在那个沙箱的 shell 历史和日志里。

票据是 32 字节随机数,作用域被压到一个文件、一个方向、一次使用、几分钟有效(默认 300 秒,上限 3600)。签发和兑换时都重新校验路径白名单——白名单可能在两者之间变了,软链接也可能被换掉。兑换在任何 I/O 之前就把票据从表里删掉,所以中途失败的请求无法重放。票据只存在内存里,重启即全部失效,这是想要的行为。

上传写到目标同目录的 .part 文件再 os.replace 改名,所以连接中断不会在目标路径上留下一个截断的文件。download_file 用同一套做法。

需要提前说明的是,这条带外通道省掉的是上下文里的 token,不是第三方可见性——字节仍然要穿过隧道服务商的边缘节点。

安全与代价

隧道服务商

ngrokCloudflare Tunnel 都在自己的边缘节点终止 TLS,再由另一条连接回到你的机器。也就是说,经过这条通道的文件内容,在服务商的边缘节点上是明文。这不是实现缺陷,而是这类服务的工作方式。

由此要修正一个容易产生的误解:前面那条"二进制走带外 HTTP"的通道,省掉的是模型上下文里的 token,不是第三方可见性。字节确实没有进入云端智能体的上下文,但它照样穿过了隧道服务商。

一次性票据 URL 也在这条链路上:/download/<32 字节随机串> 会出现在服务商的访问记录里。票据几分钟即过期、且只能兑换一次,它的价值衰减得很快——但在有效期内,它确实是一份可用的凭证。这是签发它时就接受的取舍:真正危险的做法是把长期令牌放进 URL,那才是不可撤回的泄露。

ngrok 自己的账号凭证保存在本机的 ngrok 配置文件里,既不经过本项目,也不会出现在本项目的配置和日志中。反过来说,这是两套互不相干的凭证——轮换 auth_token 不影响 ngrok,注销 ngrok 也不影响 auth_token,必须分别管理。

能收紧的地方有限,但不是没有,例如可以自建隧道,或用自有域名的反向代理,把边缘节点收回自己手里。

云端智能体

凡是进入模型上下文的内容,就进入了那个平台的会话记录。落到这套方案上,有四件具体的事:

  • 工具调用的参数和结果会被留存。 文件路径、grep 命中的行、run_bash 的 stdout——都是文本,都在上下文里,因而也都在平台侧的会话历史里。所以"哪些目录写进 allowed_paths"这个决定,等价于"哪些内容可以被抄进一份云端会话记录"。这是白名单最该被认真填写的理由,比防误删更重要。

  • ?token= 形式注册 MCP,长期令牌就存进了平台的配置里。 这条路径是为只能填一个 URL 的客户端准备的兼容方案,方便,但那个令牌等同于本机的 shell 密码。能用请求头就用请求头;把 allow_query_token 设为 false 可以直接关掉这条路。

  • 一次性 URL 会落进对方沙箱的 shell 历史。 智能体是在自己的环境里执行 curl 的。这正是当初拒绝把主令牌放进 URL 的原因;换成分钟级失效的票据之后,留在历史里的那行命令过期即成废纸。

  • 取回的文件留在对方沙箱里。 prepare_download 之后,那份副本就存在于云端沙箱的文件系统上,其生命周期由平台决定,不由你决定。截图尤其需要单说:它捕获的是当前屏幕上的全部窗口,包括与任务无关的那些,一旦发出就不可撤回。screenshot_enabled: false 因此是一个值得默认考虑的开关,而不是边角配置。

本项目这一侧能做的只有三件:不采集遥测、审计日志只落本地、把"哪三个工具会被人察觉到"标注清楚。它没有能力约束对面的留存策略

企业网络

最后一条与代码无关,但比前面几条更容易造成实际后果。

一条长期在线的内网穿透连接,符合企业侧安全设备重点识别的特征:指向隧道服务商边缘节点的长时 TLS 会话、非业务域名的 SNI、终端上出现 ngrokcloudflared 可执行文件,以及一台内网主机开始对外提供服务。上网行为管理、EDRNDR 中的任何一个都可能把它标记出来并推送告警。

所以正确的顺序是反过来的:先确认这台机器的管理策略是否允许,再讨论技术方案。 在受管的公司设备上把这条通道挂起来,即使技术上完全跑通,也只是把一个合规问题伪装成了一个工程问题。个人设备、家庭网络,或者自己有权处置的开发机,才是这套方案合适的落点。