开门见山,先给你看那个把我折磨到凌晨三点的报错:
imessage-adapter: request /status failed Error: SyntaxError: Unexpected token 'p', "permission" is not valid JSON at JSON.parse (<anonymous>) at parsePayload (src/adapters/imessage.js:47:17) at dispatchRpc (src/core/bridge.js:132:9) at processTicksAndRejections (node:internal/process/task_queue:78:11)如果只看报错文本,"permission" 不是合法 JSON——这句话本身没毛病,permission确实不是合法的 JSON 文本。但细想一下就会发现这里有个大问题:为什么一个 HTTP 接口返回的是裸字符串 permission,而不是 JSON?我当时的机器人跑在 openclaw 这个本地优先的机器人框架上,通过一个桥接服务去操作 macOS 的 iMessage。整套链路里任何一环都可能出错:权限、网络、消息库、桥接进程。而最后把所有问题都归到一个 JSON.parse 上,这一度让我排查方向跑偏了很久。
这篇文章会把完整过程写出来:从第一次看到报错、到一层层往上摸到桥接端口、再到确认 root cause 和最终的三处代码改动。如果你也玩 openclaw 这类框架,或者正准备给自己的机器人加 iMessage / 短信通道,这应该能帮你省掉一个通宵。哪怕你不玩机器人,这里面关于"内部服务接口也要有契约"的教训,大概率也用得上。
1. 先交代背景:openclaw 的 iMessage 通道是怎么搭起来的
1.1 openclaw 是什么,为什么我选它搭个人助理
openclaw 是一个本地优先的开源机器人框架,核心是一个常驻的 agent 进程,外面套一圈"通道(channel)"适配器——终端、Telegram、Slack、iMessage、短信都可以接入。所有消息进来之后统一转成内部事件,agent 处理后把回复再通过原通道发回去。优势很明显:本地跑、数据不出门、配置简单,而且通道可以热插拔。
我的诉求很具体:一台常驻的 Mac mini 上跑 openclaw,让家里人能通过 iMessage 给我发消息,机器人自动回复一些家庭备忘、定时提醒、快递查询这类私密信息。之所以选 iMessage 而不用其他工具,纯粹是因为家人都在用苹果自带的消息应用,不需要额外安装什么东西。真实需求驱动的选型,往往比技术选型还要让人纠结半天——但这不是今天的重点,跳过。
1.2 iMessage 接入的两种路径,以及我踩进去的那条
macOS 上没有官方公开的 iMessage API。想程序化读写消息,主流做法其实只有两条:
- 直接读数据库 + AppleScript 发送:读取
~/Library/Messages/chat.db(SQLite),通过 AppleScript 操控 Messages.app 发送。问题在于 chat.db 的库表结构每次系统大版本更新都可能变,而且读库需要"完全磁盘访问权限",发送需要"自动化权限",两个权限缺一不可。 - 本地桥接服务:写一个常驻进程,内部用 AppleScript / JXA 与 Messages.app 交互,对外暴露 HTTP 接口。核心进程只需要跟桥接服务通信,权限问题集中在桥接进程上处理。
我选的是第 2 条,也是社区里更稳的一条路。openclaw 的 iMessage 适配器本来就这么设计:核心进程通过GET http://127.0.0.1:8765/status探测桥接状态,通过/send发送消息,通过/listen长轮询收取新消息。协议约定是"所有响应都是 JSON",历史版本也一直是这么实现的。问题恰恰出在这个"约定"上。
1.3 崩溃前我最后一次动过什么
回忆一下崩溃前的操作时间线:
- 晚上 8 点:openclaw 在终端前台跑,iMessage 通道一切正常,家人发消息、机器人正常回复。
- 晚上 10 点:我把 openclaw 和桥接进程改成开机自启(launchd 方式),想让它们脱离终端窗口后台运行。
- 晚上 11 点:系统弹窗提示"某个进程想要控制 Messages.app",我顺手点了允许。
- 凌晨 0 点:重启了一次机器,然后机器人就再也没正常起来过。日志里反复出现的就是开头那行报错。
现在回头看,这个时间线里最关键的是晚上 10 点到 11 点之间发生的事情——进程的运行身份变了,权限环境也随之变了。但当时我完全没往这个方向想,因为我犯了一个经典错误:在终端前台跑通之后,就默认后台跑也一定能跑通。
2. 崩溃现场与第一轮排查:报错本身会说话
2.1 完整错误堆栈和它透露的信息
先放上完整的崩溃输出,逐行看:
imessage-adapter: request /status failed Error: SyntaxError: Unexpected token 'p', "permission" is not valid JSON at JSON.parse (<anonymous>) at parsePayload (src/adapters/imessage.js:47:17) at dispatchRpc (src/core/bridge.js:132:9) at processTicksAndRejections (node:internal/process/task_queue:78:11)拆解一下,这里面有三个关键信息:
- 崩溃发生在
parsePayload,也就是 iMessage 适配器里对 HTTP 响应体做JSON.parse的那一行。 dispatchRpc说明是内部 RPC 调用的统一入口,也就是说任何桥接接口的响应都会走同一个 parse 逻辑。- Node 的报错信息里直接带了原始 payload:
"permission"是没带引号的裸字符串。
第三点尤其重要。Node 报"permission" is not valid JSON的时候,permission就是服务端真正返回的文本,一字不差。也就是说我根本不需要猜响应内容,报错已经告诉我桥接进程返回了一个裸的permission。但当时我被"JSON 解析失败"这几个字带偏了,一直在想"返回的数据怎么会是 permission?哪个字段?"——方向完全跑偏。
2.2 第一轮排查:我做的那些"正确的无用功"
拿到堆栈之后,我按常规套路排查了一圈,全部无功而返:
- 先看桥接进程还在不在:
ps aux | grep imessage-bridge,进程活着,端口也在监听。 - 再试重启大法:桥接、openclaw 依次重启,报错依旧,而且是启动后立刻报,不是运行一段时间才报。
- 怀疑是编码问题:网上有人说 JSON.parse 对中文或特殊字符会敏感。但报错文本是纯 ASCII 的
permission,这条路显然不对。 - 关掉其他通道:把 Telegram、Slack 的配置全部注释掉,只留 iMessage,照样崩。这说明问题不在通道之间的互相干扰。
这一圈下来,唯一确定的结论是:只要 openclaw 核心进程向桥接进程发起第一次 RPC 请求,响应体里就含有一个裸字符串 permission,然后 JSON.parse 必然崩溃。但我仍然不知道这个 permission 从哪来、为什么来。下一轮排查必须上工具了。
2.3 在适配器里加日志:看到原始响应体
既然堆栈指向parsePayload,我直接打开src/adapters/imessage.js第 47 行。代码长这样:
async function parsePayload(res) { const body = await res.text(); return JSON.parse(body); // <- 第 47 行 }没有 try-catch,没有任何前置校验,拿过来就 parse。我在这行前面临时加了一句日志:
async function parsePayload(res) { const body = await res.text(); console.error('[DEBUG] raw bridge response:', JSON.stringify(body)); return JSON.parse(body); }重启,崩溃,日志输出:
[DEBUG] raw bridge response: "permission"确认了:HTTP 响应体就是一个字符串permission。接下来要查的就是——桥接进程在什么情况下会返回这个字符串。
提示:排查这类问题,第一件事永远是看原始响应。不管是接口调试还是第三方对接,拿到 body 原始内容的日志,比你脑补一百种可能性都有用。这一步建议做成永久日志而不是临时调试代码,后面会细说。
3. 顺着数据链路上溯:从核心代码摸到桥接端口
3.1 用 curl 直连桥接服务,拿到第一手响应
适配器加日志只能证明"核心收到了什么",但还不能证明"桥接主动返回了什么",因为中间可能还有代理、拦截、重定向之类的东西。我直接用 curl 绕过整个 openclaw,单测桥接服务:
$ curl -i http://127.0.0.1:8765/status HTTP/1.1 403 Forbidden Content-Type: text/plain; charset=utf-8 permission这下水落石出了一半。桥接服务对/status的响应是:
- 状态码
403 Forbidden Content-Type: text/plain- 响应体是裸字符串
permission
注意 HTTP 状态码和 Content-Type 其实已经提前剧透了:这不是一个正常的数据响应,而是一个权限拒绝信号。但"信号"和"数据"是两回事,桥接进程的作者把权限拒绝写成了一个纯文本字符串,而 openclaw 核心这边把所有响应统一当 JSON 解析,于是炸了。
这种"一边用状态码表达语义,一边用文本表达语义,另一边却只认 JSON"的错位,就是整个事故的结构性根源。
3.2 桥接源码里的分叉逻辑:TCC 权限检查
接着我去翻桥接服务的源码,很快找到了/status路由的实现。简化后的核心逻辑:
app.get('/status', (req, res) => { const authorized = checkAutomationPermission(); if (!authorized) { res.status(403).send('permission'); // <- 就是它 return; } res.json({ ok: true, data: { ready: true, messages: [] } }); });checkAutomationPermission()底层调的是 AppleScript 对Messages.app做一次最小操作,如果系统不允许,就返回 false。而桥接服务在 false 的情况下用了res.send('permission')这个快捷方式——res.send遇到字符串参数时,默认就是text/plain,所以变成了裸文本。
作者当时大概是这样想的:"权限没通过,我返回一个明确的字符串 permission 告诉上游,你们自己去处理。" 但问题在于,上游根本没有处理非 JSON 响应的代码路径。于是这个用心良苦的字符串,在进入 JSON.parse 的那一刻,从"信号"变成了"炸弹"。
3.3 为什么之前"没触发":进程身份与 TCC 权限的关系
回到最困惑的问题:为什么在终端前台跑的时候好好的,改成开机自启之后就崩了?
这需要理解 macOS 的 TCC(Transparency, Consent, and Control)权限机制。TCC 授权不是给"进程"的,而是给"具有签名标识的进程"的。Messages.app的自动化授权,记录的是"哪个程序被允许控制 Messages.app"。
- 我从终端里跑桥接进程时,实际发起请求的是终端进程(或者说通过终端继承的上下文),系统弹窗询问的是"终端想要控制 Messages.app",我点了允许,所以终端跑的时候没问题。
- 改成 launchd 开机自启后,桥接进程由 launchd 拉起,上下文变成了 launchd 派生的独立进程,它没有继承终端的 TCC 授权。虽然我之前在系统弹窗里点了允许,但那个允许是给终端的,不是给这个新的守护进程的。
- 最迷惑的一点是:桥接进程本身能启动、能监听端口,只是每次尝试控制 Messages.app 时被 TCC 拦截,返回未授权。表面上"进程活着",实际上"功能全盲"。
这个机制,不踩一次是真的记不住。TCC 的授权粒度、继承关系、弹窗归属,每一项都能成为深夜排错的素材。
4. 根因确认:这不是 JSON 解析 bug,是"协议失衡"bug
4.1 完整因果链,一条条列出来
当我同时看到 core 的 parsePayload、桥接的res.send('permission')、以及 TCC 授权变化之后,root cause 就非常清晰了。完整链条如下:
- 我把桥接进程从终端前台改成 launchd 后台运行,导致进程未获得 TCC 自动化授权。
- 桥接进程每次探测权限都失败,于是对所有请求返回
403 text/plain: permission。 - openclaw 的 iMessage 适配器对任何响应体直接
JSON.parse,没有任何守卫。 JSON.parse('permission')抛SyntaxError,适配器崩溃,机器人整个退出。
表面上是第 4 步的 JSON 解析 bug,实际是第 2 步和第 3 步之间的协议失衡:桥接端的错误约定是"用裸字符串当信号",核心端的假设是"永远是 JSON"。任何一方都不算错,但两边凑在一起就是事故。
这也是为什么我会说"不要相信任何看似是 JSON 的返回值"——准确地说,不要相信任何'看起来应该是什么'的返回值。内部接口的脆弱,往往是约定只存在于开发者脑子里,没有存在于代码里。
4.2 为什么报错信息如此具有迷惑性
SyntaxError: Unexpected token 'p', "permission" is not valid JSON这句报错,最大的迷惑性在于它让你把思考焦点放在"permission 是不是合法 JSON"上,从而忽略了更本质的问题:"为什么服务器会返回一个非 JSON 的文本?"
排错的时候,报错信息是望远镜也是墨镜。它指向的直接原因(JSON.parse 解析失败)是对的,但如果你只盯着直接原因,就容易在数据格式层面打转——比如去研究 permission 这个词是不是关键字、是不是要转义、是不是要加引号——而忘了往上一层问:"这个响应来自哪个分支?这个分支的语义是什么?"
正确姿势是:站在直接原因之上,沿着数据流向,回到产生响应的那个源头,看作者在什么条件下写了这个返回。报错信息告诉你的是"哪里炸了",不一定告诉你"为什么会有这个东西",后者必须靠读代码链路。
4.3 顺手排查同类隐患:全项目搜裸字符串返回
既然桥接服务能用permission当信号,它就很可能在别的地方也用同类方式。我在桥接服务源码里 grep 了一下:
grep -rn "res.send(" src/结果发现了另外三个类似的裸字符串返回:
| 分支 | 返回内容 | 潜在后果 |
|---|---|---|
| 权限未授权 | permission | JSON.parse 直接崩溃 |
| 请求被过滤 | denied | JSON.parse 直接崩溃 |
| 设备不受信任 | untrusted | JSON.parse 直接崩溃 |
| 资源不存在 | not_found | JSON.parse 直接崩溃 |
四个分支,任何一个被触发,openclaw 核心进程都会以同样的方式崩溃。也就是说,我这次踩到的只是其中一个分支,如果没有系统性排查,修好permission之后很可能过几天又被denied带走。修 bug 不修同名类 bug,是排错过程中最亏的一件事。
5. 修复与实践:三处改动让机器人从此不再裸奔
5.1 第一处:桥接端返回结构化 JSON,不再用裸字符串当信号
最本质的修复在桥接端:所有响应必须严格遵循"JSON 协议"约定,错误也要给结构化的错误对象。我把那四个裸字符串分支统一改成:
app.get('/status', (req, res) => { const authorized = checkAutomationPermission(); if (!authorized) { res.status(403).json({ ok: false, error: { code: 'PERMISSION_DENIED', message: 'iMessage 桥接进程未被授权,请在 系统设置 -> 隐私与安全性 中检查自动化权限' } }); return; } res.json({ ok: true, data: { ready: true } }); });同理,denied、untrusted、not_found三个分支全部改成{ ok: false, error: { code: ..., message: ... } }的结构。这样不管哪个分支被触发,核心端拿到的都是合法 JSON,至少不会再因为语法错误崩溃。
另外我顺手把状态码语义也梳理了一遍:权限类问题一律 403,资源不存在用 404,参数错误用 400,正常用 200。状态码和 JSON 双重表达,比之前"状态码靠感觉、响应体看心情"的写法健壮得多。
5.2 第二处:核心端对响应做防护性解析,JSON.parse 必须放在守卫后面
桥接端修好了,只解决"信号是合法 JSON"的问题,还没解决"核心端太脆弱"的问题。如果未来某个接口又返回了意外格式(比如上游改版、网络中间层注入的 HTML、代理返回的错误页),核心还是要崩。所以第二处改动在 openclaw 的parsePayload:
function parsePayload(raw) { const body = typeof raw === 'string' ? raw.trim() : String(raw || ''); if (!body) { return { ok: false, error: { code: 'EMPTY_RESPONSE', message: 'bridge returned empty response', raw: body } }; } if (!body.startsWith('{') && !body.startsWith('[')) { // 非 JSON 响应,不再抛异常,而是转成结构化错误 return { ok: false, error: { code: 'NON_JSON_RESPONSE', message: `bridge returned non-json response: ${body.slice(0, 200)}`, raw: body } }; } try { return JSON.parse(body); } catch (err) { // 可能是半截 JSON、多字节被截断等情况 return { ok: false, error: { code: 'JSON_PARSE_FAILED', message: err.message, raw: body } }; } }改动思路很朴素:JSON.parse 永远不直接执行,前面必须有数据形态守卫,后面必须有 try-catch。非 JSON 响应不要抛异常,而是转成结构化错误对象,这样上游可以走正常的错误分支,而不是崩溃退出。
5.3 第三处:让"权限未授权"被正确识别,并给出可操作的引导
前两处改动解决了"不崩溃",但还没解决"体验好"。如果机器人只是不崩溃,却永远静默地回一句"处理失败",那跟崩溃也没太大区别。所以第三处改动在核心的 iMessage 适配器里,专门识别PERMISSION_DENIED这个错误码:
if (payload && payload.error && payload.error.code === 'PERMISSION_DENIED') { logger.warn(`[imessage] bridge not authorized: ${payload.error.message}`); // 不退出进程,标记通道不可用,等待授权后自动重试 channel.state = 'pending_permission'; channel.retryDelay = 15_000; return; }效果是:授权缺失时,机器人进程保持存活,启动日志里有一行清楚的警告;我修好 TCC 授权并重启桥接进程后,最多 15 秒,通道会自动恢复。pending_permission状态也可以用专门的状态机逻辑来驱动重试,避免每次启动都崩、每崩一次都要手动重启的窘境。
5.4 验证过程:模拟权限拒绝、真实授权、回归测试
改完代码,验证必须覆盖三条路径,缺一不可。
先模拟权限拒绝。最简单的方式是直接停掉桥接服务,让核心拿到ECONNREFUSED;再配合一个"假 bridge"脚本,在不同 URL 上按需返回各种格式的响应,测试核心对permission、denied、HTML、半截 JSON 的处理。实测下来:
- 裸字符串
permission:不再崩溃,日志输出NON_JSON_RESPONSE,进程存活。 {"ok":false,"error":{...}}:走结构化错误分支,正确识别PERMISSION_DENIED。- 半截 JSON(
{"ok": tr):走JSON_PARSE_FAILED,进程存活。 - 正常 JSON:正常解析,功能不受影响。
再走真实授权流程。我在 macOS 的"系统设置 -> 隐私与安全性 -> 自动化"里,给新的后台桥接进程勾选了控制 Messages.app 的权限。重启桥接服务,观察 openclaw 日志,通道从pending_permission变成ready,然后我用手机给机器人发了一条 iMessage,机器人正常回复。这一刻比什么都治愈。
最后是回归测试:把其他通道配置加回来,多通道并发跑了一遍,确认改动只影响桥接响应解析,不影响其他通道。
6. 事后复盘:这次踩坑教会我的四件事
6.1 内部接口也要有"协议契约",哪怕只有两个进程
这次事故最根上的教训,不是 JSON.parse 没有 try-catch,而是内部接口之间默认彼此信任、不做契约校验。桥接进程和核心进程都是我自己在维护,但它们其实是两个独立的软件组件,有各自的发布节奏和作者(哪怕作者都是自己)。任何两个组件之间的接口,都应该像外部 API 一样有明确的契约:响应格式、状态码语义、错误结构、Content-Type。
我后来做了一个很简单的约束:两个服务的所有响应体必须满足同一个 JSON Schema,CI 里加了一个基础校验脚本,核心端每次解析前先做 shape 校验。投入不到半天,换来的是一整类"格式错位"bug 不会再犯。
6.2 JSON.parse 永远要放在守卫后面,没有例外
JSON.parse是一个"要么成功、要么抛异常"的函数,但它不会告诉你"这不是 JSON",只会抛一个语法错误。你的代码里只要出现裸的JSON.parse(x),就必然存在一个崩溃路径。唯一正确的姿势是:
- 先判断类型和形态(是不是字符串、是不是以
{或[开头); - 再 try-catch 包住 parse;
- 最后对解析结果做结构校验(有没有
ok字段、有没有error字段)。
这个规则对任何语言都适用。我在其他项目里也顺手把同样的模式应用到了所有第三方接口调用上,效果立竿见影。
6.3 错误信息里必须带上原始报文
排错最痛苦的事情,就是你拿到的错误信息里没有"现场证据"。Node 的JSON.parse报错还算良心,会把原文贴进Unexpected token 'p', "permission" is not valid JSON,但有些情况下原文会很长,或者被框架吞掉。我现在的做法:任何网络请求的错误对象里,必须包含raw字段,存原始响应体的截断版本。这样无论什么时候出问题,日志里都能看到"到底收到了什么",而不是只有一句"parse failed"。
6.4 接入新通道时的检查清单
最后分享一份我整理给自己用的检查清单,每次接入新通道(不只是 iMessage)都会过一遍:
- 桥接/适配进程的运行身份是什么?是前台终端、后台 daemon、还是容器?对应的权限(TCC、密钥、环境变量)是否都齐了?
- 非 2xx 响应长什么样?是结构化错误、裸字符串、还是空 body?
- 空响应、超时、进程崩溃这三种异常路径,核心进程能不能存活?
- 授权变更之后,是否需要重启才能生效?恢复路径是自动还是手动?
- 是否有一个"假上游"可以随时模拟各种异常返回,用来做回归测试?
其中第 2 条和第 5 条,就是这次事故的核心。如果没有假上游,我很难在真实授权之外,快速验证permission、denied、HTML 注入、半截 JSON 这些边界情况。这半天的投入,换来了所有未来通道的稳定性。
最后再分享一点个人感受:这次事故里最讽刺的是,我一开始怪 openclaw 的 JSON.parse 为什么这么蠢,后来才发现,真正蠢的是我自己——在终端里跑通了就以为万事大吉,完全没想过"换一种启动方式,权限环境就彻底变了"。macOS 的 TCC 权限不会继承给 launchd 派生的新进程,这是系统机制,不是框架 bug。现在每次改完部署方式,我都会先查一遍运行身份的权限矩阵,再也不会让 "permission" 这个老朋友半夜来找我了。