阅读视图

发现新文章,点击刷新页面。

Tauri 2 Linux 上 asset://localhost 访问返回 403 避坑指南

很多人在 Tauri v2(尤其是 Linux 系统)中使用 convertFileSrc()asset://localhost 协议加载本地图片、视频、音频等资源时,经常遇到 403 Forbidden 错误。Windows/macOS 可能正常,Linux 却直接翻车。

本文把整个坑的来龙去脉、根本原因、glob 匹配规则彻底讲清楚,并给出最稳的配置方案,帮助大家一次性避坑。

一、问题现象

  • 使用 convertFileSrc(fullPath) 生成的 URL 在 <img><video><audio> 等标签中加载失败
  • 浏览器控制台报 403
  • 终端(Rust 侧)日志提示类似:
    asset protocol not configured to allow the path: /home/user/.local/share/xxx/xxx.png
    
  • 尤其容易出现在 隐藏目录(以 . 开头的目录)下:.local/share.cache.config

二、根本原因:Tauri 的 Glob Scope + Linux 隐藏目录规则

Tauri v2 的 assetProtocol.scope 使用的是 Rust globset 库实现的 glob 模式来做安全校验。只有路径匹配 scope 里的 glob,才允许浏览器通过 asset 协议访问。

最坑的一点在于,Linux(Unix-like 系统)下:

通配符 *?**默认不会匹配以 . 开头的路径(dotfiles / dotdirs),除非你在 glob 模式里字面写出 .

所以即使你写了最宽松的 "**/*",它也进不了 .local.cache 等隐藏目录,导致 403。

这不是 bug,而是 Tauri 为了安全故意设计的(和 Linux shell 的 ls * 默认不显示隐藏文件一样)。

三、Glob 模式最容易搞混的两个写法:**/ vs **/*

glob 写法 含义 能匹配什么 在 assetProtocol.scope 里的实际效果 推荐程度
**/* 递归匹配所有文件 文件(如 a.pngsub/b.mp4 ✅ 强烈推荐 ★★★★★
**/ 递归匹配所有目录 纯目录路径(如 images/sub/ ❌ 几乎没用(scope 要的是文件路径) ★☆☆☆☆

一句话总结

  • **/* = “递归所有文件”(你 99% 的情况都需要这个)
  • **/ = “递归所有目录”(基本不要单独写在 scope 里)

正确写法是 你的路径/**/* 或直接 **/*

四、正确配置(一步到位)

1. 主配置(推荐同时加 Linux 专属配置)

src-tauri/tauri.conf.json(全局):

{
  "app": {
    "security": {
      "csp": "default-src 'self' ipc: http://ipc.localhost; img-src 'self' asset: http://asset.localhost; video-src 'self' asset: http://asset.localhost; audio-src 'self' asset: http://asset.localhost; style-src 'self' 'unsafe-inline';",
      "assetProtocol": {
        "enable": true,
        "scope": [
          "**/*",
          "**/.local/share/**/*",
          "**/.cache/**/*",
          "$CACHE/**",
          "$CONFIG/**",
          "$HOME/**"
        ]
      }
    }
  }
}

src-tauri/tauri.linux.conf.json(Linux 专属,强烈建议):

{
  "app": {
    "security": {
      "assetProtocol": {
        "enable": true,
        "scope": [
          "**/*",
          "**/.local/share/**/*",
          "**/.cache/**/*",
          "$CACHE/**",
          "$CONFIG/**"
        ]
      }
    }
  }
}

这样 Windows/macOS 不会被多余的 scope 影响。

2. 代码侧使用(不变)

import { convertFileSrc } from '@tauri-apps/api/core';

const assetUrl = convertFileSrc(absoluteFilePath);

五、操作流程

  1. 按上面修改配置文件
  2. (推荐)cargo clean
  3. pnpm tauri dev(或 npm run tauri dev)测试
  4. 还是 403?看终端日志,把报错里提示的路径对应的 glob 补进去

六、额外避坑小贴士

  • 用 Tauri 内置变量 $CACHE$CONFIG 最香,自动处理平台差异
  • 如果是用户通过 dialog.open() 选择的路径,Tauri 会自动扩展 scope,但持久化路径仍需写进配置
  • 打包进 bundle 的资源不需要 assetProtocol,走 frontendDist 即可
  • Rust 版本建议 ≥ 1.77,Tauri CLI 保持最新

总结
Tauri 2 的 asset 403 坑,99% 是因为 Linux 下 glob 默认不匹配 . 开头的隐藏目录。只要把 **/* + **/.local/share/**/* + $CACHE/** 写全,问题基本秒解。

把这篇配置直接复制到你的项目里,基本不会再踩这个坑了。

希望这篇文章能帮到更多 Tauri 开发者少走弯路!
如果你还有其他 Tauri v2 的奇葩问题,欢迎继续留言~

一套面向 Web、H5、小程序与 Flutter 的多端一致性技术方案

在很多团队里,多端协作的主要问题是“同一个需求被翻译了多少次”。最典型的场景是:Web 一套实现,Flutter 一套实现,H5 和小程序又各有一套适配逻辑。产品提一个需求,设计讲一遍,前端理解一遍,Flutter 再理解一遍,最后各端虽然都“做出来了”,但视觉、交互、状态处理、权限逻辑、埋点口径往往并不一致。沟通成本高、返工频繁、质量不稳定。

如果团队还希望进一步引入 AI 辅助开发,那么问题会更明显。因为 AI 并不能天然理解团队的设计语言、组件规范、页面模式和业务边界。如果没有一套结构化、可检索、可校验的规范体系,大模型生成的结果往往只能做“演示代码”,无法真正进入工程体系。

因此,真正有效的多端技术方案,不应该只是“做一套 Design Token”,也不应该只是“尝试一套代码通吃所有端”。更合理的思路是: 先统一设计语言,再统一组件协议和页面模式,继续沉淀业务规范,最后把这些规范结构化,供大模型参与生成、校验和辅助开发。 规范不是靠发文件落地的,是靠‘让别人用起来更省事’落地的。

一、问题到底出在哪里

很多团队一提多端一致性,第一反应是颜色、字号、间距不一致。但这其实只是表层问题。真正让协作成本变高的,通常是下面几类问题。

第一类,是需求翻译成本。同一个“企业洞察页”需求,Web 理解为信息卡 + 图表 + 推荐列表,Flutter 可能理解为信息页 + Tab + 卡片流,结果做出来像两个产品。

第二类,是组件行为不一致。同样一个按钮,Web 支持 loading 态并禁止重复点击,Flutter 可能只有 disabled;同样一个空态页,Web 有引导操作,Flutter 只有一段提示文案。

第三类,是状态模型不一致。正常态大家都能做,但 loading、empty、error、no-permission、offline、partial-error 这些状态,经常每个端各自发挥。

第四类,是数据与规则不一致。接口字段解释不同,权限判断方式不同,埋点参数命名不同,最后统计口径都不一样。

第五类,是AI 无法真正接入工程体系。设计规范写在 PPT 里,组件约定写在 Confluence 里,业务规则散落在需求文档里,大模型拿不到稳定的 source of truth,自然无法参与受控生成。

所以,多端一致性不是一个“视觉层优化”问题,而是一个从需求到实现的翻译层重构问题

二、方案总览:统一语义,而不是强行统一实现

这套方案的核心原则很简单:

统一语义,不强行统一实现。

Web、H5、小程序、Flutter 的渲染机制、组件生态和交互能力都不一样,硬要一套代码跑所有端,通常只会让所有端都不舒服。真正应该统一的,是下面这些层:

  1. Design Token:统一设计语言
  2. 组件 Contract:统一组件语义和行为
  3. 状态矩阵与页面 Pattern:统一交互模式
  4. 业务 Spec / Contract / Schema:统一需求表达
  5. 工程化与校验链路:统一交付方式
  6. Agent 接入层:统一 AI 辅助方式

可以把整个方案理解成这样一条链路:

设计源头 → Token → 组件协议 → 页面模式 → 业务规范 → 结构化规则 → 多端实现 → AI 生成与校验

这不是单点优化,而是一套可以逐步演进的前端基础设施。

三、Design Token 是基础,但不能停在这里

Design Token 解决的是“设计值如何被标准化和跨端传递”的问题。它是多端一致性的起点,但绝不是终点。

在实现上,我推荐把 Token 分成三层:

1. Primitive Token:原始值层

这是最底层的设计原材料,比如:

{
  "color": {
    "blue": { "500": "#2F6BFF" },
    "gray": { "900": "#111827" }
  },
  "space": {
    "8": "8px",
    "16": "16px"
  },
  "radius": {
    "4": "4px"
  }
}

这一层回答的是“具体数值是多少”,适合设计系统维护者和构建脚本使用,不应该直接暴露给业务页面。

2. Semantic Token:语义层

语义层把原始值转成产品语言,例如:

{
  "color-text-primary": "{color.gray.900}",
  "color-bg-surface-card": "{color.white}",
  "space-page-section-gap": "{space.16}"
}

这一层回答的是“这个值在界面里扮演什么角色”。它是跨端最值得统一的一层,也是页面开发默认应该使用的一层。

3. Component Token:组件层

组件层是组件内部的状态和部位规则,例如:

{
  "button-primary-bg-default": "{color-bg-brand-primary}",
  "button-primary-bg-hover": "{color.blue.600}",
  "input-border-focus": "{color-border-focus}"
}

这一层回答的是“某个组件在某种状态下应该怎么表现”,适合组件库内部实现,不建议业务页面直接使用。

为什么要这么分层

因为业务页面真正关心的是“主文本”“卡片背景”“页面间距”,而不是 blue-500button-primary-bg-hover 这种底层细节。

所以一个健康的约束应该是:

  • 定义基础值,用 Primitive
  • 写页面布局,用 Semantic
  • 做组件实现,用 Component

对于多端来说,最应该统一的是 Semantic Token 命名和语义,而不是强求每个平台完全共享底层实现。Web 可以映射到 CSS Variables,Flutter 可以映射到 ThemeData,小程序可以做裁剪版映射。统一的是产品语言,不是渲染机制。

四、比 Token 更重要的是组件 Contract

如果说 Token 解决的是“长得像不像”,那么 Component Contract 解决的是“行为是不是同一套”。

这里的 contract,是组件层面的“协议”或“契约”。它定义的是一个组件的输入、输出、状态、行为和边界。

以 Button 为例,一个合格的 Button contract 至少应该包含这些内容:

  • 支持哪些 variant:primary / secondary / text
  • 支持哪些尺寸:sm / md / lg
  • 支持哪些状态:default / loading / disabled
  • loading 态是否禁止再次点击
  • icon 支持哪些位置
  • 文案最大长度建议
  • 埋点何时触发
  • 无障碍要求是什么

它可以写成这样的结构:

{
  "component": "Button",
  "variants": ["primary", "secondary", "text"],
  "sizes": ["sm", "md", "lg"],
  "states": {
    "loading": { "disableClick": true },
    "disabled": { "emitClick": false }
  },
  "iconPlacement": ["left", "right"],
  "a11y": {
    "requireLabel": true
  }
}

一旦这份 contract 稳定下来,Web 和 Flutter 都可以按同一套语义实现,而不是各写各的。

这一步非常关键。因为很多团队做了 Token,却没有做 Contract,结果所有端看起来像一家人,但行为逻辑还是各自为战。

五、状态矩阵与页面 Pattern 才是多端协作的真正降本点

大部分返工,并不是因为某个颜色错了,而是因为状态和页面结构没有统一

1. 状态矩阵

在 AI 产品和复杂 B 端产品里,页面状态往往远不止一个“加载中”。一个成熟的状态模型,至少要覆盖:

  • loading
  • refreshing
  • empty
  • partial-error
  • full-error
  • no-permission
  • offline
  • generating
  • interrupted

状态矩阵不是为了写文档,而是为了让所有端都知道:同一个页面在不同状态下应该展示什么、隐藏什么、保留什么交互、是否允许重试。

2. 页面 Pattern

很多需求其实不是全新页面,而是“列表页”“详情页”“洞察页”“趋势页”“报告编辑页”“智能体配置页”的某种变体。

所以与其每次从零设计,不如沉淀页面 pattern library。每个 pattern 里定义:

  • 页面区块组成
  • 信息优先级
  • 推荐组件组合
  • 常见交互
  • 状态切换方式
  • 多端适配建议

例如“企业洞察页”可以规定:

  • 顶部:企业基础信息卡
  • 中部:趋势图 + 风险摘要
  • 底部:相关推荐
  • 高风险摘要必须带引用来源
  • 导出按钮仅分析师可见
  • loading 时骨架屏优先展示顶部和中部关键区域

一旦 pattern 稳定,同一个需求在 Web 和 Flutter 上的“翻译成本”就会下降很多。

六、把需求变成结构化规范,而不是散落的文档

到这里,多端协作已经不只是 UI 层问题了,必须往更高层抽象。

这里最容易混淆的三个概念是:spec、contract、schema

1. Spec:整体规格说明

Spec 关注的是“这个需求整体要做成什么样”。它通常包括:

  • 页面目标
  • 页面结构
  • 交互流程
  • 状态处理
  • 权限规则
  • 埋点要求
  • 验收标准

Spec 是完整说明书,适合给产品、设计、前后端和测试一起看。

2. Contract:边界约定

Contract 关注的是“边界两边如何对接”。它包括:

  • API contract
  • 组件 contract
  • 事件埋点 contract
  • 页面区块 contract

它强调的是输入、输出、状态、行为和边界。

3. Schema:数据结构定义

Schema 关注的是“数据长什么样”,比如:

  • 字段名
  • 字段类型
  • 必填项
  • 枚举值
  • 嵌套关系

例如:

{
  "companyName": "string",
  "riskLevel": "low | medium | high",
  "canExport": "boolean",
  "tags": ["string"]
}

在工程里,可以粗略理解为:

Schema 是结构,Contract 是约定,Spec 是全局规则。

三者一起使用,才能真正把需求从“会讨论”变成“可执行”。

七、工程体系的重点不是复用代码,而是复用定义

到了工程层,很多团队会陷入一个误区:一说多端,就想“一套代码跑全部端”。

实际上,对 Web 和 Flutter 这样的异构平台来说,更现实的目标不是复用所有代码,而是复用定义、约束和生成链路

一个比较健康的工程目录,可以是这样:

design-system/
  tokens/
    primitive.json
    semantic.json
    component.json
  components/
    button.contract.json
    card.contract.json
    input.contract.json
  patterns/
    insight-page.spec.md
    report-editor.spec.md
  business-rules/
    permission.rules.json
    tracking.rules.json
  platform-mappings/
    web/
      tokens.css
    flutter/
      theme_mapping.dart
    mini-program/
      token_mapping.json

在这套结构里,Web 和 Flutter 不一定共享组件代码,但它们共享:

  • 设计语言
  • 组件协议
  • 页面模式
  • 业务规则
  • 类型定义
  • 校验标准

这样真正减少的,不是“写代码的次数”,而是“需求被重复翻译的次数”。

八、最后才是把规范结构化,供大模型参与生成和校验

很多团队在引入 AI 时,最容易犯的错误是:一上来就希望大模型“自动生成页面”。但如果前面的规范体系还没建立好,这种生成只能停留在 demo 层面。

更现实的路径应该是:

第一步,让 Agent 先读规范,而不是先写代码

Agent 不应该靠一大段 prompt 去猜团队规范,而应该按需读取:

  • Semantic Token
  • Component Contract
  • Page Pattern
  • Business Rules
  • Schema

第二步,让 Agent 先做受控生成

最适合 AI 先接入的场景包括:

  • 页面骨架生成
  • 表单 / 列表 / 详情区 schema 驱动生成
  • TS types / Dart models 自动生成
  • 埋点 / 权限 / 状态处理检查
  • 组件使用规范检查

第三步,再做确定性校验

大模型适合生成,但最终质量不能靠模型“自觉”。要把关键规则做成 validator:

  • 是否用了非法 token
  • 是否绕过组件库直接写样式
  • 是否缺少 loading / empty / error
  • 是否遗漏权限态
  • 是否漏了埋点
  • 是否违反页面 pattern

所以 AI 在这套体系里的位置,不是“替代开发”,而是:

基于结构化规范做受控生成,再基于确定性规则做质量校验。

九、落地路径:不要一上来就做大一统

这套方案看起来大,但完全可以分阶段推进。

第一阶段:先统一最基础的三件事

  • 基础 Design Token
  • 高复用组件 Contract
  • 接口模型与类型自动生成

这一阶段的目标不是“多先进”,而是先把最容易反复沟通的部分统一起来。

第二阶段:沉淀页面 Pattern 和状态矩阵

挑选高频页面类型,比如列表页、详情页、洞察页、配置页,把交互模式和状态处理收敛起来。

第三阶段:开始结构化业务规范

把 Spec、Contract、Schema 分层管理,逐步形成 source of truth。

第四阶段:引入 Agent 和 Validator

先让 AI 参与骨架生成和规则检查,再逐步扩大到辅助开发。

这条路径的好处是:每一步都能独立产生价值,而不是必须一次性完成一个庞大的“平台化工程”。

这套方案最终解决了什么

如果这套体系跑起来,真正被降低的不是代码行数,而是以下几类成本:

  • 同一个需求在多端之间的翻译成本
  • 视觉、行为、状态不一致带来的返工成本
  • 接口、权限、埋点理解不一致的沟通成本
  • 新人接手复杂项目时的理解成本
  • AI 生成无法进入正式工程体系的失控成本

换句话说,它把“多端协作”从一种靠人力补位的模式,变成一种靠规范驱动、工程约束和 AI 辅助的模式。

使用micro-app 多层嵌套的问题

micro-app 多层嵌套问题解决方案

版本说明:本文讨论的 micro-app 版本为截止发稿日期的最新版 1.0.0-rc.27

一、问题背景

1.1 业务场景

在实际开发中,我们遇到了一个三层嵌套的微前端场景:

基座应用 → 中间应用 → 子应用
  • 技术栈:Vue 3 + Vite
  • 架构层级:三层嵌套结构
  • 业务需求:中间应用和子应用需要进行频繁的数据交互的场景

1.2 官方文档说明

micro-app 官方文档针对 Vite 项目给出了使用 iframe 模式的建议: image.png 官方文档虽然提到了支持多层嵌套,但并未给出具体的实现示例和注意事项: image.png

1.3 问题现象

当中间层应用使用 iframe 模式时,第三层子应用会出现**栈溢出(Stack Overflow)**错误:

Maximum call stack size exceeded

image.png

这个问题在 GitHub Issues 中也有多人反馈,但官方尚未给出明确的解决方案。


二、问题原因分析

2.1 根本原因

经过深入分析和测试,问题的根本原因如下:

  1. 资源查找机制问题:当基座应用和中间层应用都启用 iframe 模式后,第三层子应用在查找 iframe 标签资源时,会向上查找父级应用。

  2. 循环查找导致栈溢出

    • 第三层应用向上查找时,找到的是基座应用而非中间层应用
    • 基座应用再次下发资源
    • 第三层应用继续向上查找
    • 形成无限循环,最终导致栈溢出
  3. iframe 标签的资源查找逻辑:micro-app 在处理 Vite 项目的 iframe 模式时,资源查找机制在多层级嵌套场景下存在缺陷。

2.2 测试验证

我们对不同技术栈和框架进行了测试,测试结果如下: image.png

基座应用 中间应用 子应用 是否出现栈溢出
Vite + iframe Vite + iframe Vite ❌ 是
Vite + iframe Vite + iframe Webpack ❌ 是
Vite + iframe Webpack Vite ✅ 否
Vite + iframe Webpack Webpack ✅ 否

结论:不论第三层使用什么技术栈,只要第二层(中间应用)使用了 iframe 模式,就会出现栈溢出问题。


三、解决方案

方案一:使用原生 iframe 标签(不推荐)

实现方式

第三层子应用使用原生的 `` 标签,而不是 micro-app 标签。

优点
  • ✅ 完全避免栈溢出问题
  • ✅ 实现简单,无需额外配置
缺点
  • ❌ 失去了 micro-app 的所有优势(样式隔离、JS 沙箱、通信机制等)
  • ❌ 需要重新实现微前端的各种能力
  • ❌ 与现有架构不兼容,需要大量改造工作
  • ❌ 性能较差,用户体验不佳
适用场景

仅适用于对微前端能力要求不高的简单嵌入场景。 不需要频繁的进行数据交互及ui风格统一等。


方案二:中间层不使用 iframe 模式(不推荐)

实现方式

中间层应用不使用 iframe 模式,改用 Webpack 构建或其他方式。

优点
  • ✅ 可以避免栈溢出问题
  • ✅ 保持 micro-app 的完整能力
缺点
  • ❌ 需要将 Vite 项目改回 Webpack,技术倒退
  • ❌ 失去 Vite 的快速构建和开发体验
  • ❌ 不符合当前主流技术趋势
  • ❌ 团队需要重新学习 Webpack 配置
适用场景

仅适用于可以接受技术栈变更的项目。


方案三:第三层使用基座应用的标签(推荐⭐)

这是本文重点推荐的解决方案,通过让第三层子应用直接使用基座应用的 micro-app 标签,绕过中间层的资源查找问题。

3.1 核心思路
  • 第三层子应用不再通过中间层应用加载
  • 直接使用基座应用的 micro-app 标签进行渲染
  • 通过基座应用实现中间层和子应用之间的通信
3.2 实现步骤
步骤一:将基座应用的 micro-app 挂载到全局

在基座应用中,将 micro-app 实例挂载到全局对象,以便子应用能够访问:

// 基座应用:main.js 或 bootstrap.js
import microApp from '@micro-zoe/micro-app';

// 权限校验函数(可选)
function accessMicroAppName(appName) {
  // 根据业务需求实现权限校验逻辑
  // 例如:检查当前子应用是否有权限访问指定的子应用
  return true;
}

// 将 micro-app 方法挂载到全局
window.microApp = {
  setData(...args) {
    if (!accessMicroAppName(args[0])) {
      return;
    }
    microApp.setData(...args);
  },

  addDataListener(...args) {
    if (!accessMicroAppName(args[0])) {
      return;
    }
    microApp.addDataListener(...args);
  },

  getData(...args) {
    if (!accessMicroAppName(args[0])) {
      return null;
    }
    return microApp.getData(...args);
  },

  removeDataListener(...args) {
    if (!accessMicroAppName(args[0])) {
      return;
    }
    microApp.removeDataListener(...args);
  },
};

注意事项

  • 建议添加权限校验,防止子应用越权访问
  • 可以根据业务需求选择性暴露方法
步骤二:基座应用设置动态标签名称

基座应用在初始化时,设置动态标签名称,并通过 setGlobalData 传递给子应用:

// 基座应用:micro-app 初始化
import microApp from '@micro-zoe/micro-app';

// 定义动态标签名称常量
const MICRO_APP_TAGNAME = 'micro-app-base';

// 初始化 micro-app
microApp.start({
  tagName: MICRO_APP_TAGNAME, // 使用自定义标签名
  lifeCycles: {
    // 生命周期钩子
  },
  preFetchApps: [
    // 预加载应用列表
  ],
});

// 通过 setGlobalData 将标签名传递给子应用
microApp.setGlobalData({
  microAppTagName: MICRO_APP_TAGNAME,
});

子应用接收 image.png

步骤三:中间层应用创建动态组件

在中间层应用中,创建一个动态组件,使用基座应用的标签名称:



  



import { ref, computed, onMounted } from 'vue';

interface Props {
  appName: string;
  appUrl: string;
  embedPath?: string;
  appData?: Record;
}

const props = defineProps();

// 从全局数据中获取基座应用的标签名
const microAppTagName = ref('micro-app');

// 监听全局数据变化,获取标签名
onMounted(() => {
  if (window.microApp) {
    window.microApp.addDataListener((data: any) => {
      if (data?.microAppTagName) {
        microAppTagName.value = data.microAppTagName;
      }
    }, true); // true 表示立即执行一次

    // 获取初始数据
    const globalData = window.microApp.getData();
    if (globalData?.microAppTagName) {
      microAppTagName.value = globalData.microAppTagName;
    }
  }
});

const handleDataChange = (e: CustomEvent) => {
  // 处理子应用数据变化
  emit('dataChange', e.detail.data);
};

const emit = defineEmits(['dataChange']);

简单版: image.png

步骤四:使用动态组件并传递参数

在中间层应用的页面中,使用动态组件:



  <div class="sub-app-container">
    
  </div>



import { ref, watch } from 'vue';
import MicroApp from './MicroApp.vue';

const subAppName = ref('sub-app-name');
const subAppUrl = ref('https://sub-app.example.com');
const embedPath = ref('/page1'); // 通过 default-page 传递路由参数
const appData = ref({});

// 监听参数变化,更新子应用
watch(embedPath, (newPath) => {
  // 参数变化时,子应用会自动更新
});

const handleSubAppDataChange = (data: any) => {
  // 处理子应用数据变化
  console.log('子应用数据变化:', data);
};

简版: image.png

步骤五:实现参数传递和数据通信

中间层应用通过基座应用的 setData 方法向子应用传递数据:

// 中间层应用:参数传递
import { ref } from 'vue';

const embedPath = ref('/page1');

// 更新子应用参数
const updateSubAppPath = (newPath: string) => {
  embedPath.value = newPath;

  // 通过基座应用向子应用传递数据
  if (window.microApp) {
    window.microApp.setData(subAppName.value, {
      path: newPath,
      timestamp: Date.now(),
    });
  }
};

// 监听子应用数据变化
if (window.microApp) {
  window.microApp.addDataListener((data: any) => {
    console.log('收到子应用数据:', data);
    // 处理子应用返回的数据
  }, subAppName.value);
}

image.png

3.3 方案优势
  • 解决栈溢出问题:第三层直接使用基座应用的标签,绕过中间层的资源查找
  • 保持微前端能力:仍然可以使用 micro-app 的所有功能
  • 支持频繁交互:通过基座应用实现中间层和子应用之间的数据通信
  • 避免白屏问题:子应用不会因为参数变化而重新加载,提升用户体验
  • 支持多子应用:每个子应用都可以使用独立的标签,互不干扰
  • 技术栈兼容:支持 Vite + Vue 3 技术栈
3.4 注意事项
  1. 通信机制:中间层应用和子应用的通信需要通过基座应用进行,不能直接通信
  2. 权限控制:建议在基座应用中实现权限校验,防止子应用越权访问
  3. 标签名称:确保基座应用的标签名称唯一,避免冲突
  4. 数据管理:需要合理设计数据传递机制,避免数据混乱
3.5 架构示意图
┌─────────────────────────────────────┐
│           基座应用                   │
│  ┌───────────────────────────────┐  │
│  │  micro-app (tagName: 'base')  │  │
│  │  ┌─────────────────────────┐  │  │
│  │  │    中间层应用             │  │  │
│  │  │  ┌───────────────────┐  │  │  │
│  │  │  │  动态组件          │  │  │  │
│  │  │  │             │  │  │  │
│  │  │  │    ┌───────────┐  │  │  │  │
│  │  │  │    │ 子应用    │   │  │  │  │
│  │  │  │    └───────────┘  │  │  │  │
│  │  │  └───────────────────┘  │  │  │
│  │  └─────────────────────────┘  │  │
│  └───────────────────────────────┘  │
└─────────────────────────────────────┘

四、方案对比

方案 解决栈溢出 保持微前端能力 技术栈兼容 实现复杂度 推荐度
方案一:原生 iframe ⭐⭐
方案二:中间层不用 iframe ⭐⭐⭐ ⭐⭐
方案三:使用基座标签 ⭐⭐⭐⭐ ⭐⭐⭐⭐⭐

五、总结

5.1 问题根源

micro-app 1.x 版本在处理 Vite 项目的多层嵌套场景时,当中间层应用使用 iframe 模式,会导致第三层子应用在资源查找时出现循环查找,最终引发栈溢出。

5.2 最佳实践

推荐使用方案三:让第三层子应用直接使用基座应用的 micro-app 标签,通过基座应用实现中间层和子应用之间的通信。这样既解决了栈溢出问题,又保持了微前端的完整能力。

5.3 注意事项

  1. 确保基座应用的标签名称唯一且可配置
  2. 实现完善的权限校验机制
  3. 合理设计数据传递和通信机制
  4. 注意处理子应用的生命周期管理

5.4 未来展望

希望 micro-app 官方能够在后续版本中:

  • 修复多层嵌套场景下的资源查找问题
  • 提供更完善的多层嵌套示例和文档
  • 优化 Vite 项目的 iframe 模式支持

【更新】有人已经给出了解决方案,大家如果遇到同类问题,可以用此方案试试~ github.com/jd-opensour…

image.pnggithub.com/jd-opensour…

企业微信截图_5798ebde-4dc0-4c49-a0b2-4eb23d46cb9a.png

六、参考资料


范畴论——前端与计算机领域的“抽象工具箱”:该用则用,该弃则弃

一、引言:打破范畴论的“数学壁垒”

一说起“范畴论”,不少前端同学的第一反应是:这不是数学系研究生才啃的硬骨头吗?跟我写页面、调接口有什么关系?

别急着划走。咱们今天聊的范畴论,不是那个让你推导交换图、证明自然变换的纯数学,而是一套解决业务共性难题的工程化抽象工具。说白了,它就像一把瑞士军刀——你不用搞懂钢材的冶金工艺,只需要知道什么时候该用剪刀、什么时候该用螺丝刀。

在前端和计算机领域,有些“老大难”问题会反复出现:

  • 复杂业务逻辑越写越乱,改一个地方崩三个地方
  • 数据转换到处都是 if (data && data.user && data.user.name) 这种“防御性空值地狱”
  • 异步操作嵌套回调、then 链混着 try/catch,可读性堪比毛线团
  • 多步骤业务流程的复用全靠复制粘贴

这些问题,范畴论都能精准“对症下药”。但注意,它不是万能药——适用场景 ≠ 万能场景。咱们今天的目标很明确:搞懂范畴论的实用价值,掌握“什么时候该用、什么时候该弃”的落地标准,拒绝为了抽象而抽象。

二、范畴论核心:用计算机视角,读懂“对象与态射”

先忘掉那些让人头晕的定义。在计算机的世界里,范畴论可以极简理解为:

范畴 = 一组“对象” + 一组“态射”(箭头)

  • 对象:在代码里,就是类型stringnumberUser)、组件ButtonModal)、模块数据结构ArrayMap)。只要是“能待在那儿的东西”。
  • 态射:就是对象之间的关系,在代码里就是纯函数x => x + 1)、映射map)、组件通信(props 传递)、数据转换JSON.parse)。

你看,这不就是咱们每天都在写的东西吗?

范畴论的 3 大核心定律(前端可感知版)

光有对象和箭头还不够,得讲“规矩”。范畴论有三条基本定律,咱们用 JS 验证一遍:

1. 恒等律:每个对象都有一个“回到自己”的箭头。

// 恒等态射:identity 函数
const identity = x => x;

// 对任何值,identity(x) === x
identity(42);        // 42
identity([1,2,3]);   // [1,2,3]

React 组件里的透传 props、Vue 的插槽默认内容,本质上也是一种“恒等”思想——保持原样传递。

2. 结合律:多个箭头组合时,先组合谁后组合谁,结果一样。

const add1 = x => x + 1;
const double = x => x * 2;
const square = x => x * x;

// 两种组合方式,结果相同
const f1 = x => square(double(add1(x)));
const f2 = x => square(double(add1(x))); // 一样
// 更优雅的方式:用 compose
const compose = (f, g) => x => f(g(x));
const composed1 = compose(square, compose(double, add1));
const composed2 = compose(compose(square, double), add1);
composed1(3); // 64
composed2(3); // 64

这保证了我们拆解复杂逻辑时,顺序不会导致“灵异事件”。

3. 复合封闭性:两个箭头组合后,还是同一个范畴里的箭头。

// 纯函数组合后,还是纯函数
const add1ThenDouble = x => double(add1(x));
// 输入数字,输出数字,没有副作用,符合预期

这三条定律看起来简单,但它们是后续所有抽象(Functor、Monad)的基石。你不必刻意背它们,只需记住:范畴论保证了一件事——当你把“对象”和“箭头”按照规则组合时,结果是可预测、可信任的

范畴论与计算机的“桥梁”

Functor、Monad 这些词听起来高大上,其实就是范畴论在编程语言里的“落地载体”:

  • Functor:一个能 map 的东西。ArrayPromiseObservable 都是 Functor。
  • Monad:一个能 flatMap / chain 的东西。Promisethen 链、Maybe 处理空值,都是 Monad 的实际应用。

你不需要背定义,只需要知道:它们是范畴论思想“变现”后的实用工具

三、范畴论的核心价值:为什么计算机/前端需要它?

1. 解决“复杂性”

不同领域(数组、异步操作、DOM 事件)看起来八竿子打不着,但范畴论发现它们背后有相同的“结构”。比如 map 既可以用在数组上,也可以用在 Promise 上:

// 数组的 map
[1, 2, 3].map(x => x + 1); // [2, 3, 4]

// Promise 的 then(本质上是 map)
Promise.resolve(1).then(x => x + 1); // Promise(2)

用同一个概念统一处理不同场景,减少重复学习成本和代码模式。

2. 保障“正确性”

纯函数 + 不可变数据 = 代码可预测。范畴论鼓励的“态射”是纯函数,没有副作用,输入确定输出就确定。配合 TypeScript,这种正确性可以前移到编译时:

// 使用 Maybe 类型避免空指针
type Maybe<T> = T | null | undefined;

function getUserName(user: Maybe<{ name: string }>): string {
  return user?.name ?? 'Anonymous';  // 类型安全,不用担心运行时崩溃
}

3. 提升“复用性”

态射的“组合”特性,让代码像乐高积木一样拼装。比如有一组数据转换函数,可以随意组合出新逻辑:

const trim = s => s.trim();
const toLower = s => s.toLowerCase();
const capitalize = s => s[0].toUpperCase() + s.slice(1);

// 组合成新函数,复用已有逻辑
const formatName = compose(capitalize, toLower, trim);
formatName('  JOhN  '); // "John"

4. 降低“耦合度”

范畴论关注“对象之间的关系”,而不是对象内部的具体实现。在组件设计上,这体现为“依赖抽象而非具体实现”:

// 高阶组件接收一个“渲染函数”(态射),不关心内部如何实现
function List({ items, renderItem }) {
  return <ul>{items.map(renderItem)}</ul>;
}

四、适合使用范畴论原理的场景

(一)场景1:函数式编程(前端 JS/TS、后端函数式开发)

为什么适合?
函数式编程本身就是范畴论思想的直接体现。用 mapflatMap、函数组合来编写逻辑,天然符合范畴论定律。

案例:用 Maybe 处理嵌套数据

// 传统写法:防御性判断地狱
function getStreet(user) {
  if (user && user.address && user.address.street) {
    return user.address.street;
  }
  return 'Unknown';
}

// 使用 Maybe Monad
class Maybe {
  constructor(value) { this.value = value; }
  static of(value) { return new Maybe(value); }
  map(fn) {
    return this.value == null ? Maybe.of(null) : Maybe.of(fn(this.value));
  }
  getOrElse(defaultValue) {
    return this.value == null ? defaultValue : this.value;
  }
}

function getStreet(user) {
  return Maybe.of(user)
    .map(u => u.address)
    .map(a => a.street)
    .getOrElse('Unknown');
}

这段代码不仅消除了 if 嵌套,而且 map 的链式调用清晰表达了“可能不存在”的数据流,一旦某个环节为 null,整个链条短路返回默认值。

(二)场景2:高可靠/复杂系统开发

为什么适合?
电商订单、支付系统、金融交易这类场景,一个 bug 就是真金白银的损失。范畴论的“可预测性”和“纯函数”能极大降低出错概率。

案例:订单金额计算

// 纯函数:输入订单项,输出总价
const calculateSubtotal = items => 
  items.reduce((sum, item) => sum + item.price * item.quantity, 0);

const applyDiscount = (total, discountCode) => {
  const discount = discountMap[discountCode] || 0;
  return total * (1 - discount);
};

const addTax = (total, taxRate) => total * (1 + taxRate);

// 组合成一个完整流程
const calculateTotal = (items, discountCode, taxRate) =>
  compose(
    total => addTax(total, taxRate),
    total => applyDiscount(total, discountCode),
    calculateSubtotal
  )(items);

每个函数都是纯的,可单独测试。组合时不用担心互相影响,业务逻辑清晰得像流水账。这里的compose 是非必需的

(三)场景3:跨领域抽象(前端+后端、多端适配)

前后端可能使用不同语言(前端JS/TS,后端Java/Go/Python)但通过范畴论的"态射"思想,可以用统一的数学模型描述数据转换

案例:前后端数据映射

// 场景:订单金额处理,前后端必须保证计算逻辑一致
// 范畴论视角:定义一组纯函数态射,用数学语言描述,两端各自实现

// ========== 统一的"数学模型"(用伪代码/文档描述) ==========
// 态射1: 分转元 (金额单位转换)
// 态射2: 应用折扣
// 态射3: 计算税费
// 组合: 最终金额 = 税费(折扣(分转元(原始金额)))

// ========== 前端实现(TypeScript) ==========
const centsToYuan = (cents: number): number => cents / 100;

const applyDiscount = (amount: number, discountRate: number): number => 
  amount * (1 - discountRate);

const addTax = (amount: number, taxRate: number): number => 
  amount * (1 + taxRate);

// 组合态射:最终金额计算(纯函数,可测试)
const calculateFinalAmount = (
  cents: number, 
  discountRate: number, 
  taxRate: number
): number => {
  return addTax(applyDiscount(centsToYuan(cents), discountRate), taxRate);
};

// ========== 后端实现(Java,逻辑完全一致) ==========
/*
public class AmountCalculator {
    public static double centsToYuan(int cents) {
        return cents / 100.0;
    }
    
    public static double applyDiscount(double amount, double discountRate) {
        return amount * (1 - discountRate);
    }
    
    public static double addTax(double amount, double taxRate) {
        return amount * (1 + taxRate);
    }
    
    public static double calculateFinalAmount(int cents, double discountRate, double taxRate) {
        return addTax(applyDiscount(centsToYuan(cents), discountRate), taxRate);
    }
}
*/

关键价值:

  1. 用"态射组合"的范畴论思想统一建模,前后端各自实现同一组数学变换
  2. 避免因"前端用分、后端用元"导致的金额错乱 bug
  3. 核心业务逻辑(折扣、税费规则)只在一处定义,两端保持语义一致
  4. 新增币种/税率时,只需添加新的态射函数,不破坏原有组合

五、不适合使用范畴论原理的场景

(一)场景1:简单业务脚本/快速原型开发

反例:一个简单的表单提交,没有复杂校验,就是 input → 发送请求 → 显示成功

为什么不适合?
抽象成本 > 实际收益。为三行代码封装一个 Maybe、搞个函数组合,纯属杀鸡用牛刀。直接写 if/elsetry/catch,三分钟搞定,维护的人也一眼看懂。

// 简单场景,直接写更清晰
async function submitForm(formData) {
  try {
    const res = await api.post('/submit', formData);
    showSuccess(res.message);
  } catch (err) {
    showError(err.message);
  }
}

(二)场景2:纯命令式为主的小型项目

反例:一个企业官网的静态页面,只有展示内容和少量动画,没有任何复杂交互。

为什么不适合?
项目规模小、逻辑简单,命令式代码(if/elsefor 循环)更直观。团队如果对函数式不熟悉,强行引入范畴论抽象,后续维护的人可能“看不懂”或者“不敢改”。

// 简单展示页面,直接循环即可
const items = ['Home', 'About', 'Contact'];
const navHtml = '<ul>' + items.map(item => `<li>${item}</li>`).join('') + '</ul>';

没必要封装一个 Functor 来处理数组。

(三)场景3:对性能极致要求的底层代码

反例:Canvas 动画每一帧都要计算上万次的位置;高并发的底层网关接口。

为什么不适合?
范畴论的抽象(如 Monad 嵌套、多层函数组合)会带来额外的函数调用开销和中间对象创建。底层代码追求“极致简洁”,有时一个 for 循环比 map+reduce 快一个数量级。

// 高频渲染循环,用最简写法
function updatePositions(particles) {
  for (let i = 0; i < particles.length; i++) {
    particles[i].x += particles[i].vx;
    particles[i].y += particles[i].vy;
  }
}

这时候别为了“函数式优雅”而牺牲帧率。

(四)场景4:短期迭代、需求频繁变更的业务

反例:创业公司早期 MVP,产品方向一周一变;临时活动页面上线一两周就下线。

为什么不适合?
范畴论的抽象设计需要“长期规划”,需求频繁变更会导致抽象边界反复调整,改一处抽象影响所有使用方,得不偿失。短期迭代追求“快速响应”,简单直接才是王道。

// 需求变来变去,直接写死最省心
if (isSpecialOffer) {
  price = price * 0.8;
}
// 别急着封装 discount 策略模式,可能下周活动就换了

六、核心取舍:判断是否使用范畴论的 3 个可落地标准

判断维度 适合使用 不适合使用
成本收益比 抽象能显著减少重复代码、提升可靠性,长期维护成本降低 简单场景,抽象带来的复杂度 > 收益
项目规模与复杂度 大型项目、核心业务、高可靠系统(订单、支付、金融) 小型项目、一次性脚本、快速原型
团队适配度 团队熟悉函数式/抽象思维,愿意接受 团队完全不熟悉,强行引入导致维护困难

快速决策口诀

  • 代码逻辑超过 3 层嵌套?→ 考虑函数组合
  • 到处都是 if (x && x.y && x.y.z)?→ 考虑 Maybe
  • 异步操作层层回调或 then 链混乱?→ 考虑 Promise 的 Monad 特性(then 链本质就是 flatMap
  • 项目生命周期 < 1 个月?→ 别想那么多,直接写

七、总结:范畴论不是“银弹”,是“精准工具”

范畴论的价值,从来不是让你在代码里塞满高深莫测的数学概念,而是提供一套解决复杂问题的抽象能力

对于前端和计算机开发者,我建议:

  1. 不用刻意死磕数学理论,理解“对象”和“态射”这对核心概念,能看懂 mapflatMap、函数组合就够了。
  2. 按需引入,从痛点入手:遇到空值地狱,试试 Maybe;遇到复杂数据转换,试试函数组合;遇到不可预测的副作用,把核心逻辑抽成纯函数。
  3. 终极取舍:适合的场景用范畴论“降本提效”,不适合的场景用简单逻辑“快速落地”。别为了“看起来高级”而牺牲可读性和维护性。

记住:好代码的标准是“容易理解和修改”,而不是“用了多少数学概念”。范畴论是工具箱里的一把精密扳手,不是让你把所有螺丝都换成它的理由。

该用则用,该弃则弃。这才是工程化的智慧。

npm 包入口指南:package.json 中的 main、module、exports

你有没有遇到过这些问题:

  • 明明装了包,import 就报错,换成 require 又好了?
  • TypeScript 提示找不到类型声明,但包里明明有 .d.ts 文件?
  • 发布了一个 npm 包,别人用的时候打包体积巨大,Tree Shaking 不生效?
  • mainmoduleexportsbrowsertypes 写了一堆,到底谁在生效?

如果你也被这些问题折磨过,这篇文章就是为你写的。


一、先搞清一件事:模块系统的历史包袱

在讲入口字段之前,你必须理解一个前提 —— JavaScript 有两套模块系统,而且它们互不兼容

CommonJS(CJS)

// 导出
module.exports = { add, subtract }
// 或
exports.add = function() {}

// 导入
const { add } = require('lodash')
  • Node.js 原生支持(从诞生起就有)
  • 同步加载,不适合浏览器
  • 文件后缀:.js(在 type: "commonjs" 下)或 .cjs

ES Module(ESM)

// 导出
export function add() {}
export default subtract

// 导入
import { add } from 'lodash-es'
  • ECMAScript 官方标准
  • 静态分析,支持 Tree Shaking
  • Node.js 12+ 开始支持
  • 文件后缀:.js(在 type: "module" 下)或 .mjs

矛盾的根源

一个 npm 包的使用者可能是:

使用场景 期望的模块格式
Node.js 老项目(require CJS
Node.js 新项目(import ESM
Webpack / Vite 前端项目 ESM(优先)或 CJS
浏览器直接 <script type="module"> ESM
SSR(Nuxt / Next.js) CJS 或 ESM

一个包要服务这么多场景,只用一个入口文件显然不够。 这就是为什么 package.json 需要这么多入口字段。


二、入口字段逐个击破

2.1 main — 最古老的入口

{
  "main": "dist/index.js"
}

历史地位: 这是 package.json 中最早的入口字段,Node.js 从一开始就读它。

行为: 当别人写 require('your-package')import 'your-package' 时,Node.js 会去找 main 字段指向的文件。

注意:

  • 如果不写 main,Node.js 默认找包根目录下的 index.js
  • main 指向的文件格式应该和 type 字段一致(后面会讲)
  • 在有 exports 字段的情况下,main 只是作为兜底存在

一句话: main 是给 require() 用的,通常指向 CJS 格式的文件。


2.2 module — 打包工具的"私下约定"

{
  "module": "dist/index.esm.js"
}

重要:这不是 Node.js 官方标准。 它是 Rollup 在 2015 年提出的一个社区约定,后来 Webpack 也支持了。

为什么需要它?

假设你写了一个工具库,你想同时提供 CJS 和 ESM 两种格式:

{
  "main": "dist/index.cjs.js",
  "module": "dist/index.esm.js"
}

打包工具(Webpack、Rollup、Vite)看到 module 字段就会优先使用 ESM 版本,因为 ESM 支持静态分析Tree Shaking。而 Node.js 直接运行时会忽略 module,走 main 拿到 CJS 版本。

一句话: module 是给 Webpack / Rollup / Vite 这些打包工具看的 ESM 入口。


2.3 browser — 浏览器专用入口

{
  "browser": "dist/index.browser.js"
}

使用场景: 你的包在 Node.js 和浏览器中需要不同的实现。

典型例子:

{
  "main": "dist/index.node.js",
  "browser": "dist/index.browser.js"
}

比如一个 HTTP 请求库,Node 端用 http 模块,浏览器端用 fetchXMLHttpRequestaxios 就是这么干的。

高级用法 —— 模块替换:

{
  "browser": {
    "./lib/ws.js": "./lib/ws-browser.js",
    "fs": false,
    "path": false
  }
}
  • "./lib/ws.js": "./lib/ws-browser.js" → 替换特定文件
  • "fs": false → 在浏览器端将 fs 模块替换为空对象

Webpack 在构建 target: 'web' 时会读取这个字段。

一句话: browser 是给浏览器环境用的入口,解决 Node vs 浏览器 API 差异。


2.4 types / typings — TypeScript 类型入口

{
  "types": "dist/index.d.ts"
}

作用: 告诉 TypeScript 编译器去哪里找类型声明文件。

没有这个字段会怎样?

TypeScript 会尝试找 main 字段指向的文件,把 .js 替换为 .d.ts。比如 main: "dist/index.js" → 找 dist/index.d.ts。找不到就报那个烦人的错误:

Could not find a declaration file for module 'xxx'.

types vs typings 完全等价,推荐用 types(更简短)。


2.5 type — 模块系统的"开关"

{
  "type": "module"
}

这个字段不是入口,而是一个全局开关,决定了 Node.js 怎么理解 .js 文件:

type 的值 .js 文件被视为 .cjs 文件 .mjs 文件
"commonjs"(默认) CommonJS CommonJS ESModule
"module" ESModule CommonJS ESModule

关键点:

  • .cjs 永远是 CommonJS,不管 type 怎么设
  • .mjs 永远是 ESModule,不管 type 怎么设
  • .js 的身份取决于 type 字段

一个容易踩的坑:

你在 package.json 里写了 "type": "module",然后你的 .eslintrc.js 配置文件用了 module.exports = {},Node.js 就会报错:

SyntaxError: Unexpected token 'export'

因为 Node.js 把 .js 当 ESM 处理了,但 module.exports 是 CJS 语法。解决办法:把配置文件改名为 .eslintrc.cjs


2.6 exports — 终极解决方案(重点!)

如果你只想记住一个字段,那就记住 exports

{
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs",
      "default": "./dist/index.mjs"
    }
  }
}

exports 是 Node.js 12.11 引入的官方方案,一个字段解决了 mainmodulebrowsertypes 四个字段干的事

能力一:条件导出

根据不同的使用方式,返回不同的文件:

{
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs"
    }
  }
}

当使用者写 import pkg from 'your-package' → 走 import 条件,拿到 ESM 文件
当使用者写 const pkg = require('your-package') → 走 require 条件,拿到 CJS 文件

支持的条件关键字:

条件 含义 谁在用
types TypeScript 类型声明 TypeScript 编译器
import ESM import 方式引入 Node.js、打包工具
require CJS require() 方式引入 Node.js、打包工具
node Node.js 环境 Node.js
browser 浏览器环境 打包工具
development 开发环境 部分打包工具
production 生产环境 部分打包工具
default 兜底条件 所有

条件匹配规则:从上到下,命中第一个就停。 所以顺序很重要:

{
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",    // ← 必须第一个!
      "node": {
        "import": "./dist/node.mjs",
        "require": "./dist/node.cjs"
      },
      "browser": "./dist/browser.mjs",
      "default": "./dist/index.mjs"     // ← 兜底放最后
    }
  }
}

TypeScript 的 types 条件必须放在最前面! 否则 TS 可能匹配到其他条件就停了,导致找不到类型。

能力二:子路径导出

不需要暴露整个包,可以精确控制哪些路径可以被外部引用:

{
  "exports": {
    ".": "./dist/index.mjs",
    "./utils": "./dist/utils.mjs",
    "./hooks": "./dist/hooks.mjs",
    "./styles": "./dist/styles.css"
  }
}

使用方式:

import { debounce } from 'your-package/utils'
import { useAuth } from 'your-package/hooks'
import 'your-package/styles'

通配符导出:

{
  "exports": {
    ".": "./dist/index.mjs",
    "./components/*": "./dist/components/*/index.mjs",
    "./icons/*": "./dist/icons/*.mjs"
  }
}
import Button from 'your-package/components/Button'
import StarIcon from 'your-package/icons/Star'

能力三:封装隔离

一旦声明了 exports未列出的路径就无法被外部访问

{
  "exports": {
    ".": "./dist/index.mjs",
    "./utils": "./dist/utils.mjs"
  }
}
// ✅ 可以用
import pkg from 'your-package'
import { foo } from 'your-package/utils'

// ❌ 报错!未在 exports 中声明
import internal from 'your-package/dist/internal.mjs'
import helper from 'your-package/src/helper.js'

这是一个非常重要的特性 —— 保护内部实现细节,防止使用者依赖你的私有 API


三、到底什么时候需要打包?什么时候不需要?

这可能是最让人困惑的问题了。同样是写 npm 包,有的包 dist/ 目录里放着打包好的文件,有的包直接发布源码。到底怎么选?

场景一:纯 Node.js 工具包(CLI / 服务端)

my-cli/
├── src/
│   ├── index.js
│   └── utils.js
├── package.json
└── README.md

不需要打包。

原因:

  • Node.js 直接运行 JS 文件,不需要打包
  • 没有浏览器兼容性问题
  • 不需要 Tree Shaking(Node.js 用不到)
  • 发布源码即可
{
  "main": "src/index.js",
  "type": "module",
  "files": ["src"]
}

但如果用了 TypeScript,需要编译(不是打包):

{
  "main": "dist/index.js",
  "types": "dist/index.d.ts",
  "files": ["dist"],
  "scripts": {
    "build": "tsc"
  }
}

这里用 tsc 只是把 .ts.js一对一转换,不是打包。

场景二:前端 UI 组件库

my-ui/
├── src/
│   ├── Button/
│   ├── Modal/
│   └── index.ts
├── dist/
│   ├── index.mjs      ← ESM
│   ├── index.cjs       ← CJS
│   ├── index.d.ts      ← 类型
│   └── style.css       ← 样式
└── package.json

需要打包。

原因:

  • 使用者的打包工具需要 ESM 格式做 Tree Shaking
  • 需要编译 TypeScript / JSX / Vue SFC
  • 需要处理 CSS / Less / Sass
  • 可能需要同时提供 CJS 和 ESM
{
  "main": "dist/index.cjs",
  "module": "dist/index.mjs",
  "types": "dist/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs"
    },
    "./style.css": "./dist/style.css"
  },
  "sideEffects": ["*.css"],
  "files": ["dist"]
}

场景三:工具函数库(lodash 那种)

需要打包,而且最好提供多种格式。

{
  "main": "dist/index.cjs",
  "module": "dist/index.mjs",
  "types": "dist/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs"
    }
  },
  "sideEffects": false,
  "files": ["dist"]
}

sideEffects: false 至关重要 —— 它告诉打包工具"这个包里所有模块都没有副作用,可以放心 Tree Shaking"。

场景四:全栈框架的插件/中间件

{
  "main": "dist/index.cjs",
  "module": "dist/index.mjs",
  "types": "dist/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "node": {
        "import": "./dist/node.mjs",
        "require": "./dist/node.cjs"
      },
      "browser": "./dist/browser.mjs",
      "default": "./dist/index.mjs"
    }
  }
}

Node 端和浏览器端实现不同,需要条件导出区分。

场景五:只发布类型声明(纯 .d.ts 包)

比如 @types/node@types/lodash

不需要打包。

{
  "types": "index.d.ts",
  "files": ["*.d.ts", "**/*.d.ts"]
}

决策速查表

问题 是 → 否 →
用了 TypeScript? 至少需要 tsc 编译 可以直接发布源码
用了 JSX / Vue SFC / Sass? 需要打包/编译
需要 Tree Shaking? 必须提供 ESM 格式 只提供 CJS 也行
Node 和浏览器行为不同? 需要多入口(exports 条件导出) 单入口即可
需要同时支持 requireimport 提供 CJS + ESM 双格式 只提供一种

四、不同工具的解析优先级

你写了一堆入口字段,但最终谁在生效?这取决于"谁在消费你的包"。

Node.js(>= 16)

exports  →  main  →  index.js
  • 如果有 exports完全忽略 mainmodulebrowser
  • 如果没有 exports,读 main
  • 如果没有 main,找 index.js

Webpack 5

exports  →  browser  →  module  →  main
  • 优先 exports
  • 然后看 browser(如果 target 是 web)
  • 再看 module(ESM 优先)
  • 最后 main

Vite / Rollup

exportsmodule  →  main
  • Vite 基于 Rollup,天然偏好 ESM
  • 不读 browser 字段(通过 Vite 自己的 resolve.conditions 处理)

TypeScript

exports["types"]  →  types  →  typings  →  main 对应的 .d.ts

需要 tsconfig.json 配合:

{
  "compilerOptions": {
    "moduleResolution": "bundler"    // 或 "node16" / "nodenext"
  }
}

注意: 如果 moduleResolution 还是 "node"(旧模式),TypeScript 不会读 exports 字段!这是很多人类型丢失的根本原因。

优先级总览图

               Node.js         Webpack 5        Vite/Rollup      TypeScript
               ───────         ─────────        ──────────       ──────────
最高优先级 →    exports         exports          exports          exports.types
               │               │                │                │
               │               browser          module           types/typings
               │               │                │                │
               main            module           main             main→.d.ts
               │               │
               index.js        main

五、Dual Package 的陷阱(CJS + ESM 双格式)

同时提供 CJS 和 ESM 是好事,但有一个隐藏的大坑:Dual Package Hazard(双包风险)

问题是什么?

假设你的包导出了一个单例:

// 你的包
let count = 0
export function increment() { count++ }
export function getCount() { return count }

如果使用者的项目中同时通过 importrequire 引用了你的包(这在复杂项目中很常见),Node.js 会加载两份代码 —— ESM 一份,CJS 一份。两份代码各自维护自己的 count,状态不共享,产生诡异的 bug。

解决方案一:ESM Wrapper

只打包一份 CJS,ESM 入口只是一个转发:

// dist/index.cjs  ← 真正的实现
module.exports = { increment, getCount }

// dist/index.mjs  ← 只是一个 wrapper
import cjs from './index.cjs'
export const { increment, getCount } = cjs
{
  "exports": {
    ".": {
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs"
    }
  }
}

这样 ESM 和 CJS 用的是同一份代码,状态一致。

解决方案二:无状态设计

如果你的包本身是纯函数、无状态的(大部分工具函数库都是),那就不用担心,直接双格式打包即可。


六、实战配置模板

模板一:TypeScript 工具函数库

打包工具推荐 tsup(基于 esbuild,零配置):

{
  "name": "my-utils",
  "version": "1.0.0",
  "type": "module",
  "main": "dist/index.cjs",
  "module": "dist/index.js",
  "types": "dist/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js",
      "require": "./dist/index.cjs"
    }
  },
  "files": ["dist"],
  "sideEffects": false,
  "scripts": {
    "build": "tsup src/index.ts --format cjs,esm --dts --clean",
    "dev": "tsup src/index.ts --format cjs,esm --dts --watch"
  },
  "devDependencies": {
    "tsup": "^8.0.0",
    "typescript": "^5.0.0"
  }
}

模板二:Vue 组件库

打包工具推荐 Vite Library Mode

{
  "name": "my-components",
  "version": "1.0.0",
  "type": "module",
  "main": "dist/index.umd.js",
  "module": "dist/index.es.js",
  "types": "dist/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.es.js",
      "require": "./dist/index.umd.js"
    },
    "./style.css": "./dist/style.css"
  },
  "files": ["dist"],
  "sideEffects": ["*.css"],
  "peerDependencies": {
    "vue": "^3.3.0"
  },
  "scripts": {
    "build": "vite build"
  }
}

模板三:React 组件库

{
  "name": "my-react-ui",
  "version": "1.0.0",
  "type": "module",
  "main": "dist/index.cjs",
  "module": "dist/index.js",
  "types": "dist/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js",
      "require": "./dist/index.cjs"
    },
    "./styles": "./dist/styles.css"
  },
  "files": ["dist"],
  "sideEffects": ["*.css"],
  "peerDependencies": {
    "react": "^18.0.0 || ^19.0.0",
    "react-dom": "^18.0.0 || ^19.0.0"
  },
  "peerDependenciesMeta": {
    "react-dom": { "optional": true }
  }
}

模板四:纯 Node.js 包(不打包)

{
  "name": "my-server-lib",
  "version": "1.0.0",
  "type": "module",
  "main": "src/index.js",
  "types": "src/index.d.ts",
  "exports": {
    ".": {
      "types": "./src/index.d.ts",
      "default": "./src/index.js"
    },
    "./middleware": {
      "types": "./src/middleware.d.ts",
      "default": "./src/middleware.js"
    }
  },
  "files": ["src"],
  "engines": {
    "node": ">=18.0.0"
  }
}

模板五:CLI 工具

{
  "name": "my-cli",
  "version": "1.0.0",
  "type": "module",
  "bin": {
    "mycli": "./bin/cli.js"
  },
  "files": ["bin", "src"],
  "engines": {
    "node": ">=18.0.0"
  }
}

CLI 工具通常不需要别人 import,所以连 main 都不需要写。


七、常见报错排查指南

报错 1:ERR_REQUIRE_ESM

Error [ERR_REQUIRE_ESM]: require() of ES Module not supported

原因: 你用 require() 引入了一个 "type": "module" 的包。

解决:

  • 改用 import(推荐)
  • 或者用 await import('the-package')(动态导入)
  • 或者在你的项目中也设置 "type": "module"

报错 2:ERR_PACKAGE_PATH_NOT_EXPORTED

Error [ERR_PACKAGE_PATH_NOT_EXPORTED]: Package subpath './lib/foo' is not defined by "exports"

原因: 包设置了 exports,但你访问的路径不在 exports 的声明里。

解决:

  • 只使用包 exports 中声明的路径
  • 如果你是包作者,把遗漏的路径加到 exports

报错 3:Could not find a declaration file for module

Could not find a declaration file for module 'xxx'.
'xxx' implicitly has an 'any' type.

原因: TypeScript 找不到类型声明。

排查步骤:

  1. 包有 types 字段吗?指向的 .d.ts 文件存在吗?
  2. 包有 exports 吗?exports 里有 types 条件吗?
  3. 你的 tsconfig.jsonmoduleResolution 是什么?如果是 "node"(旧模式),改为 "bundler""node16"
  4. 如果都没问题,安装 @types/xxx

报错 4:Tree Shaking 不生效,打包体积大

排查步骤:

  1. 包有 moduleexports.import 入口吗?(必须是 ESM 格式)
  2. 包设置了 "sideEffects": false 吗?
  3. 你是用 import { specific } from 'pkg' 而不是 import * as pkg from 'pkg' 吗?
  4. 检查是否有 barrel file(index.tsexport * from 一大堆)导致的连锁引入

八、总结

一张决策流程图帮你选择正确的配置:

你的包是什么类型?
│
├── CLI 工具
│   └── 只需要 bin,不需要 main
│
├── 纯 Node.js 库
│   ├── 用 JS 写的 → 不需要打包,直接发布源码
│   └── 用 TS 写的 → tsc 编译,发布 dist
│
├── 前端组件库
│   └── 需要打包(Vite / tsup / Rollup)
│       ├── 提供 CJS + ESM 双格式
│       ├── 设置 exports 条件导出
│       ├── 设置 sideEffects
│       └── peerDependencies 声明框架依赖
│
└── 工具函数库
    └── 需要打包
        ├── 提供 CJS + ESM 双格式
        ├── sideEffects: false(关键!)
        └── exports 条件导出

无论哪种类型,如今的最佳实践是:
✅ 始终写 exports(现代标准)
✅ 保留 main + module 做向后兼容
✅ types 条件放在 exports 的第一个
✅ moduleResolution 用 "bundler""node16"

如果这篇文章帮你解开了心中的疑惑,点个赞让更多人看到吧。有问题欢迎在评论区讨论!

🚀 2026 前端生存指南:用 Vite + React 19.2 手搓一个“丝滑”到犯规的项目架构

🚀 2026 前端生存指南:用 Vite + React 19.2 手搓一个“丝滑”到犯规的项目架构

摘要:还在为 Webpack 配置头秃?还在纠结 Vue 和 React 谁才是“正宫”?别争了,2026 年的今天,React 19.2 已经带着它的“自动优化编译器”杀疯了!本文将带你从零开始,用 Vite 极速启动,搭配 React Router 6+,手搓一套能扛住双 11 流量的现代化架构。准备好了吗?我们要让冷启动比你的咖啡冷却得还快!☕️


🎬 序幕:告别“等待”,拥抱“瞬间”

曾几何时,创建一个新项目是这样的:

  1. npm init (等待...)
  2. 安装 Webpack, Babel, Loader, Plugin... (等待 x 100)
  3. 配置 webpack.config.js (写错一行,报错一整天)
  4. 终于 npm start 了,然后看着进度条慢慢爬... (去上个厕所回来还没好)

现在,2026 年了,朋友! 我们只需要一条命令:

npm create vite@latest my-super-app -- --template react

嗖! 项目好了。 再嗖! npm run dev 服务器启动了。 再再嗖! 浏览器打开了。

这就是 Vite 的魔法。它不是脚手架,它是开发体验的革命者。利用原生 ESM (ES Modules),它实现了极致的冷启动。不需要打包整个应用,你需要哪个文件,它就即时编译哪个文件。就像点菜,吃多少炒多少,绝不浪费一毫秒。


🛠️ 第一关:依赖管理的“爱恨情仇”

package.json 的世界里,存在着两个平行宇宙:dependenciesdevDependencies。分不清楚?小心你的生产包体积爆炸!

📦 生产依赖 (dependencies)

这是你项目的灵魂。没有它们,你的应用跑不起来。

  • react (19.2.0): 2026 年的王者。现在的 React 不仅仅是 UI 库,它是响应式、组件化、数据绑定的集大成者。React 19.2 更是引入了稳定的 Compiler,自动帮你做 useMemouseCallback 的优化,你只管写代码,性能它来扛!
  • react-dom: 如果把 React 比作大脑(Core),那 react-dom 就是手脚。它负责把虚拟 DOM 真正渲染到浏览器的 DOM 树上。
    • 冷知识:Vue 3.5+ 其实也借鉴了 React 的很多思想,可以说 Vue = React(Core) + 更贴心的语法糖。但在生态广度上,React 依然是那个“第一的现代前端开发框架”。

🔧 开发依赖 (devDependencies)

这是你项目的工具箱。只在开发、测试、构建时使用,上线时不需要带走。

  • vite: 开发服务器和构建工具。
  • stylus/sass: 预处理器。你写代码时需要它编译 CSS,但浏览器只需要最终的 CSS 文件。
  • typescript/eslint: 代码检查员。

安装姿势要帅:

# 安装生产依赖
npm install react react-dom react-router-dom

# 安装开发依赖 (记得加 -D 或 --save-dev)
npm install -D vite stylus

💡 避坑指南:千万别把 vite 装进 dependencies!否则你的 node_modules 会像吃了激素一样膨胀,部署时间翻倍,运维小哥会想顺着网线过来打你。


🗺️ 第二关:路由——单页应用的“导航仪”

没有路由的 SPA (单页应用) 就像一个没有门的大房子,用户进来了就出不去,只能刷新页面(然后丢失所有状态,惨!)。

1. 请出大神:React Router DOM

npm install react-router-dom

在 2026 年,我们依然首选 react-router-dom v7+(或者兼容 React 19 的最新版本)。它完美支持 Suspense、Data API 和 类型安全。

2. 配置路由:搭建你的“立交桥”

别再写一堆 if (path === '/home') 了。让我们用声明式的方式配置路由。

// src/main.jsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter, Routes, Route } from 'react-router-dom'
import App from './App'
import Home from './pages/Home'
import About from './pages/About'
import UserProfile from './pages/UserProfile'

createRoot(document.getElementById('root')).render(
  <StrictMode>
    <BrowserRouter>
      <Routes>
        <Route path="/" element={<App />}>
          <Route index element={<Home />} /> {/* 首页 */}
          <Route path="about" element={<About />} /> {/* 关于页 */}
          <Route path="user/:id" element={<UserProfile />} /> {/* 动态路由:/user/123 */}
        </Route>
      </Routes>
    </BrowserRouter>
  </StrictMode>,
)

3. 导航:让用户“飞”起来

页面级组件之间如何跳转?用 <Link> 标签,它会阻止默认的页面刷新,实现无感跳转

// src/components/NavBar.jsx
import { Link, useNavigate } from 'react-router-dom';

export default function NavBar() {
  const navigate = useNavigate();

  const handleEmergencyJump = () => {
    // 编程式导航:适合在逻辑处理后跳转,比如登录成功
    navigate('/dashboard');
  };

  return (
    <nav>
      {/* 声明式导航:简单直接 */}
      <Link to="/">🏠 首页</Link>
      <Link to="/about">ℹ️ 关于我们</Link>
      
      <button onClick={handleEmergencyJump}>
        🚀 紧急前往控制台
      </button>
    </nav>
  );
}

🌟 React 19 新特性加持: 在 React 19 中,配合 useActionState 和 Forms 的新特性,你可以在表单提交后自动处理导航,甚至实现乐观更新(Optimistic Updates)。用户点击“保存”,界面瞬间更新,后台慢慢请求,失败了再回滚。这种“丝滑”感,让用户以为你的服务器就在他们电脑里!


🔄 第三关:生命周期——Dev -> Test -> Prod 的轮回

前端开发就是一场无尽的轮回:

  1. Dev (开发): npm run dev。Vite 开启 HMR (热模块替换)。你改一行代码,浏览器瞬间刷新,状态都不丢。这是创造的阶段。
  2. Test (测试): npm run test。Jest/Vitest 上场,确保你的组件不会在奇怪的地方崩溃。这是找茬的阶段。
  3. Production (上线): npm run build。Vite 使用 Rollup 进行生产打包,Tree-shaking 摇掉无用代码,压缩、混淆、哈希命名。这是交付的阶段。

循环往复,永无止境: Dev ➡️ Test ➡️ Prod ➡️ (发现 Bug) ➡️ Dev ...

在这个循环中,Vite 是你的加速器,React 19 是你的稳定器。

  • 开发时:ESM 极速加载。
  • 生产时:Rollup 极致优化。
  • 运行时:Compiler 自动优化渲染。

🎨 结语:架构之美,在于简单

看看我们现在的架构:

  • 构建工具:Vite (快如闪电)
  • 核心框架:React 19.2 (智能编译)
  • 路由管理:React Router (灵活导航)
  • 样式方案:Stylus (嵌套语法,优雅书写)

没有复杂的配置,没有沉重的包袱。我们只需要关注组件状态用户体验

最后的小幽默: 以前老板问:“为什么页面加载这么慢?” 你答:“Webpack 在打包...”

现在老板问:“为什么页面加载这么快?” 你答:“因为用了 Vite 和 React 19,而且我刚才喝咖啡的时间都被省下来了。”

老板:“那再做一个功能吧。” 你:“......” (这就是技术的代价 😂)

好了,别废话了,打开终端,npm create vite,开始你的 2026 前端之旅吧!🚀


OpenClaw 工具调用全链路深度解析:一条 exec 命令的七道闸门

本文基于 OpenClaw 源码深度分析,完整还原一条 exec 工具调用从 AI 模型产出到操作系统进程启动的全过程,重点拆解其中七道安全闸门的实现细节。


一、引子:工具调用为什么复杂

当你在 OpenClaw 对话框里发出 "帮我跑一下这个脚本" 时,AI 模型不会直接操控你的终端。它会发出一个结构化的 工具调用(tool call),由 OpenClaw 的执行层接管。这看起来很简单——不就是 child_process.spawn 吗?

实际上,从模型产出 exec 调用到操作系统真正 fork 出进程,中间至少经历七道完整的处理阶段:

  1. 工具注册与参数规范化 — 决定这个工具是谁、能做什么
  2. Host 路由与提权裁决 — 命令要在哪里跑、是否需要 elevated 权限
  3. 环境变量净化 — 阻止宿主机敏感信息泄漏进子进程
  4. Shell 语法分析 — 静态解析命令链,建立可审计的执行段列表
  5. 白名单与 SafeBin 评估 — 每个命令段对照允许列表做多源匹配
  6. 混淆检测 — 拦截 base64/eval/curl-pipe-shell 等绕过模式
  7. 审批流程 — 需要人工确认时挂起执行,等待授权信号

只有全部通过,才会进入真正的 spawn 阶段。本文将逐层拆解这七道闸门的源码实现。


二、工具的骨架:createExecToolexecSchema

一切从 createExecTool 开始。它是一个工厂函数,接收配置 ExecToolDefaults,返回一个符合 AgentTool 接口的工具对象。

2.1 参数规范定义

// src/agents/bash-tools.exec-runtime.ts L98-144
export const execSchema = Type.Object({
  command: Type.String({ description: "Shell command to execute" }),
  workdir: Type.Optional(Type.String({ description: "Working directory (defaults to cwd)" })),
  env: Type.Optional(Type.Record(Type.String(), Type.String())),
  yieldMs: Type.Optional(
    Type.Number({
      description: "Milliseconds to wait before backgrounding (default 10000)",
    }),
  ),
  background: Type.Optional(Type.Boolean({ description: "Run in background immediately" })),
  timeout: Type.Optional(
    Type.Number({
      description: "Timeout in seconds (optional, kills process on expiry)",
    }),
  ),
  pty: Type.Optional(
    Type.Boolean({
      description:
        "Run in a pseudo-terminal (PTY) when available (TTY-required CLIs, coding agents)",
    }),
  ),
  elevated: Type.Optional(
    Type.Boolean({
      description: "Run on the host with elevated permissions (if allowed)",
    }),
  ),
  host: Type.Optional(
    Type.String({
      description: "Exec host (sandbox|gateway|node).",
    }),
  ),
  security: Type.Optional(
    Type.String({
      description: "Exec security mode (deny|allowlist|full).",
    }),
  ),
  ask: Type.Optional(
    Type.String({
      description: "Exec ask mode (off|on-miss|always).",
    }),
  ),
  node: Type.Optional(
    Type.String({
      description: "Node id/name for host=node.",
    }),
  ),
});

这个 Schema 使用 TypeBox 定义,AI 模型在生成工具调用时必须符合该结构。注意 hostsecurityask 都是可选字符串而非枚举类型——这是有意为之的设计:运行时规范化比编译时严格枚举更灵活,同时后续会通过 normalizeExecHost/normalizeExecSecurity/normalizeExecAsk 做严格的值域验证。

2.2 ExecToolDefaults 的深度配置

// src/agents/bash-tools.exec-types.ts L5-30
export type ExecToolDefaults = {
  host?: ExecHost;
  security?: ExecSecurity;
  ask?: ExecAsk;
  node?: string;
  pathPrepend?: string[];
  safeBins?: string[];
  safeBinTrustedDirs?: string[];
  safeBinProfiles?: Record<string, SafeBinProfileFixture>;
  agentId?: string;
  backgroundMs?: number;
  timeoutSec?: number;
  approvalRunningNoticeMs?: number;
  sandbox?: BashSandboxConfig;
  elevated?: ExecElevatedDefaults;
  allowBackground?: boolean;
  scopeKey?: string;
  sessionKey?: string;
  messageProvider?: string;
  currentChannelId?: string;
  currentThreadTs?: string;
  accountId?: string;
  notifyOnExit?: boolean;
  notifyOnExitEmptySuccess?: boolean;
  cwd?: string;
};

ExecToolDefaults 是创建工具时的"出厂设置",而模型调用时传入的参数是"运行时覆盖"。两者的关系是:运行时参数不能越出出厂设置的边界,除非开启了 elevated 提权。这是整个权限模型的基础约定。

2.3 工厂函数的预计算阶段

createExecTool 在工厂阶段就完成了一批昂贵计算,避免每次调用重复计算:

// src/agents/bash-tools.exec.ts L151-201
export function createExecTool(
  defaults?: ExecToolDefaults,
): AgentTool<any, ExecToolDetails> {
  // 1. 计算后台超时窗口(clamp 到 [10, 120000] ms)
  const defaultBackgroundMs = clampWithDefault(
    defaults?.backgroundMs ?? readEnvInt("PI_BASH_YIELD_MS"),
    10_000,
    10,
    120_000,
  );

  // 2. 计算默认超时(1800秒 = 30分钟)
  const defaultTimeoutSec =
    typeof defaults?.timeoutSec === "number" && defaults.timeoutSec > 0
      ? defaults.timeoutSec
      : 1800;

  // 3. 解析 SafeBin 策略(含未剖析警告)
  const {
    safeBins,
    safeBinProfiles,
    trustedSafeBinDirs,
    unprofiledSafeBins,
    unprofiledInterpreterSafeBins,
  } = resolveExecSafeBinRuntimePolicy({
    local: {
      safeBins: defaults?.safeBins,
      safeBinTrustedDirs: defaults?.safeBinTrustedDirs,
      safeBinProfiles: defaults?.safeBinProfiles,
    },
    onWarning: (message) => {
      logInfo(message);
    },
  });

  // 4. 记录无 profile 的 SafeBin 警告
  if (unprofiledSafeBins.length > 0) {
    logInfo(
      `exec: ignoring unprofiled safeBins entries (${unprofiledSafeBins.toSorted().join(", ")}); use allowlist or define tools.exec.safeBinProfiles.<bin>`,
    );
  }
  if (unprofiledInterpreterSafeBins.length > 0) {
    logInfo(
      `exec: interpreter/runtime binaries in safeBins (${unprofiledInterpreterSafeBins.join(", ")}) are unsafe without explicit hardened profiles; prefer allowlist entries`,
    );
  }

  // 5. 从 sessionKey 解析 agentId
  const parsedAgentSession = parseAgentSessionKey(defaults?.sessionKey);
  const agentId =
    defaults?.agentId ??
    (parsedAgentSession ? resolveAgentIdFromSessionKey(defaults?.sessionKey) : undefined);
  // ...
}

这个设计把 "策略解析" 从 "请求处理" 中剥离出来。resolveExecSafeBinRuntimePolicy 会读取本地配置、合并全局 SAFE_BIN_PROFILES、验证每个 safeBin 条目是否有对应的 profile——这些都是可以复用的计算结果。


三、第一道闸门:Host 路由与提权裁决

每个 exec 请求首先要确定在哪里执行——这是 Host 路由,决定了后续所有安全策略的适用范围。

3.1 三种执行主机

OpenClaw 定义了三种执行主机:

// src/infra/exec-approvals.ts L10
export type ExecHost = "sandbox" | "gateway" | "node";
  • sandbox:在 Docker 容器内执行,最安全,默认选项
  • gateway:在宿主机(网关进程所在机器)上执行
  • node:在某个远程节点上执行(多节点分布式场景)

3.2 Host 裁决逻辑

// src/agents/bash-tools.exec.ts L307-319
const configuredHost = defaults?.host ?? "sandbox";
const sandboxHostConfigured = defaults?.host === "sandbox";
const requestedHost = normalizeExecHost(params.host) ?? null;
let host: ExecHost = requestedHost ?? configuredHost;

// 非提权模式下,运行时请求的 host 不得与出厂配置不同
if (!elevatedRequested && requestedHost && requestedHost !== configuredHost) {
  throw new Error(
    `exec host not allowed (requested ${renderExecHostLabel(requestedHost)}; ` +
      `configure tools.exec.host=${renderExecHostLabel(configuredHost)} to allow).`,
  );
}

// 提权模式下强制走 gateway(直接访问宿主机)
if (elevatedRequested) {
  host = "gateway";
}

关键约束:AI 模型不能自行切换执行主机。如果出厂配置是 sandbox,模型传入 host=gateway 会直接报错。唯一的例外是 elevated=true 模式——此时强制走 gateway,但需要通过独立的提权闸门。

3.3 提权闸门

// src/agents/bash-tools.exec.ts L248-303
const elevatedDefaults = defaults?.elevated;
const elevatedAllowed = Boolean(elevatedDefaults?.enabled && elevatedDefaults.allowed);
const elevatedDefaultMode =
  elevatedDefaults?.defaultLevel === "full"
    ? "full"
    : elevatedDefaults?.defaultLevel === "ask"
      ? "ask"
      : elevatedDefaults?.defaultLevel === "on"
        ? "ask"    // "on" 映射为 "ask",保守处理
        : "off";
const effectiveDefaultMode = elevatedAllowed ? elevatedDefaultMode : "off";

// 模型请求 elevated=true 时,映射为具体模式
const elevatedMode =
  typeof params.elevated === "boolean"
    ? params.elevated
      ? elevatedDefaultMode === "full"
        ? "full"
        : "ask"
      : "off"
    : effectiveDefaultMode;

const elevatedRequested = elevatedMode !== "off";

// 双重检查:enabled + allowed 两个门都要打开
if (elevatedRequested) {
  if (!elevatedDefaults?.enabled || !elevatedDefaults.allowed) {
    throw new Error(
      [
        `elevated is not available right now (runtime=${runtime}).`,
        `Failing gates: ${gates.join(", ")}`,
        "Fix-it keys:",
        "- tools.elevated.enabled",
        "- tools.elevated.allowFrom.<provider>",
        // ...
      ].filter(Boolean).join("\n"),
    );
  }
}

提权需要同时满足:

  1. tools.elevated.enabled = true(全局开关)
  2. tools.elevated.allowFrom.<provider> = true(按来源渠道授权)

full 模式下还会额外做 security = "full"ask = "off" 的强制覆盖,跳过所有后续安全检查——这是最高权限。


四、第二道闸门:安全模式(Security)与审批模式(Ask)的组合矩阵

OpenClaw 的命令安全策略由两个独立维度的交叉积构成:

// src/infra/exec-approvals.ts L11-12
export type ExecSecurity = "deny" | "allowlist" | "full";
export type ExecAsk = "off" | "on-miss" | "always";

Security 三级

  • deny:拒绝所有执行(最严格,适合只读代理)
  • allowlist:仅允许白名单中的命令
  • full:允许所有命令(与 elevated full 配合使用)

Ask 三级

  • off:无需审批,直接执行
  • on-miss:白名单未命中时请求审批
  • always:每次执行都需要审批

4.1 安全级别的 minSecurity 原则

// src/infra/exec-approvals.ts L547-554
export function minSecurity(a: ExecSecurity, b: ExecSecurity): ExecSecurity {
  const order: Record<ExecSecurity, number> = { deny: 0, allowlist: 1, full: 2 };
  return order[a] <= order[b] ? a : b;
}

export function maxAsk(a: ExecAsk, b: ExecAsk): ExecAsk {
  const order: Record<ExecAsk, number> = { off: 0, "on-miss": 1, always: 2 };
  return order[a] >= order[b] ? a : b;
}

这两个函数体现了 "Fail Closed" 哲学:

  • security两者中的最小值(越严格越优先)
  • ask两者中的最大值(越多审批越优先)

运行时模型可以请求更宽松的安全级别,但 minSecurity 保证了实际安全级别永远不会超过配置上限。

// src/agents/bash-tools.exec.ts L321-334
const configuredSecurity = defaults?.security ?? (host === "sandbox" ? "deny" : "allowlist");
const requestedSecurity = normalizeExecSecurity(params.security);
let security = minSecurity(configuredSecurity, requestedSecurity ?? configuredSecurity);

const configuredAsk = defaults?.ask ?? loadExecApprovals().defaults?.ask ?? "on-miss";
const requestedAsk = normalizeExecAsk(params.ask);
let ask = maxAsk(configuredAsk, requestedAsk ?? configuredAsk);

// elevated full 模式绕过所有审批
const bypassApprovals = elevatedRequested && elevatedMode === "full";
if (bypassApprovals) {
  ask = "off";
}

沙箱模式的特殊处理:当 host === "sandbox" 时,默认 security 为 "deny"——沙箱环境本身就是隔离的,不需要 allowlist 检查,直接拦截敏感命令注入即可。


五、第三道闸门:环境变量净化

5.1 沙箱与宿主机的分岔

环境变量处理在沙箱路径和宿主机路径上走不同的逻辑:

// src/agents/bash-tools.exec.ts L364-400
const inheritedBaseEnv = coerceEnv(process.env);
// 沙箱直接继承宿主机完整 env;gateway/node 需要净化
const baseEnv = host === "sandbox" ? inheritedBaseEnv : sanitizeHostBaseEnv(inheritedBaseEnv);

// 宿主机路径:在合并之前先验证模型提供的 env
if (host !== "sandbox" && params.env) {
  validateHostEnv(params.env);
}

const mergedEnv = params.env ? { ...baseEnv, ...params.env } : baseEnv;

// 沙箱路径:重建干净的 env(只保留 PATH + sandbox.env + params.env)
const env = sandbox
  ? buildSandboxEnv({
      defaultPath: DEFAULT_PATH,
      paramsEnv: params.env,
      sandboxEnv: sandbox.env,
      containerWorkdir: containerWorkdir ?? sandbox.containerWorkdir,
    })
  : mergedEnv;

这个设计很微妙:沙箱继承完整宿主机 env,然后再被 buildSandboxEnv 重建覆盖。为什么?因为 Docker exec 命令本身是在宿主机上发起的,宿主机 env 会影响 Docker 进程本身,但容器内的实际执行环境由 -e 参数注入,由 buildSandboxEnv 精确控制。

5.2 sanitizeHostBaseEnv:宿主机环境净化

// src/agents/bash-tools.exec-runtime.ts L40-54
export function sanitizeHostBaseEnv(env: Record<string, string>): Record<string, string> {
  const sanitized: Record<string, string> = {};
  for (const [key, value] of Object.entries(env)) {
    const upperKey = key.toUpperCase();
    if (upperKey === "PATH") {
      sanitized[key] = value;    // PATH 允许继承
      continue;
    }
    if (isDangerousHostEnvVarName(upperKey)) {
      continue;                  // 危险变量直接丢弃
    }
    sanitized[key] = value;
  }
  return sanitized;
}

5.3 validateHostEnv:模型提供 env 的强校验

// src/agents/bash-tools.exec-runtime.ts L57-76
export function validateHostEnv(env: Record<string, string>): void {
  for (const key of Object.keys(env)) {
    const upperKey = key.toUpperCase();

    // 1. 阻断已知危险变量(Fail Closed)
    if (isDangerousHostEnvVarName(upperKey)) {
      throw new Error(
        `Security Violation: Environment variable '${key}' is forbidden during host execution.`,
      );
    }

    // 2. 严格阻断 PATH 修改(防止二进制劫持)
    if (upperKey === "PATH") {
      throw new Error(
        "Security Violation: Custom 'PATH' variable is forbidden during host execution.",
      );
    }
  }
}

isDangerousHostEnvVarName 内部引用了一个 JSON 策略文件 host-env-security-policy.json,其中维护了需要阻断的环境变量名称列表和前缀列表(如 LD_DYLD_ 等动态链接器相关变量,它们可以被用于劫持共享库加载路径)。

这里对 PATH 的处理特别值得关注:净化函数允许继承 PATH,但验证函数禁止模型覆盖 PATH。这是因为宿主机继承的 PATH 是可信的,而模型注入的 PATH 可能包含恶意路径前缀,导致系统命令被劫持。

5.4 沙箱环境的精确重建

// src/agents/bash-tools.shared.ts L17-34
export function buildSandboxEnv(params: {
  defaultPath: string;
  paramsEnv?: Record<string, string>;
  sandboxEnv?: Record<string, string>;
  containerWorkdir: string;
}) {
  const env: Record<string, string> = {
    PATH: params.defaultPath,         // 固定为已知安全的默认 PATH
    HOME: params.containerWorkdir,    // HOME 指向容器工作目录
  };
  // sandbox.env 覆盖(管理员配置,可信)
  for (const [key, value] of Object.entries(params.sandboxEnv ?? {})) {
    env[key] = value;
  }
  // params.env 覆盖(模型提供,最后合并)
  for (const [key, value] of Object.entries(params.paramsEnv ?? {})) {
    env[key] = value;
  }
  return env;
}

沙箱环境从零重建:只有白纸黑字写进去的变量才存在于容器里。默认 PATH 是固定字符串,HOME 指向容器工作目录——这防止了容器内进程访问宿主机家目录的可能。

5.5 Docker 命令构建中的 PATH 处理

// src/agents/bash-tools.shared.ts L49-87
export function buildDockerExecArgs(params: {
  containerName: string;
  command: string;
  workdir?: string;
  env: Record<string, string>;
  tty: boolean;
}) {
  const args = ["exec", "-i"];
  // ...
  for (const [key, value] of Object.entries(params.env)) {
    // 跳过 PATH——Windows 宿主机 PATH 里有反斜杠路径,
    // 通过 -e 传入会毒化 Docker 的可执行文件查找
    if (key === "PATH") {
      continue;
    }
    args.push("-e", `${key}=${value}`);
  }
  const hasCustomPath = typeof params.env.PATH === "string" && params.env.PATH.length > 0;
  if (hasCustomPath) {
    // 通过特殊的 OPENCLAW_PREPEND_PATH 间接传递,避免插值到 shell 命令里
    args.push("-e", `OPENCLAW_PREPEND_PATH=${params.env.PATH}`);
  }
  // login shell (-l) 会 source /etc/profile 重置 PATH,
  // 所以在 profile 之后再 export 我们的 PATH
  const pathExport = hasCustomPath
    ? 'export PATH="${OPENCLAW_PREPEND_PATH}:$PATH"; unset OPENCLAW_PREPEND_PATH; '
    : "";
  args.push(params.containerName, "/bin/sh", "-lc", `${pathExport}${params.command}`);
  return args;
}

这个 PATH 处理方案解决了一个跨平台陷阱:Windows 宿主机的 PATH 包含反斜杠路径(C:\Windows\System32),直接通过 -e PATH=... 传给 Docker 会导致 Linux 容器里的 sh 找不到。通过 OPENCLAW_PREPEND_PATH 中转,在 -lc 的 shell 脚本里再合并,既保留了路径,又避免了格式冲突。


六、第四道闸门:Shell 语法分析

在评估白名单之前,需要先把命令字符串解析成结构化的执行段列表。这是 analyzeShellCommandsplitShellPipeline 的职责。

6.1 命令链分割

// src/infra/exec-approvals-analysis.ts L24-31
export type ExecCommandAnalysis = {
  ok: boolean;
  reason?: string;
  segments: ExecCommandSegment[];
  chains?: ExecCommandSegment[][];  // 按链操作符分组(&&, ||, ;)
};

对于 cmd1 && cmd2 || cmd3; cmd4 这样的命令链,解析器需要:

  1. 识别 &&||; 这三种链操作符
  2. 把每个独立命令提取为一个 ExecCommandSegment
  3. 对每个 segment 做 argv 解析和可执行文件路径解析
// src/infra/exec-approvals-allowlist.ts L530-610
export function evaluateShellAllowlist(
  params: { command: string; env?: NodeJS.ProcessEnv } & ExecAllowlistContext,
): ExecAllowlistAnalysis {
  // 保守策略:行续接符(\<newline>)语义复杂,直接返回失败
  if (hasShellLineContinuation(params.command)) {
    return analysisFailure();
  }

  // Windows 平台不走链分割(PowerShell 解析规则不同)
  const chainParts = isWindowsPlatform(params.platform) ? null : splitCommandChain(params.command);

  if (!chainParts) {
    // 无链操作符:直接分析单个命令
    const analysis = analyzeShellCommand({ command: params.command, ... });
    const evaluation = evaluateExecAllowlist({ analysis, ...allowlistContext });
    return { analysisOk: true, allowlistSatisfied: evaluation.allowlistSatisfied, ... };
  }

  // 有链操作符:每个 part 单独分析,全部通过才算 satisfied
  for (const part of chainParts) {
    const analysis = analyzeShellCommand({ command: part, ... });
    const evaluation = evaluateExecAllowlist({ analysis, ...allowlistContext });
    if (!evaluation.allowlistSatisfied) {
      return { analysisOk: true, allowlistSatisfied: false, ... };
    }
  }
  return { analysisOk: true, allowlistSatisfied: true, ... };
}

链式命令的全通过原则cmd1 && cmd2 中,只要 cmd2 不在白名单里,整个命令就会被拒绝或触发审批。这防止了通过链接一个合法命令来"携带"一个恶意命令绕过检查。

6.2 Heredoc 的特殊处理

shell 的 heredoc(<<EOF)是解析器最复杂的部分之一:

// src/infra/exec-approvals-analysis.ts L80-200(片段)
// 解析 heredoc 的定界符,支持带引号的定界符(禁止展开)和不带引号(允许展开)
const parseHeredocDelimiter = (source, start) => {
  // ...
  if (first === "'" || first === '"') {
    // 带引号的定界符:内容不展开(安全)
    return { delimiter, end: i + 1, quoted: true };
  }
  // 不带引号的定界符:内容可能含 $() 展开
  return { delimiter, end: i, quoted: false };
};

// 在 heredoc 体内检测命令替换
const hasUnquotedHeredocExpansionToken = (line: string): boolean => {
  for (let i = 0; i < line.length; i++) {
    const ch = line[i];
    if (ch === "`" && !isEscapedInHeredocLine(line, i)) {
      return true;   // 反引号命令替换
    }
    if (ch === "$" && !isEscapedInHeredocLine(line, i)) {
      const next = line[i + 1];
      if (next === "(" || next === "{") {
        return true;  // $() 或 ${} 展开
      }
    }
  }
  return false;
};

当 heredoc 定界符不带引号时(如 cat <<EOF),heredoc 体内可以包含 $(...) 命令替换。解析器会检测这种情况,并在 processGatewayAllowlist 中强制触发审批:

// src/agents/bash-tools.exec-host-gateway.ts L123-141
const hasHeredocSegment = allowlistEval.segments.some((segment) =>
  segment.argv.some((token) => token.startsWith("<<")),
);
const requiresHeredocApproval =
  hostSecurity === "allowlist" && analysisOk && allowlistSatisfied && hasHeredocSegment;

if (requiresHeredocApproval) {
  params.warnings.push(
    "Warning: heredoc execution requires explicit approval in allowlist mode.",
  );
}

即使 heredoc 命令本身(如 cat)在白名单里,只要检测到 heredoc,也会触发审批。这堵住了通过 heredoc 注入任意代码的漏洞。


七、第五道闸门:白名单评估与 SafeBin 机制

7.1 三源匹配

白名单评估的核心函数 evaluateSegments 对每个命令段做三路并行匹配:

// src/infra/exec-approvals-allowlist.ts L198-271
function evaluateSegments(segments, params): {satisfied, matches, segmentSatisfiedBy} {
  const matches: ExecAllowlistEntry[] = [];
  const skillBinTrust = buildSkillBinTrustIndex(params.skillBins);
  const allowSkills = params.autoAllowSkills === true && skillBinTrust.size > 0;
  const segmentSatisfiedBy: ExecSegmentSatisfiedBy[] = [];

  const satisfied = segments.every((segment) => {
    // 路由1:白名单条目直接匹配
    const match = executableMatch ?? shellScriptMatch;

    // 路由2:SafeBin 检查(受信二进制 + 参数 profile)
    const safe = isSafeBinUsage({ argv, resolution, safeBins, safeBinProfiles, ... });

    // 路由3:Skill 自动授权(来自 ClawHub 安装的技能二进制)
    const skillAllow = isSkillAutoAllowedSegment({ segment, allowSkills, skillBinTrust });

    const by: ExecSegmentSatisfiedBy = match
      ? "allowlist"
      : safe
        ? "safeBins"
        : skillAllow
          ? "skills"
          : null;

    segmentSatisfiedBy.push(by);
    return Boolean(by);
  });

  return { satisfied, matches, segmentSatisfiedBy };
}

三源的优先级:allowlist > safeBins > skills。segmentSatisfiedBy 数组记录了每个命令段是被哪种机制放行的,这对审计日志极为重要。

7.2 SafeBin 的双重验证

SafeBin 是一种特殊的快速放行机制——对于已知安全的工具(如 gitnpmcat),只要路径来自可信目录、参数符合 profile,就不需要显式写入 allowlist。

// src/infra/exec-approvals-allowlist.ts L51-96
export function isSafeBinUsage(params: {
  argv: string[];
  resolution: CommandResolution | null;
  safeBins: Set<string>;
  platform?: string | null;
  trustedSafeBinDirs?: ReadonlySet<string>;
  safeBinProfiles?: Readonly<Record<string, SafeBinProfile>>;
}): boolean {
  // Windows 平台保守处理:PowerShell 解析规则不同,不走 SafeBin
  if (isWindowsPlatform(params.platform ?? process.platform)) {
    return false;
  }
  if (params.safeBins.size === 0) {
    return false;
  }

  const execName = resolution?.executableName?.toLowerCase();
  if (!execName || !params.safeBins.has(execName)) {
    return false;   // 不在 safeBins 集合里
  }

  // 路径必须来自受信目录
  if (
    !isTrustedPath({
      resolvedPath: resolution.resolvedPath,
      trustedDirs: params.trustedSafeBinDirs,
    })
  ) {
    return false;
  }

  // 参数必须通过 profile 验证
  const profile = safeBinProfiles[execName];
  if (!profile) {
    return false;   // 没有 profile 就不允许
  }
  return validateSafeBinArgv(argv.slice(1), profile);
}

路径可信判断 + 参数 profile 验证构成了双重屏障:即使攻击者在某个目录放了一个伪装成 git 的恶意二进制,路径信任检查也会拒绝它;即使路径合法,如果参数带有危险 flag(如 --exec-c),profile 验证也会拒绝。

7.3 allow-always 时的模式提取

当用户批准"永远允许"时,resolveAllowAlwaysPatterns 会从命令中提取应该持久化的白名单模式:

// src/infra/exec-approvals-allowlist.ts L507-525
export function resolveAllowAlwaysPatterns(params: {
  segments: ExecCommandSegment[];
  cwd?: string;
  env?: NodeJS.ProcessEnv;
  platform?: string | null;
}): string[] {
  const patterns = new Set<string>();
  for (const segment of params.segments) {
    collectAllowAlwaysPatterns({
      segment,
      cwd: params.cwd,
      env: params.env,
      platform: params.platform,
      depth: 0,
      out: patterns,
    });
  }
  return Array.from(patterns);
}

collectAllowAlwaysPatterns 会展开 shell wrapper(如 zsh -lc "git status"),递归提取内层实际执行的可执行文件路径。这样持久化的是 git 的绝对路径,而不是 zsh 的路径——确保白名单尽可能精细。


八、第六道闸门:混淆检测

OpenClaw 内置了一个专门针对 AI 生成命令的混淆检测器,它的背景是 Issue #8592——AI 模型有时会生成绕过白名单检查的混淆命令。

8.1 检测模式

// src/infra/exec-obfuscation-detect.ts L92-169
const OBFUSCATION_PATTERNS: ObfuscationPattern[] = [
  {
    id: "base64-pipe-exec",
    description: "Base64 decode piped to shell execution",
    regex: /base64\s+(?:-d|--decode)\b.*\|\s*(?:sh|bash|zsh|dash|ksh|fish)\b/i,
  },
  {
    id: "hex-pipe-exec",
    description: "Hex decode (xxd) piped to shell execution",
    regex: /xxd\s+-r\b.*\|\s*(?:sh|bash|zsh|dash|ksh|fish)\b/i,
  },
  {
    id: "printf-pipe-exec",
    description: "printf with escape sequences piped to shell execution",
    regex: /printf\s+.*\\x[0-9a-f]{2}.*\|\s*(?:sh|bash|zsh|dash|ksh|fish)\b/i,
  },
  {
    id: "eval-decode",
    description: "eval with encoded/decoded input",
    regex: /eval\s+.*(?:base64|xxd|printf|decode)/i,
  },
  {
    id: "pipe-to-shell",
    description: "Content piped directly to shell interpreter",
    regex: /\|\s*(?:sh|bash|zsh|dash|ksh|fish)\b(?:\s+[^|;\n\r]+)?\s*$/im,
  },
  {
    id: "octal-escape",
    description: "Bash octal escape sequences (potential command obfuscation)",
    regex: /\$'(?:[^']*\\[0-7]{3}){2,}/,
  },
  {
    id: "hex-escape",
    description: "Bash hex escape sequences (potential command obfuscation)",
    regex: /\$'(?:[^']*\\x[0-9a-fA-F]{2}){2,}/,
  },
  {
    id: "curl-pipe-shell",
    description: "Remote content (curl/wget) piped to shell execution",
    regex: /(?:curl|wget)\s+.*\|\s*(?:sh|bash|zsh|dash|ksh|fish)\b/i,
  },
  {
    id: "var-expansion-obfuscation",
    description: "Variable assignment chain with expansion (potential obfuscation)",
    regex: /(?:[a-zA-Z_]\w{0,2}=[^;\s]+\s*;\s*){2,}[^$]*\$(?:[a-zA-Z_]|\{[a-zA-Z_])/,
  },
  // ...共 14 个模式
];

8.2 不可见 Unicode 清洗

// src/infra/exec-obfuscation-detect.ts L22-90
const INVISIBLE_UNICODE_CODE_POINTS = new Set<number>([
  0x00ad,   // SOFT HYPHEN
  0x034f,   // COMBINING GRAPHEME JOINER
  0x061c,   // ARABIC LETTER MARK
  0xfeff,   // ZERO WIDTH NO-BREAK SPACE (BOM)
  0x200b,   // ZERO WIDTH SPACE
  0x200c,   // ZERO WIDTH NON-JOINER
  0x200d,   // ZERO WIDTH JOINER
  // ... 共 300+ 个 Unicode 隐形字符
  ...Array.from({ length: 95 }, (_unused, index) => 0xe0020 + index),   // Tags block
  ...Array.from({ length: 240 }, (_unused, index) => 0xe0100 + index),  // Variation Selectors Supplement
]);

function stripInvisibleUnicode(command: string): string {
  return Array.from(command)
    .filter((char) => !INVISIBLE_UNICODE_CODE_POINTS.has(char.codePointAt(0) ?? -1))
    .join("");
}

export function detectCommandObfuscation(command: string): ObfuscationDetection {
  // ...
  // 先做 NFKC 规范化,再去除隐形字符
  const normalizedCommand = stripInvisibleUnicode(command.normalize("NFKC"));
  // 在规范化后的字符串上匹配
  for (const pattern of OBFUSCATION_PATTERNS) {
    if (!pattern.regex.test(normalizedCommand)) {
      continue;
    }
    // ...
  }
}

NFKC 规范化 + 隐形字符过滤是专门针对"视觉欺骗"攻击的:攻击者可能在 base64 中间插入零宽字符,让正则表达式看不到 base64 完整词,但 shell 执行时自动忽略这些字符。规范化后再检测,可以挡住这类攻击。

8.3 合法例外:SAFE_CURL_PIPE_URLS

curl-pipe-shell 是常见的安装脚本模式(如 curl https://bun.sh/install | bash)。为了不误伤合法用途,OpenClaw 维护了一个安全 URL 白名单:

// src/infra/exec-obfuscation-detect.ts L171-180
const SAFE_CURL_PIPE_URLS = [
  { host: "brew.sh" },
  { host: "get.pnpm.io" },
  { host: "bun.sh", pathPrefix: "/install" },
  { host: "sh.rustup.rs" },
  { host: "get.docker.com" },
  { host: "install.python-poetry.org" },
  { host: "raw.githubusercontent.com", pathPrefix: "/Homebrew" },
  { host: "raw.githubusercontent.com", pathPrefix: "/nvm-sh/nvm" },
];

这些是社区广泛信任的安装脚本来源。当 curl-pipe-shell 模式匹配,但 URL 精确匹配这个白名单时,不触发混淆告警。


九、第七道闸门:审批流程

如果所有静态检查都通过,但 ask 策略要求人工审批,命令会进入异步审批流程。

9.1 审批请求创建

// src/agents/bash-tools.exec-host-gateway.ts L143-178
if (requiresAsk) {
  const requestArgs = buildDefaultExecApprovalRequestArgs({
    warnings: params.warnings,
    approvalRunningNoticeMs: params.approvalRunningNoticeMs,
    createApprovalSlug,
    turnSourceChannel: params.turnSourceChannel,
    turnSourceAccountId: params.turnSourceAccountId,
  });

  const {
    approvalId,    // UUID,完整 ID
    approvalSlug,  // 8 字符短码,用于用户输入
    warningText,
    expiresAtMs,   // 默认 120 秒后过期
    preResolvedDecision,   // 是否已有预决定(如来自 socket)
    initiatingSurface,     // 哪个平台发起的(telegram/discord/...)
    sentApproverDms,       // 是否已发 DM 给审批人
    unavailableReason,     // 为什么审批渠道不可用
  } = await createAndRegisterDefaultExecApprovalRequest({
    ...requestArgs,
    register: registerGatewayApproval,
  });

  // 立即返回 "approval-pending" 工具结果给模型
  return {
    pendingResult: buildExecApprovalPendingToolResult({ ... }),
  };
}

审批流程是非阻塞的:创建审批请求后立即返回 approval-pending 状态给模型,告知模型"命令需要审批,等待人工响应"。后续等待和执行在独立的异步协程中进行。

9.2 审批决策回调

// src/agents/bash-tools.exec-host-gateway.ts L191-294
void (async () => {
  // 等待审批决定(最多 120 秒)
  const decision = await resolveApprovalDecisionOrUndefined({
    approvalId,
    preResolvedDecision,
    onFailure: () =>
      void sendExecApprovalFollowupResult(
        followupTarget,
        `Exec denied (gateway id=${approvalId}, approval-request-failed): ${params.command}`,
      ),
  });
  if (decision === undefined) {
    return;  // 等待超时或请求失败
  }

  const { baseDecision, approvedByAsk, deniedReason } = createExecApprovalDecisionState({
    decision,
    askFallback,
    obfuscationDetected: obfuscation.detected,
  });

  // 超时回退:如果 askFallback=allowlist 且命令在白名单里,自动批准
  if (baseDecision.timedOut && askFallback === "allowlist") {
    if (!analysisOk || !allowlistSatisfied) {
      deniedReason = "approval-timeout (allowlist-miss)";
    } else {
      approvedByAsk = true;  // 超时后白名单命令自动放行
    }
  } else if (decision === "allow-once") {
    approvedByAsk = true;
  } else if (decision === "allow-always") {
    approvedByAsk = true;
    // 持久化白名单
    if (hostSecurity === "allowlist") {
      const patterns = resolveAllowAlwaysPatterns({ segments, cwd, env, platform });
      for (const pattern of patterns) {
        addAllowlistEntry(approvals.file, params.agentId, pattern);
      }
    }
  }

  if (deniedReason) {
    await sendExecApprovalFollowupResult(followupTarget, `Exec denied (gateway ...)`);
    return;
  }

  // 通过:执行命令
  const run = await runExecProcess({ ... });
  markBackgrounded(run.session);
  const outcome = await run.promise;
  // 发送执行结果给用户
  await sendExecApprovalFollowupResult(followupTarget, summary);
})();

审批结果对应的三种后续行为:

  • allow-once:执行一次,不写入白名单
  • allow-always:执行 + 持久化白名单(通过 addAllowlistEntry
  • deny:拒绝,发送拒绝通知

超时回退askFallback)是一个重要的降级机制:如果审批人 120 秒内没有响应,askFallback=allowlist 配置可以让白名单命令自动通过,避免因无人审批而阻塞 AI 的正常工作。


十、进程生命周期:从 Spawn 到 ProcessSession

通过所有安全闸门后,终于进入 runExecProcess——实际的进程创建阶段。

10.1 ProcessSession:进程生命周期的载体

// src/agents/bash-process-registry.ts L28-55
export interface ProcessSession {
  id: string;               // 随机生成的会话 ID
  command: string;          // 原始命令(用于显示/日志)
  scopeKey?: string;        // 所属作用域
  sessionKey?: string;      // 所属 agent 会话
  notifyOnExit?: boolean;   // 后台退出时是否通知
  child?: ChildProcessWithoutNullStreams;
  stdin?: SessionStdin;
  pid?: number;
  startedAt: number;
  cwd?: string;
  maxOutputChars: number;           // 最大输出缓冲(默认 200KB)
  pendingMaxOutputChars?: number;   // pending 状态最大输出(默认 30KB)
  totalOutputChars: number;         // 总输出字符计数
  pendingStdout: string[];          // pending 输出缓冲
  pendingStderr: string[];
  pendingStdoutChars: number;
  pendingStderrChars: number;
  aggregated: string;               // 完整聚合输出(受 maxOutputChars 截断)
  tail: string;                     // 最近 2000 字符(用于通知摘要)
  exitCode?: number | null;
  exitSignal?: NodeJS.Signals | number | null;
  exited: boolean;
  truncated: boolean;               // 输出是否被截断
  backgrounded: boolean;            // 是否已进入后台模式
}

10.2 Spawn 规格计算

// src/agents/bash-tools.exec-runtime.ts L388-436
const spawnSpec = (() => {
  if (opts.sandbox) {
    // 沙箱路径:封装为 docker exec 命令
    return {
      mode: "child" as const,
      argv: [
        "docker",
        ...buildDockerExecArgs({
          containerName: opts.sandbox.containerName,
          command: execCommand,
          workdir: opts.containerWorkdir ?? opts.sandbox.containerWorkdir,
          env: shellRuntimeEnv,
          tty: opts.usePty,
        }),
      ],
      env: process.env,   // docker 进程本身继承宿主机 env
      stdinMode: opts.usePty ? "pipe-open" as const : "pipe-closed" as const,
    };
  }
  const { shell, args: shellArgs } = getShellConfig();
  const childArgv = [shell, ...shellArgs, execCommand];
  if (opts.usePty) {
    return {
      mode: "pty" as const,
      ptyCommand: execCommand,
      childFallbackArgv: childArgv,  // PTY 失败时的降级方案
      env: shellRuntimeEnv,
      stdinMode: "pipe-open" as const,
    };
  }
  return {
    mode: "child" as const,
    argv: childArgv,
    env: shellRuntimeEnv,
    stdinMode: "pipe-closed" as const,  // 非 PTY 非沙箱:关闭 stdin
  };
})();

三条执行路径:

  1. sandboxdocker exec -i [-t] -w workdir -e KEY=VAL ... container /bin/sh -lc cmd
  2. PTY 模式:通过 node-pty 启动伪终端,支持颜色输出、光标控制等 TTY 特性
  3. 普通子进程/bin/zsh -c cmd,关闭 stdin,标准管道传输

10.3 PTY 的 DSR 响应

当使用 PTY 模式运行时,有个微妙的细节:终端应用经常发送 DSR(Device Status Report)请求来查询光标位置,如果没有响应,应用会卡住等待:

// src/agents/bash-tools.exec-runtime.ts L442-454
const onSupervisorStdout = (chunk: string) => {
  if (usingPty) {
    const { cleaned, requests } = stripDsrRequests(chunk);
    if (requests > 0 && managedRun?.stdin) {
      for (let i = 0; i < requests; i += 1) {
        // 回复光标位置响应(固定值)
        managedRun.stdin.write(cursorResponse);
      }
    }
    handleStdout(cleaned);
    return;
  }
  handleStdout(chunk);
};

stripDsrRequests 从输出流中移除 DSR 请求序列(ESC [6n),同时向进程 stdin 写入固定的光标位置响应。这让 coding agent 类工具(如 claudecodex)在 PTY 模式下能正常运行,而不会因为终端响应缺失而挂起。

10.4 退出码的语义区分

// src/agents/bash-tools.exec-runtime.ts L520-587
const promise = managedRun.wait().then((exit): ExecProcessOutcome => {
  const exitCode = exit.exitCode ?? 0;
  // exit code 126: not executable(权限问题)
  // exit code 127: command not found(命令不存在)
  // 这两种是不可恢复的基础设施失败,不应被视为"正常退出"
  const isShellFailure = exitCode === 126 || exitCode === 127;
  const status: "completed" | "failed" =
    isNormalExit && !isShellFailure ? "completed" : "failed";

  markExited(session, exit.exitCode, exit.exitSignal, status);
  maybeNotifyOnExit(session, status);

  if (status === "completed") {
    const exitMsg = exitCode !== 0 ? `\n\n(Command exited with code ${exitCode})` : "";
    return { status: "completed", exitCode, ... };
  }

  const reason = isShellFailure
    ? exitCode === 127
      ? "Command not found"
      : "Command not executable (permission denied)"
    : exit.reason === "overall-timeout"
      ? `Command timed out after ${opts.timeoutSec} seconds. ...`
      : exit.reason === "no-output-timeout"
        ? "Command timed out waiting for output"
        : exit.exitSignal != null
          ? `Command aborted by signal ${exit.exitSignal}`
          : "Command aborted before exit code was captured";
  return { status: "failed", reason: aggregated ? `${aggregated}\n\n${reason}` : reason, ... };
});

退出码 126 和 127 被特殊处理为 failed(即使进程"正常退出"),因为它们代表执行环境本身的问题,而不是命令逻辑失败。模型看到这两种错误应该纠正执行环境,而不是继续重试。


十一、后台模式:yieldMs 与进程注册表

11.1 Background 机制设计

OpenClaw 的 exec 支持两种后台模式:

  • background=true:立即后台,不等待任何输出
  • yieldMs=N:等待 N 毫秒的输出窗口后后台
// src/agents/bash-tools.exec.ts L491-593
let yielded = false;
let yieldTimer: NodeJS.Timeout | null = null;

// abort 信号不杀死已后台的进程
const onAbortSignal = () => {
  if (yielded || run.session.backgrounded) {
    return;  // 已后台,忽略 abort
  }
  run.kill();
};

return new Promise<AgentToolResult<ExecToolDetails>>((resolve, reject) => {
  const resolveRunning = () =>
    resolve({
      content: [{
        type: "text",
        text: `Command still running (session ${run.session.id}, pid ${run.session.pid ?? "n/a"}).
               Use process (list/poll/log/write/kill/clear/remove) for follow-up.`,
      }],
      details: { status: "running", sessionId: run.session.id, ... },
    });

  const onYieldNow = () => {
    if (yielded) return;
    yielded = true;
    markBackgrounded(run.session);
    resolveRunning();  // 立即 resolve 工具调用,返回 "running" 状态
  };

  if (allowBackground && yieldWindow !== null) {
    if (yieldWindow === 0) {
      onYieldNow();  // background=true 时立即 yield
    } else {
      yieldTimer = setTimeout(() => {
        if (yielded) return;
        yielded = true;
        markBackgrounded(run.session);
        resolveRunning();
      }, yieldWindow);  // 超时后 yield
    }
  }

  // 如果进程在 yieldWindow 内就结束了,取消 timer,同步返回结果
  run.promise.then((outcome) => {
    if (yieldTimer) clearTimeout(yieldTimer);
    if (yielded || run.session.backgrounded) return;  // 已后台,忽略
    // 同步完成路径...
    resolve({ ... });
  });
});

yieldedrun.session.backgrounded 是两个独立的状态标志:

  • yielded:工具调用层面已 yield(Promise 已 resolve)
  • backgrounded:进程注册表层面已标记为后台

两者通常同步设置,但分开维护是为了让 abort signal 处理器能正确判断:工具调用已完成但进程仍在运行时,不应该因为父 session 结束而杀死子进程。

11.2 ProcessSession 注册表

// src/agents/bash-process-registry.ts L73-89
const runningSessions = new Map<string, ProcessSession>();
const finishedSessions = new Map<string, FinishedSession>();

export function addSession(session: ProcessSession) {
  runningSessions.set(session.id, session);
  startSweeper();  // 确保 sweeper 在运行
}

所有运行中的进程都注册在 runningSessions,完成后的后台进程移入 finishedSessions(TTL 默认 30 分钟)。process 工具(list/poll/kill/write)就是通过查询这两个 Map 来操作后台进程的。

11.3 输出缓冲管理

// src/agents/bash-process-registry.ts L104-132
export function appendOutput(session: ProcessSession, stream: "stdout" | "stderr", chunk: string) {
  // pending 缓冲用于 "增量 poll":只读取上次 poll 之后的新输出
  const buffer = stream === "stdout" ? session.pendingStdout : session.pendingStderr;
  const pendingCap = Math.min(
    session.pendingMaxOutputChars ?? DEFAULT_PENDING_OUTPUT_CHARS,
    session.maxOutputChars,
  );
  buffer.push(chunk);
  let pendingChars = bufferChars + chunk.length;
  if (pendingChars > pendingCap) {
    session.truncated = true;
    pendingChars = capPendingBuffer(buffer, pendingChars, pendingCap);
  }
  // aggregated 保留完整输出(受 maxOutputChars 截断)
  const aggregated = trimWithCap(session.aggregated + chunk, session.maxOutputChars);
  session.truncated =
    session.truncated || aggregated.length < session.aggregated.length + chunk.length;
  session.aggregated = aggregated;
  // tail 只保留最近 2000 字符,用于通知摘要
  session.tail = tail(session.aggregated, 2000);
}

三层输出缓冲:

  • pendingStdout/pendingStderr:增量缓冲(poll 后清空),上限 30KB
  • aggregated:完整输出(尾部截断),上限 200KB
  • tail:最近 2000 字符(通知摘要,实时更新)

11.4 进程退出时的资源回收

// src/agents/bash-process-registry.ts L161-213
function moveToFinished(session: ProcessSession, status: ProcessStatus) {
  runningSessions.delete(session.id);

  // 清理 child process stdio,防止 FD 泄漏
  if (session.child) {
    session.child.stdin?.destroy?.();
    session.child.stdout?.destroy?.();
    session.child.stderr?.destroy?.();
    session.child.removeAllListeners();
    delete session.child;
  }

  // 清理 stdin wrapper
  if (session.stdin) {
    if (typeof session.stdin.destroy === "function") {
      session.stdin.destroy();
    } else if (typeof session.stdin.end === "function") {
      session.stdin.end();
    }
    delete session.stdin;
  }

  // 只有 backgrounded 的进程才保存到 finishedSessions
  if (!session.backgrounded) {
    return;
  }
  finishedSessions.set(session.id, {
    id: session.id,
    command: session.command,
    startedAt: session.startedAt,
    endedAt: Date.now(),
    status,
    exitCode: session.exitCode,
    // ...
  });
}

非后台进程的退出不保存到 finishedSessions。这是合理的:同步执行的命令,工具调用本身就是返回值的载体,不需要通过 process poll 来查询结果。只有后台命令才需要进入 "finished" 状态以供后续查询。


十二、预检:Shell Bleed 检测

在进入 spawn 之前,还有一个针对 AI 模型常见失误的预检:

// src/agents/bash-tools.exec.ts L55-149
function extractScriptTargetFromCommand(command: string) {
  // 仅支持简单形式:python file.py 或 node file.js
  const pythonMatch = raw.match(/^\s*(python3?|python)\s+(?:-[^\s]+\s+)*([^\s]+\.py)\b/i);
  const nodeMatch = raw.match(/^\s*(node)\s+(?:--[^\s]+\s+)*([^\s]+\.js)\b/i);
  // ...
}

async function validateScriptFileForShellBleed(params: {
  command: string;
  workdir: string;
}): Promise<void> {
  const target = extractScriptTargetFromCommand(params.command);
  if (!target) return;

  // 沙箱路径检查
  await assertSandboxPath({ filePath: absPath, cwd: params.workdir, root: params.workdir });

  // 最大 512KB,超出跳过检查
  if (stat.size > 512 * 1024) return;

  const content = await fs.readFile(absPath, "utf-8");

  // 检测 Python/JS 文件中的 shell 变量语法($VAR_NAME)
  const envVarRegex = /\$[A-Z_][A-Z0-9_]{1,}/g;
  const first = envVarRegex.exec(content);
  if (first) {
    throw new Error(
      [
        `exec preflight: detected likely shell variable injection (${token}) in ${target.kind} script: ${path.basename(absPath)}:${line}.`,
        target.kind === "python"
          ? `In Python, use os.environ.get(${JSON.stringify(token.slice(1))}) instead of raw ${token}.`
          : `In Node.js, use process.env[${JSON.stringify(token.slice(1))}] instead of raw ${token}.`,
        "(If this is inside a string literal on purpose, escape it or restructure the code.)",
      ].join("\n"),
    );
  }

  // 检测 JS 文件以 NODE 开头(shell 命令误写为 JS)
  if (target.kind === "node") {
    const firstNonEmpty = content.split(/\r?\n/).find((l) => l.trim().length > 0);
    if (firstNonEmpty && /^NODE\b/.test(firstNonEmpty)) {
      throw new Error(`exec preflight: JS file starts with shell syntax (${firstNonEmpty}).`);
    }
  }
}

这个检测器解决了一个真实的 AI 失误场景:AI 生成了 Python 文件,但文件里写的是 $HOME$PATH 这样的 shell 变量语法(Python 里应该用 os.environ.get('HOME'))。如果不预检,运行这个文件时 shell 会展开 $PATH 再传给 Python 解释器,导致诡异错误。


十三、全链路状态图

综合以上所有分析,一条 exec 命令的完整链路如下:

AI 模型生成 exec 工具调用
         │
         ▼
┌─────────────────────────────────┐
│  1. createExecTool (工厂预计算)   │
│     - SafeBin Policy 解析        │
│     - 默认参数规范化              │
└──────────────┬──────────────────┘
               │
               ▼
┌─────────────────────────────────┐
│  2. Host 路由与提权裁决          │
│     - sandbox / gateway / node  │
│     - elevated 双重闸门检查      │
│     - security / ask 交叉矩阵   │
└──────────────┬──────────────────┘
               │
               ▼
┌─────────────────────────────────┐
│  3. 环境变量净化                 │
│     - sanitizeHostBaseEnv       │
│     - validateHostEnv (拦截注入)│
│     - buildSandboxEnv (重建)    │
│     - PATH 特殊处理              │
└──────────────┬──────────────────┘
               │
               ▼ (host=gateway/node 才走)
┌─────────────────────────────────┐
│  4. Shell 语法分析               │
│     - splitShellPipeline        │
│     - 命令链分组 (&&/||/;)      │
│     - heredoc 解析与标记         │
│     - argv 解析 + 可执行文件解析 │
└──────────────┬──────────────────┘
               │
               ▼
┌─────────────────────────────────┐
│  5. 白名单 + SafeBin 评估        │
│     - allowlist 模式匹配         │
│     - SafeBin 路径信任 + profile │
│     - Skill 自动授权             │
│     - 三源优先级决策             │
└──────────────┬──────────────────┘
               │
               ▼
┌─────────────────────────────────┐
│  6. 混淆检测                    │
│     - NFKC 规范化 + 隐形 Unicode │
│     - 14 种混淆模式匹配          │
│     - SAFE_CURL_PIPE_URLS 白名单 │
└──────────────┬──────────────────┘
               │
        ┌──────┴──────┐
        │ requiresAsk? │
        └──────┬───────┘
    YES ◄──────┤ NO
    │          │
    ▼          ▼
┌───────┐  ┌──────────────────────────┐
│ 7.审批│  │  Shell Bleed 预检         │
│  流程 │  │  validateScriptFileFor   │
│       │  │  ShellBleed              │
│ await │  └──────────┬───────────────┘
│ 用户  │             │
│ 决策  │             ▼
│       │  ┌──────────────────────────┐
│allow  │  │  runExecProcess          │
│──────►│  │  - Spawn 规格计算         │
└───────┘  │  - supervisor.spawn      │
           │  - PTY/child 路径选择    │
           │  - DSR 响应处理          │
           └──────────┬───────────────┘
                      │
                      ▼
           ┌──────────────────────────┐
           │  yieldMs / background    │
           │  - 前台同步等待结束       │
           │  - 超时后台              │
           │  - 即时后台              │
           └──────────┬───────────────┘
                      │
                      ▼
           ┌──────────────────────────┐
           │  ProcessSession 生命周期  │
           │  - exit code 语义区分    │
           │  - 资源回收              │
           │  - notifyOnExit         │
           └──────────────────────────┘

十四、设计哲学总结

回顾整个链路,有几个核心设计哲学贯穿始终:

1. Fail Closed(关闭失败) 所有安全决策在不确定时倾向于拒绝:解析失败 → 不走白名单;分析不可靠 → 触发审批;超出范围 → 抛出错误。宁可误报,不可漏报。

2. 出厂配置优先于运行时覆盖 AI 模型只能在出厂设置划定的边界内行动。安全级别只能收紧,不能放开;HOST 只能按配置,不能自行切换;PATH 只能继承,不能注入。

3. 静态分析 + 运行时防护双层次 Shell 语法分析、白名单评估、混淆检测都是 静态分析——在进程启动之前完成。但环境变量净化、PATH 特殊处理、沙箱隔离是 运行时 防护——即使静态分析被绕过,运行时防护也能兜底。

4. 审计可追溯 每个命令段被哪种机制放行(allowlist/safeBins/skills)都有记录;allowlist 命中记录了 lastUsedAtlastUsedCommand;审批流程有完整的 approvalId + approvalSlug 追踪链。

5. 可组合的安全策略 security × ask × host × elevated 形成了一个完整的策略矩阵,管理员可以针对不同 agent、不同来源渠道精细配置。这是 OpenClaw 多租户、多渠道场景下安全可扩展的关键设计。


附:核心源文件索引

本文涉及的主要源文件:

前端工程化基石:package.json 40+ 字段逐一拆解

每个前端项目的根目录下几乎都有一个 package.json,但你真的了解它的每个字段吗?本文将从基础字段高级配置,逐一拆解 package.json 中的所有字段,帮你彻底搞懂它。


一、必填字段

1.1 name — 包名

{
  "name": "@packageName/sdk"
}

规则:

  • 长度不超过 214 个字符
  • 不能以 ._ 开头
  • 不能包含大写字母
  • 不能包含 URL 不安全字符(如空格、~ 等)
  • 支持 scope(作用域),格式为 @scope/name,常用于组织级别的包管理,例如 @vue/cli@babel/core

作用:
name 是包的唯一标识符。当你执行 npm install xxx 时,xxx 就是这个字段的值。配合 version,它们共同构成了包的"身份证"。


1.2 version — 版本号

{
  "version": "1.6.7"
}

必须遵循 Semantic Versioning(语义化版本) 规范,格式为 MAJOR.MINOR.PATCH

含义 示例场景
MAJOR 不兼容的 API 变更 重构了核心 API
MINOR 向下兼容的功能新增 新增了一个工具函数
PATCH 向下兼容的问题修复 修复了一个边界 Bug

还支持预发布标签:1.0.0-alpha.11.0.0-beta.21.0.0-rc.1


二、描述信息字段

2.1 description — 包描述

{
  "description": "packageDescription"
}

简短描述包的功能,会展示在 npm search 的搜索结果中,也是 npm 官网搜索排序的权重因子之一。

2.2 keywords — 关键词

{
  "keywords": ["cloud", "sdk", "vue", "plugin", "micro-frontend"]
}

字符串数组,用于 npm 官网的搜索优化(SEO),帮助其他开发者更快找到你的包。

2.3 homepage — 项目主页

{
  "homepage": "https://github.com/user/project#readme"
}

项目官网或文档地址,会展示在 npm 包详情页的侧边栏。

2.4 bugs — Bug 反馈地址

{
  "bugs": {
    "url": "https://github.com/user/project/issues",
    "email": "bugs@example.com"
  }
}

也可以简写为字符串:"bugs": "https://github.com/user/project/issues"

2.5 license — 开源协议

{
  "license": "MIT"
}

常见协议:

协议 特点
MIT 极其宽松,几乎无限制
Apache-2.0 允许商用,需保留版权,提供专利许可
GPL-3.0 传染性协议,衍生作品也需开源
ISC 类似 MIT,更简洁
UNLICENSED 私有包,不允许他人使用

2.6 author — 作者

{
  "author": {
    "name": "张三",
    "email": "zhangsan@example.com",
    "url": "https://zhangsan.dev"
  }
}

也支持简写形式:"author": "张三 <zhangsan@example.com> (https://zhangsan.dev)"

2.7 contributors — 贡献者

{
  "contributors": [
    { "name": "李四", "email": "lisi@example.com" },
    "王五 <wangwu@example.com>"
  ]
}

格式同 author,是一个数组。

2.8 funding — 赞助信息

{
  "funding": {
    "type": "opencollective",
    "url": "https://opencollective.com/project"
  }
}

也支持数组形式,用于声明多个赞助渠道。执行 npm fund 可查看项目的赞助信息。


三、入口文件字段

这是 package.json 中最核心也最容易混淆的一组字段,直接决定了别人引用你的包时,加载的是哪个文件。

3.1 main — CommonJS 入口

{
  "main": "dist/cloud-sdk.umd.js"
}

作用:
Node.js 和旧版打包工具默认读取的入口。当执行 require('your-package') 时,实际加载的就是 main 指向的文件。

3.2 module — ESModule 入口

{
  "module": "dist/cloud-sdk.esm.js"
}

作用:
这不是 Node.js 官方字段,而是由打包工具(Webpack、Rollup、Vite)约定的。当打包工具发现 module 字段时,会优先使用它,因为 ESM 格式支持 Tree Shaking,能有效减小打包体积。

3.3 browser — 浏览器入口

{
  "browser": "dist/cloud-sdk.browser.js"
}

当包需要在浏览器中运行,且浏览器版本与 Node 版本实现不同时使用。打包工具在构建浏览器端代码时会优先读取此字段。

也支持对象形式,用于替换特定模块:

{
  "browser": {
    "./lib/server-utils.js": "./lib/browser-utils.js",
    "fs": false
  }
}

3.4 types / typings — TypeScript 类型入口

{
  "types": "dist/index.d.ts"
}

指定 TypeScript 类型声明文件的入口路径。typestypings 等价,推荐用 types

3.5 exports — 条件导出(重点!)

{
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/cloud-sdk.esm.js",
      "require": "./dist/cloud-sdk.umd.js"
    },
    "./utils": {
      "types": "./dist/utils.d.ts",
      "import": "./dist/utils.esm.js",
      "require": "./dist/utils.cjs.js"
    }
  }
}

这是 Node.js 12.11+ 引入的现代模块解析方案,是 mainmodulebrowser 的"终极替代方案"。

核心能力:

特性 说明
条件导出 根据环境(import / require / node / browser / default)返回不同文件
子路径导出 允许 import { foo } from 'pkg/utils' 形式的子路径引用
封装隔离 未在 exports 中声明的路径,外部无法访问,保护内部实现

条件匹配的优先级(从上到下):

{
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "node": {
        "import": "./dist/node.mjs",
        "require": "./dist/node.cjs"
      },
      "browser": "./dist/browser.js",
      "default": "./dist/index.js"
    }
  }
}

注意: types 条件必须放在最前面,否则 TypeScript 可能无法正确解析类型。

3.6 type — 模块系统声明

{
  "type": "module"
}
含义
"module" .js 文件默认作为 ESModule 处理
"commonjs"(默认值) .js 文件默认作为 CommonJS 处理

设置为 "module" 后:

  • .js → ESM
  • .cjs → CommonJS(强制)
  • .mjs → ESM(强制)

四、文件管控字段

4.1 files — 发布包含的文件

{
  "files": ["dist", "README.md", "LICENSE"]
}

白名单机制,指定 npm publish 时需要包含的文件和目录。类似 .gitignore 的反向操作。

始终包含的文件(无法排除):

  • package.json
  • README(任何大小写和扩展名)
  • LICENSE / LICENCE
  • CHANGELOG
  • main 字段指向的文件

始终排除的文件(无法包含):

  • .git
  • node_modules
  • .npmrc
  • package-lock.json

技巧: 也可以用 .npmignore 做黑名单控制,但 files 字段优先级更高,两者同时存在时以 files 为准。

4.2 directories — 项目目录结构

{
  "directories": {
    "lib": "src/lib",
    "bin": "bin",
    "man": "man",
    "doc": "docs",
    "example": "examples",
    "test": "test"
  }
}

声明项目的目录结构。实际使用较少,主要是一种语义化描述。


五、脚本与命令字段

5.1 scripts — NPM 脚本

{
  "scripts": {
    "dev": "vite build --watch",
    "build": "vite build",
    "lint": "eslint src",
    "lint:fix": "eslint src --fix",
    "format": "prettier --write src",
    "prepare": "husky install",
    "preinstall": "npx only-allow pnpm"
  }
}

通过 npm run <script-name> 执行。部分脚本名有特殊含义:

生命周期脚本:

脚本名 触发时机
preinstall 安装依赖之前执行
install 安装依赖时执行
postinstall 安装依赖之后执行
prepare npm install 之后、npm publish 之前执行
prepublishOnly 仅在 npm publish 之前执行
prepack 打 tarball 之前(npm pack / npm publish
postpack 打 tarball 之后

pre/post 钩子:

任何自定义脚本都可以加 pre / post 前缀:

{
  "scripts": {
    "prebuild": "rimraf dist",
    "build": "vite build",
    "postbuild": "echo 构建完成"
  }
}

执行 npm run build 会依次执行:prebuildbuildpostbuild

注意: pnpm 和 yarn 现代版本默认不会自动执行 pre/post 钩子,需手动配置开启。

5.2 bin — 可执行文件

{
  "bin": {
    "create-uver": "./bin/create.js"
  }
}

当用户全局安装(npm install -g)或通过 npx 执行时,系统会创建软链接到 bin 指定的文件。

如果只有一个可执行文件,可以简写为:

{
  "name": "create-uver",
  "bin": "./bin/create.js"
}

此时命令名就是 name 字段的值。

5.3 man — 帮助手册

{
  "man": ["./man/doc.1", "./man/doc.2"]
}

指定 man 命令的文档文件路径,文件必须以数字结尾或以 .gz 压缩。


六、依赖管理字段

6.1 dependencies — 生产依赖

{
  "dependencies": {
    "lodash-es": "^4.17.21",
    "vue": "^3.4.0",
    "vue-router": "^4.5.0"
  }
}

项目运行时必须的依赖,npm install 默认安装,最终会被打包进产物中。

6.2 devDependencies — 开发依赖

{
  "devDependencies": {
    "typescript": "^5.3.3",
    "vite": "^6.3.5",
    "eslint": "^9.3.4",
    "prettier": "^3.2.5"
  }
}

仅开发阶段需要的依赖(构建工具、Linter、测试框架等)。其他项目安装你的包时不会安装 devDependencies

6.3 peerDependencies — 宿主依赖

{
  "peerDependencies": {
    "vue": "^3.0.0",
    "react": "^18.0.0"
  }
}

声明"我需要宿主环境提供这个依赖",而不是自己安装一份。最经典的场景是 UI 组件库 —— element-plus 声明 peerDependencies: { "vue": "^3.0.0" },因为它不应该自带一份 Vue。

npm 版本 行为
npm 3-6 仅发出警告
npm 7+ 自动安装 peerDependencies

6.4 peerDependenciesMeta — 宿主依赖元信息

{
  "peerDependencies": {
    "vue": "^3.0.0",
    "react": "^18.0.0"
  },
  "peerDependenciesMeta": {
    "react": {
      "optional": true
    }
  }
}

标记某个 peerDependency 为可选,未安装时不会报警告。

6.5 optionalDependencies — 可选依赖

{
  "optionalDependencies": {
    "fsevents": "^2.3.0"
  }
}

安装失败时不会导致整个 npm install 失败。典型场景:fsevents 仅在 macOS 下可用。

6.6 bundleDependencies / bundledDependencies — 捆绑依赖

{
  "bundleDependencies": ["lodash", "chalk"]
}

npm pack 时会将这些依赖打包进 tarball。适用于需要确保特定版本依赖的场景,或内网环境发布。

6.7 overrides(npm)/ resolutions(yarn)— 依赖覆盖

npm(overrides):

{
  "overrides": {
    "source-map": "^0.7.4"
  }
}

yarn(resolutions):

{
  "resolutions": {
    "source-map": "^0.7.4"
  }
}

pnpm(pnpm.overrides):

{
  "pnpm": {
    "overrides": {
      "source-map": "^0.7.4"
    }
  }
}

强制将依赖树中所有匹配的包替换为指定版本。常用于修复深层依赖的安全漏洞或兼容性问题。

版本号范围速查

符号 含义 示例 匹配范围
^ 兼容版本 ^1.2.3 >=1.2.3 <2.0.0
~ 近似版本 ~1.2.3 >=1.2.3 <1.3.0
>= 大于等于 >=1.2.3 >=1.2.3
* 任意版本 * 所有版本
无符号 精确版本 1.2.3 1.2.3
` ` ^1.0.0 || ^2.0.0 满足任一条件

七、发布配置字段

7.1 private — 私有包

{
  "private": true
}

设置为 true 后,npm publish 会直接拒绝发布。用于防止 monorepo 根目录或内部项目被意外发布到公共 npm。

7.2 publishConfig — 发布配置

{
  "publishConfig": {
    "registry": "http://jfrog.gdu-tech.com/artifactory/api/npm/gdu-npm-package/",
    "access": "public",
    "tag": "latest"
  }
}
字段 说明
registry 发布到指定 npm 仓库(私有源)
access "public""restricted",scope 包默认 restricted
tag 发布时的 dist-tag,默认 latest

7.3 repository — 仓库信息

{
  "repository": {
    "type": "git",
    "url": "https://github.com/user/project.git",
    "directory": "packages/cloud-sdk"
  }
}

directory 字段在 monorepo 中非常有用,指明包在仓库中的具体位置。

npm 官网会根据此字段在包详情页展示源码链接。


八、环境约束字段

8.1 engines — 运行环境要求

{
  "engines": {
    "node": ">=18.0.0",
    "pnpm": ">=9.15.0",
    "npm": ">=8.0.0"
  }
}

声明项目所需的 Node.js 和包管理器版本。默认仅作为建议,如需强制校验:

  • npm:.npmrc 中设置 engine-strict=true
  • yarn: 自动强制检查
  • pnpm: 自动强制检查

8.2 os — 操作系统限制

{
  "os": ["darwin", "linux", "!win32"]
}

限制包可运行的操作系统。! 前缀表示排除。

8.3 cpu — CPU 架构限制

{
  "cpu": ["x64", "arm64", "!ia32"]
}

限制包可运行的 CPU 架构。

8.4 packageManager — 指定包管理器

{
  "packageManager": "pnpm@9.15.0"
}

Node.js 16.9+ 引入的 Corepack 特性。声明项目使用的包管理器及精确版本,搭配 corepack enable,其他包管理器会被拦截。


九、Monorepo 相关字段

9.1 workspaces — 工作空间

npm/yarn:

{
  "workspaces": [
    "packages/*",
    "business/*"
  ]
}

pnpm 使用独立的 pnpm-workspace.yaml

packages:
  - 'packages/*'
  - 'business/*'

工作空间允许在一个仓库中管理多个包,共享 node_modules,实现包之间的互相引用。

9.2 pnpm — pnpm 专有配置

{
  "pnpm": {
    "overrides": {
      "source-map": "^0.7.4"
    },
    "peerDependencyRules": {
      "ignoreMissing": ["@babel/*"],
      "allowedVersions": {
        "vue": "3"
      }
    },
    "neverBuiltDependencies": ["fsevents"],
    "patchedDependencies": {
      "express@4.18.2": "patches/express@4.18.2.patch"
    }
  }
}

pnpm 的专属扩展配置项,功能非常丰富:

字段 说明
overrides 强制覆盖依赖版本
peerDependencyRules 控制 peerDep 检查行为
neverBuiltDependencies 跳过某些包的 postinstall 脚本
patchedDependencies 声明补丁文件,搭配 pnpm patch 使用

十、工具链配置字段

许多工具支持直接在 package.json 中配置,免去创建额外配置文件。

10.1 lint-staged

{
  "lint-staged": {
    "*.{js,ts,vue}": ["eslint --fix", "prettier --write"],
    "*.{json,md,yaml,yml}": ["prettier --write"]
  }
}

配合 husky 在 git commit 前对暂存文件执行 lint 和格式化。

10.2 browserslist

{
  "browserslist": [
    "> 1%",
    "last 2 versions",
    "not dead",
    "not ie 11"
  ]
}

声明目标浏览器范围,影响 Babel、PostCSS Autoprefixer、SWC 等工具的编译输出。

10.3 sideEffects

{
  "sideEffects": false
}

告知打包工具(Webpack/Rollup/Vite)该包的所有模块都没有副作用,可以安全 Tree Shaking。

也可以指定有副作用的文件:

{
  "sideEffects": ["*.css", "*.scss", "./src/polyfill.js"]
}

这是优化打包体积最关键的字段之一。如果你的库设置了 "sideEffects": false,使用者只 import 了一个函数,打包工具就敢放心地把其余代码全部删掉。

10.4 config

{
  "config": {
    "port": "8080"
  }
}

可以在 npm scripts 中通过 npm_package_config_port 环境变量读取,用户可以用 npm config set project:port 3000 覆盖。

10.5 其他工具内联配置

以下工具都支持在 package.json 中直接配置:

工具 字段名 说明
ESLint(旧版) eslintConfig ESLint 配置
Prettier prettier 代码格式化配置
Babel babel 编译器配置
Jest jest 测试框架配置
Stylelint stylelint CSS Lint 配置
commitlint commitlint Commit 消息规范
unplugin-auto-import auto-import 自动导入配置

十一、不常见但有用的字段

11.1 flat — 扁平化依赖(yarn)

{
  "flat": true
}

强制 yarn 安装依赖时使用扁平结构,如果有版本冲突会提示用户选择。

11.2 preferGlobal — 建议全局安装(已废弃)

{
  "preferGlobal": true
}

npm 5+ 已废弃此字段,但部分老项目可能还在使用。

11.3 deprecated — 废弃提示

不是在 package.json 中设置的字段,而是通过 npm deprecate 命令发布:

npm deprecate my-package@"<2.0.0" "请升级到 2.x 版本"

安装时会显示黄色警告。


十二、字段优先级总结

入口文件解析优先级

不同工具对入口字段的解析优先级不同:

Node.js(>=12.11):

exports > main

Webpack 5:

exports > browser > module > main

Vite / Rollup:

exports > module > main

TypeScript:

exports["."]["types"] > types > typings > main(.d.ts)

一张图看清全貌

package.json
├── 📋 基本信息
│   ├── name            # 包名
│   ├── version         # 版本号
│   ├── description     # 描述
│   ├── keywords        # 关键词
│   ├── license         # 协议
│   ├── author          # 作者
│   └── contributors    # 贡献者
│
├── 📦 入口文件
│   ├── main            # CJS 入口
│   ├── module          # ESM 入口
│   ├── browser         # 浏览器入口
│   ├── types           # TS 类型入口
│   ├── exports         # 条件导出(现代方案)
│   └── type            # 模块系统声明
│
├── 📁 文件管控
│   ├── files           # 发布白名单
│   └── directories     # 目录结构声明
│
├── ⚙️ 脚本与命令
│   ├── scripts         # NPM 脚本
│   ├── bin             # 可执行文件
│   └── man             # 帮助手册
│
├── 📚 依赖管理
│   ├── dependencies          # 生产依赖
│   ├── devDependencies       # 开发依赖
│   ├── peerDependencies      # 宿主依赖
│   ├── peerDependenciesMeta  # 宿主依赖元信息
│   ├── optionalDependencies  # 可选依赖
│   ├── bundleDependencies    # 捆绑依赖
│   └── overrides/resolutions # 依赖覆盖
│
├── 🚀 发布配置
│   ├── private         # 私有标记
│   ├── publishConfig   # 发布配置
│   └── repository      # 仓库信息
│
├── 🔒 环境约束
│   ├── engines         # Node/npm 版本要求
│   ├── os              # 操作系统限制
│   ├── cpu             # CPU 架构限制
│   └── packageManager  # 包管理器声明
│
├── 🏗️ Monorepo
│   ├── workspaces      # 工作空间
│   └── pnpm            # pnpm 专有配置
│
└── 🔧 工具链配置
    ├── lint-staged     # 暂存文件 lint
    ├── browserslist    # 目标浏览器
    ├── sideEffects     # 副作用声明
    └── config          # 自定义配置

十三、最佳实践

1. 库开发的标准 package.json 模板

{
  "name": "@scope/my-lib",
  "version": "1.0.0",
  "description": "A modern library",
  "type": "module",
  "main": "dist/index.cjs",
  "module": "dist/index.mjs",
  "types": "dist/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs"
    }
  },
  "files": ["dist"],
  "sideEffects": false,
  "scripts": {
    "build": "tsup src/index.ts --format cjs,esm --dts",
    "dev": "tsup src/index.ts --format cjs,esm --dts --watch",
    "lint": "eslint src",
    "test": "vitest"
  },
  "peerDependencies": {
    "vue": "^3.0.0"
  },
  "peerDependenciesMeta": {
    "vue": { "optional": true }
  },
  "engines": {
    "node": ">=18.0.0"
  },
  "publishConfig": {
    "access": "public"
  },
  "license": "MIT"
}

2. Monorepo 根目录模板

{
  "name": "my-monorepo",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "packageManager": "pnpm@9.15.0",
  "engines": {
    "node": ">=18.0.0"
  },
  "scripts": {
    "dev": "pnpm --filter app dev",
    "build": "pnpm -r build",
    "lint": "eslint .",
    "prepare": "husky install"
  },
  "devDependencies": {
    "eslint": "^9.0.0",
    "husky": "^9.0.0",
    "lint-staged": "^15.0.0",
    "prettier": "^3.0.0",
    "typescript": "^5.0.0"
  },
  "lint-staged": {
    "*.{js,ts,vue}": ["eslint --fix", "prettier --write"]
  }
}

3. 常见误区

误区 正解
vite/webpack 放到 dependencies 构建工具应放在 devDependencies
不设置 files 字段 会把整个项目(含源码)都发布上去
exportstypes 条件放在后面 TypeScript 要求 types 必须在第一个
不设置 sideEffects 使用者无法有效 Tree Shaking
不设置 engines 用户在低版本 Node 上可能出现诡异问题
不设置 private: true monorepo 根目录可能被意外 npm publish

结语

package.json 看似简单,实则承载了包的身份信息、入口解析、依赖管理、构建配置、发布流程等方方面面。理解每一个字段的含义和使用场景,不仅能帮你写出更规范的 npm 包,还能在排查 "模块找不到"、"类型丢失"、"打包体积过大" 等问题时快速定位根因。

希望这篇文章能成为你的 package.json 随身手册,收藏备用!


如果觉得有帮助,别忘了点个赞 👍 收藏一下,后续还会更新更多前端工程化干货。

鳌虾 AoCode:重新定义 AI 编程助手的下一代可视化工具

前言

在 AI 代码生成工具层出不穷的今天,程序员面临着一个核心问题:如何更高效、更精准地让 AI 理解我们的需求?传统的 AI 对话模式需要我们反复描述项目背景、手动关联各种文档和技能规范,这种模式不仅效率低下,还容易因为信息不完整导致生成结果与预期相差甚远。

鳌虾(AoCode) 正是为解决这些痛点而生。它通过可视化拖拽的方式,让开发者无需手敲冗长的 Prompt,即可自动生成高质量的 AI 编程指令。更重要的是,它能与项目中的技能文件(skills)无缝结合,让 AI 始终在统一的规范下生成代码,从根本上减少"幻觉"的产生。

GitHubgithub.com/zy1992829/a…


一、工具使用:零门槛上手,三步生成 AI 指令

1.1 组件拖拽,所见即所得

image.png

鳌虾提供了一个直观的可视化页面设计器。左侧是丰富的组件库,右侧是线框图骨架画布。开发者只需从左侧拖拽组件到画布中,即可快速搭建页面结构。

支持的组件包括:

  • 页面布局:单列、双列、左侧定宽、右侧定宽等多种布局容器
  • 基础组件:搜索栏、数据表格、表单区域、可编辑表格、详情区块
  • 自定义模块:支持纯文本自定义模块

每个组件都可以单独配置其属性和关联的业务字段,满足不同的业务需求。

1.2 智能读取项目技能文件

鳌虾支持自动扫描并读取项目中的技能文件。它会按照优先级自动探测以下目录:

.trae/skills  >  .trae/rules  >  .cursor/rules  >  .windsurf/rules  >  .aocode/rules  >  docs/rules

读取逻辑采用三态模式

  • 状态一:未找到任何技能文件 → 输出"您没有任何技能约束"
  • 状态二:找到文件但文件中没有 <rules>[CODE_RULES_START] 标签 → 静默处理,不输出任何内容
  • 状态三:找到文件且文件包含标签内容 → 自动提取并注入到 AI 指令中

这种设计确保了 AI 指令的精简性——只传递必要的信息,避免噪声干扰。

1.3 页面级技能分配

在鳌虾中,每个页面都可以独立绑定不同的技能文件。比如:

  • index.vue(列表页)绑定 page.md
  • edit.vue(编辑页)绑定 edit.md
  • look.vue(详情页)绑定 look.md

这样,不同类型的页面会自动带上各自的规范约束,生成结果更加精准。

1.4 一键生成 Clipboard 指令

image.png

配置完成后,点击**"生成 AI 指令"**按钮,鳌虾会自动生成一份结构化的指令文本,包含:

  • 功能目录和路径信息
  • 页面模块及布局顺序
  • 绑定的技能规范内容
  • API 基础路径

生成后直接复制到剪贴板,粘贴到 AI 对话窗口即可。


二、工具对比:鳌虾 vs 传统 AI 编程

对比维度 传统 AI 编程 鳌虾 AoCode
Prompt 输入 每次都要手敲完整描述 可视化配置,一键生成
技能规范传递 手动复制粘贴或反复提及 自动读取并注入
多页面一致性 每个页面都要重复描述项目背景 页面级技能分配,一劳永逸
信息完整性 容易遗漏关键约束条件 结构化输出,确保信息无遗漏
技能文件管理 依赖开发者自觉遵守 系统层面强制关联
学习成本 需要学习 Prompt 编写技巧 无需任何 Prompt 经验

2.1 传统模式的痛点

传统 AI 编程中,开发者常常面临这样的困境:

  1. 重复劳动:每次对话都要重新描述项目结构、技术栈、规范要求
  2. 信息不对称:AI 无法主动了解项目规范,容易产生"幻觉"
  3. 一致性差:不同对话生成的代码风格不统一,集成困难
  4. 维护成本高:项目规范变更后,需要手动更新所有历史 Prompt

2.2 鳌虾的解决方案

  1. 零 Prompt 编写:通过可视化配置替代手写文本,降低使用门槛
  2. 技能即规范:将项目规范写入技能文件(skills),AI 随时可读
  3. 上下文共享:一次配置,多页面复用,确保输出一致性
  4. 版本可控:技能文件可纳入版本管理,规范变更有迹可循

三、快速上手:下载与安装

3.1 环境要求

  • Node.js:>= 16.0.0
  • npm:>= 8.0.0

3.2 安装步骤

使用 npm 全局安装:

npm install -g aoxia-ui-generator

# 验证安装
aocode --version

安装完成后,在任意项目目录下运行即可启动鳌虾:

aocode

服务启动后会自动打开浏览器访问 http://localhost:3000/,即可开始使用。

3.3 项目初始化

首次使用时,建议在项目根目录下创建 .trae/skills 文件夹,并放置你的技能规范文件:

my-project/
├── .trae/
│   └── skills/
│       ├── page.md      # 列表页规范
│       ├── edit.md      # 编辑页规范
│       └── look.md      # 详情页规范
└── src/
    └── views/
        └── ...

鳌虾会自动扫描并读取这些文件,让你在页面配置时自由绑定。

image.png

image.png


四、未来展望:AI 编程的下一个十年

4.1 从"工具"到"助手"的进化

当前的 AI 编程工具大多停留在"响应指令"的层面。鳌虾的愿景是成为主动协作的助手——它不仅被动响应开发者的配置,还会主动建议最优的页面结构、规范的代码组织方式。

4.2 技能生态的构建

未来,鳌虾计划构建一个开放的技能市场(Skills Market)

  • 开发者可以发布自己编写的技能文件
  • 项目可订阅行业最佳实践技能
  • 支持技能的版本管理和更新通知

4.3 多模态融合

未来的 AI 编程将不局限于文本。鳌虾计划引入:

  • 设计稿导入:直接解析 Figma、Sketch 等设计文件
  • API 文档解析:自动理解接口定义并生成对应页面
  • 代码审查集成:生成后自动检查是否符合规范

4.4 对标 OpenClaw,走向国际

鳌虾的愿景不止于国内市场。它以 OpenClaw(开源龙虾)为对标目标,致力于成为全球开发者喜爱的 AI 编程工具。开源、生态、国际化的道路,将是鳌虾下一阶段的核心方向。


结语

AI 编程的时代已经到来,但"幻觉"问题始终困扰着开发者。鳌虾通过可视化配置 + 技能文件 + 智能注入的创新模式,让 AI 始终在规范的框架内生成代码,从根本上减少了不确定性。

这不是一个简单的 Prompt 生成器,而是一套完整的AI 编程工作流解决方案。它让开发者从繁琐的文本工作中解放出来,专注于真正的业务逻辑。

当别人还在手敲 Prompt 的时候,你已经在用鳌虾生成代码了。


鳌虾 AoCode,下一代 AI 编程助手,让代码生成更精准、更高效、更可控。


Vue2 → Vue3 深度对比:8 大核心优化,性能提升 2 倍

Vue2 到 Vue3:这 8 个优化点让性能提升 2 倍,开发效率翻倍!

从 Options API 到 Composition API,从 Object.defineProperty 到 Proxy,Vue3 不仅仅是升级,更是一次重构。本文深入剖析 Vue3 的 8 大核心优化点,帮你彻底搞懂为什么要升级。


前言

"Vue3 出来这么久了,到底要不要升级?"

这是很多前端团队都在纠结的问题。Vue2 项目跑得好好的,业务也稳定,为什么要花时间去升级?

答案是:性能 + 开发体验 + 未来支持。

Vue3 相比 Vue2,不仅仅是语法的改变,更是架构层面的全面优化

  • 🚀 性能提升:打包体积减少 41%,渲染速度提升 40-50%
  • 💡 开发体验:更好的 TypeScript 支持,更灵活的代码组织
  • 🔮 未来保障:Vue2 已于 2023 年 12 月 31 日停止维护

今天,我们就来深入剖析 Vue3 相比 Vue2 的 8 大核心优化点,让你彻底搞懂升级的价值。


优化点 1:响应式系统重构(Proxy vs Object.defineProperty)

Vue2 的响应式原理

Vue2 使用 Object.defineProperty 实现响应式:

// Vue2 响应式原理(简化版)
function defineReactive(obj, key, val) {
  Object.defineProperty(obj, key, {
    get() {
      console.log(`获取 ${key}`);
      return val;
    },
    set(newVal) {
      console.log(`设置 ${key}`);
      val = newVal;
      // 通知更新
    }
  });
}

const data = { name: 'Vue2' };
defineReactive(data, 'name', 'Vue2');

// ❌ 问题 1:无法检测对象属性的添加和删除
data.age = 25;  // 不会触发响应式更新

// ❌ 问题 2:无法检测数组索引和长度的变化
data.items[0] = 'new';  // 不会触发响应式更新
data.items.length = 0;  // 不会触发响应式更新

// ✅ 解决方案:使用 Vue.set / this.$set
this.$set(data, 'age', 25);
this.$set(data.items, 0, 'new');

Vue3 的响应式原理

Vue3 使用 Proxy 重写响应式系统:

// Vue3 响应式原理(简化版)
function reactive(obj) {
  return new Proxy(obj, {
    get(target, key, receiver) {
      console.log(`获取 ${key}`);
      return Reflect.get(target, key, receiver);
    },
    set(target, key, value, receiver) {
      console.log(`设置 ${key}`);
      return Reflect.set(target, key, value, receiver);
    },
    deleteProperty(target, key) {
      console.log(`删除 ${key}`);
      return Reflect.deleteProperty(target, key);
    }
  });
}

const data = reactive({ name: 'Vue3' });

// ✅ 优势 1:可以检测对象属性的添加和删除
data.age = 25;  // ✅ 会触发响应式更新
delete data.name;  // ✅ 会触发响应式更新

// ✅ 优势 2:可以检测数组索引和长度的变化
data.items[0] = 'new';  // ✅ 会触发响应式更新
data.items.length = 0;  // ✅ 会触发响应式更新

// ✅ 优势 3:无需特殊 API,原生操作即可

性能对比

特性 Vue2 Vue3
对象属性添加 ❌ 需要 Vue.set ✅ 原生支持
数组索引修改 ❌ 需要 Vue.set ✅ 原生支持
Map/Set 支持 ❌ 不支持 ✅ 原生支持
性能开销 较高(递归遍历) 较低(懒代理)

实测数据: 在大型列表中,Vue3 的响应式初始化速度比 Vue2 快 40-50%


优化点 2:Composition API(组合式 API)

Vue2 的 Options API 问题

<!-- Vue2 Options API -->
<template>
  <div>
    <p>{{ userName }}</p>
    <p>{{ userAge }}</p>
    <button @click="fetchUser">加载用户</button>
  </div>
</template>

<script>
export default {
  data() {
    return {
      userName: '',
      userAge: 0,
      loading: false,
      error: null
    };
  },
  methods: {
    async fetchUser() {
      this.loading = true;
      try {
        const res = await fetch('/api/user');
        const data = await res.json();
        this.userName = data.name;
        this.userAge = data.age;
      } catch (e) {
        this.error = e.message;
      } finally {
        this.loading = false;
      }
    }
  },
  computed: {
    userTitle() {
      return `${this.userName} - ${this.userAge}岁`;
    }
  },
  watch: {
    userName(newVal) {
      console.log('用户名变化:', newVal);
    }
  },
  mounted() {
    this.fetchUser();
  }
};
</script>

问题:

  • 逻辑分散:同一个功能的 datamethodscomputedwatch 分散在不同位置
  • 复用困难:Mixins 存在命名冲突、来源不清晰的问题
  • TypeScript 支持差this 类型推断复杂

Vue3 的 Composition API

<!-- Vue3 Composition API -->
<template>
  <div>
    <p>{{ userName }}</p>
    <p>{{ userAge }}</p>
    <p>{{ userTitle }}</p>
    <button @click="fetchUser">加载用户</button>
  </div>
</template>

<script setup>
import { ref, computed, onMounted } from 'vue';

// ✅ 优势 1:逻辑聚合 - 相关代码在一起
const userName = ref('');
const userAge = ref(0);
const loading = ref(false);
const error = ref(null);

const userTitle = computed(() => `${userName.value} - ${userAge.value}岁`);

const fetchUser = async () => {
  loading.value = true;
  try {
    const res = await fetch('/api/user');
    const data = await res.json();
    userName.value = data.name;
    userAge.value = data.age;
  } catch (e) {
    error.value = e.message;
  } finally {
    loading.value = false;
  }
};

// 监听
watch(userName, (newVal) => {
  console.log('用户名变化:', newVal);
});

// 生命周期
onMounted(() => {
  fetchUser();
});
</script>

逻辑复用对比

Vue2 Mixins(有问题):

// mixins/userLogic.js
export default {
  data() {
    return {
      userName: '',  // ❌ 命名冲突风险
      loading: false
    };
  },
  methods: {
    fetchUser() {}  // ❌ 来源不清晰
  }
};

// 组件中
export default {
  mixins: [userLogic, otherMixin],  // ❌ 多个 mixins 冲突怎么办?
};

Vue3 Composables(优雅):

// composables/useUser.js
import { ref } from 'vue';

export function useUser() {
  const userName = ref('');
  const loading = ref(false);
  
  const fetchUser = async () => {
    // ...
  };
  
  return { userName, loading, fetchUser };  // ✅ 清晰明确
}

// 组件中
import { useUser } from '@/composables/useUser';

const { userName, loading, fetchUser } = useUser();  // ✅ 无冲突

优化点 3:性能优化(打包体积 + 渲染速度)

打包体积对比

框架 最小 + 压缩体积 相比 Vue2
Vue2 ~30 KB -
Vue3 ~10 KB 减少 41%

原因:

  • Vue3 采用 Tree-shaking 优化,未使用的功能会被自动移除
  • 内部模块解耦,按需引入
// Vue3 按需引入
import { ref, computed, watch } from 'vue';  // ✅ 只引入需要的

// Vue2 全量引入
import Vue from 'vue';  // ❌ 全部引入

渲染速度对比

场景 Vue2 Vue3 提升
初次渲染 基准 快 40-50% ⬆️ 45%
更新渲染 基准 快 40-50% ⬆️ 45%
内存占用 基准 减少 50% ⬇️ 50%

原因:

  • Vue3 使用 虚拟 DOM 重写,引入 静态标记(PatchFlags)
  • 动态节点和静态节点分离,只更新变化的部分
<!-- Vue3 编译优化 -->
<template>
  <div>
    <p>静态文本</p>  <!-- 静态节点,不追踪 -->
    <p>{{ dynamicText }}</p>  <!-- 动态节点,带 PatchFlags -->
  </div>
</template>

<!-- 编译后(简化) -->
{
  type: 'div',
  children: [
    { type: 'p', children: '静态文本', patchFlag: 0 },  // 静态
    { type: 'p', children: dynamicText, patchFlag: 1 }  // 动态,只追踪文本
  ]
}

优化点 4:TypeScript 支持

Vue2 的 TypeScript 支持

// Vue2 + TypeScript(繁琐)
import Vue from 'vue';
import Component from 'vue-class-component';

@Component({
  props: {
    userId: Number,
    userName: String
  }
})
export default class UserCard extends Vue {
  // ❌ 需要装饰器
  // ❌ 类型推断复杂
  // ❌ 配置繁琐
  
  get userTitle() {
    return `${this.userName} - ${this.userId}`;
  }
}

Vue3 的 TypeScript 支持

// Vue3 + TypeScript(原生)
<script setup lang="ts">
import { ref, defineProps, defineEmits } from 'vue';

// ✅ 原生 TypeScript 支持
interface Props {
  userId: number;
  userName: string;
}

const props = defineProps<Props>();

// ✅ 自动类型推断
const userTitle = `${props.userName} - ${props.userId}`;

// ✅ 事件类型定义
const emit = defineEmits<{
  (e: 'update', id: number): void;
  (e: 'delete'): void;
}>();
</script>

优势:

  • ✅ 无需装饰器,原生支持
  • ✅ 自动类型推断
  • ✅ 更好的 IDE 提示

优化点 5:生命周期优化

生命周期对比

Vue2 生命周期 Vue3 生命周期 说明
beforeCreate setup() 在 setup 中直接写
created setup() 在 setup 中直接写
beforeMount onBeforeMount 类似
mounted onMounted 类似
beforeUpdate onBeforeUpdate 类似
updated onUpdated 类似
beforeDestroy onBeforeUnmount 改名了
destroyed onUnmounted 改名了

代码对比

Vue2:

export default {
  data() {
    return { count: 0 };
  },
  beforeCreate() {
    console.log('beforeCreate');
  },
  created() {
    console.log('created');
  },
  beforeDestroy() {
    console.log('beforeDestroy');
  },
  destroyed() {
    console.log('destroyed');
  }
};

Vue3:

import { onBeforeMount, onMounted, onBeforeUnmount, onUnmounted } from 'vue';

setup() {
  onBeforeMount(() => {
    console.log('onBeforeMount');
  });
  
  onMounted(() => {
    console.log('onMounted');
  });
  
  onBeforeUnmount(() => {
    console.log('onBeforeUnmount');
  });
  
  onUnmounted(() => {
    console.log('onUnmounted');
  });
};

优势:

  • ✅ 生命周期钩子可以在多个 composables 中使用
  • ✅ 更好的逻辑组织

优化点 6:Teleport(传送门)

Vue2 的模态框问题

<!-- Vue2:模态框被父组件样式影响 -->
<template>
  <div class="modal-container">
    <div class="modal" v-if="show">
      <!-- ❌ 受父组件 overflow: hidden 影响 -->
      <!-- ❌ 受父组件 z-index 影响 -->
      模态框内容
    </div>
  </div>
</template>

<style>
.modal-container {
  overflow: hidden;  /* ❌ 模态框被裁剪 */
}
</style>

Vue3 的 Teleport

<!-- Vue3:传送到 body 下 -->
<template>
  <Teleport to="body">
    <div class="modal" v-if="show">
      <!-- ✅ 不受父组件样式影响 -->
      <!-- ✅ 始终在最上层 -->
      模态框内容
    </div>
  </Teleport>
</template>

优势:

  • ✅ 模态框、Toast、通知等组件不再受父组件样式影响
  • ✅ 代码逻辑和 DOM 结构分离

优化点 7:Suspense(异步组件优化)

Vue2 的异步组件

<!-- Vue2:需要手动处理 loading 状态 -->
<template>
  <div>
    <div v-if="loading">加载中...</div>
    <div v-else-if="error">加载失败</div>
    <AsyncComponent v-else />
  </div>
</template>

<script>
export default {
  components: {
    AsyncComponent: () => ({
      component: import('./AsyncComponent.vue'),
      loading: LoadingComponent,
      error: ErrorComponent,
      delay: 200,
      timeout: 3000
    })
  },
  data() {
    return {
      loading: true,
      error: null
    };
  }
};
</script>

Vue3 的 Suspense

<!-- Vue3:内置异步处理 -->
<template>
  <Suspense>
    <template #default>
      <AsyncComponent />
    </template>
    <template #fallback>
      <div>加载中...</div>
    </template>
  </Suspense>
</template>

<script setup>
import AsyncComponent from './AsyncComponent.vue';
// ✅ 自动处理 loading 和 error 状态
</script>

优势:

  • ✅ 内置异步组件处理
  • ✅ 代码更简洁

优化点 8:多根节点支持

Vue2 的单根节点限制

<!-- Vue2:必须有一个根节点 -->
<template>
  <div>  <!-- ❌ 多余的 div -->
    <header>...</header>
    <main>...</main>
    <footer>...</footer>
  </div>
</template>

Vue3 的多根节点

<!-- Vue3:支持多个根节点 -->
<template>
  <header>...</header>
  <main>...</main>
  <footer>...</footer>
</template>

<!-- ✅ 无需多余的包裹 div -->
<!-- ✅ 更简洁的 DOM 结构 -->

性能对比总结

优化点 Vue2 Vue3 提升幅度
打包体积 ~30 KB ~10 KB ⬇️ 41%
渲染速度 基准 快 40-50% ⬆️ 45%
内存占用 基准 减少 50% ⬇️ 50%
TypeScript 支持 一般 优秀 质的飞跃
代码复用 Mixins(有问题) Composables 架构升级
响应式原理 Object.defineProperty Proxy 原生支持

升级建议

适合升级的场景

  • 新项目:直接用 Vue3
  • TypeScript 项目:Vue3 的 TS 支持更好
  • 大型项目:Composition API 更适合复杂逻辑
  • 性能敏感项目:需要更好的渲染性能

暂缓升级的场景

  • ⚠️ 稳定运行的老项目:业务稳定,暂无性能问题
  • ⚠️ 依赖 Vue2 生态:部分插件尚未支持 Vue3
  • ⚠️ 团队不熟悉 Vue3:需要学习时间

升级策略

  1. 渐进式迁移:使用 @vue/compat 兼容版本
  2. 先迁移工具函数:Composables 可以独立迁移
  3. 新组件用 Vue3:老组件逐步迁移
  4. 充分测试:确保核心功能正常

总结

Vue3 相比 Vue2,不仅仅是版本升级,更是架构层面的全面优化

  1. 响应式系统:Proxy 替代 Object.defineProperty,更强大
  2. Composition API:逻辑聚合,复用更优雅
  3. 性能提升:打包体积减少 41%,渲染速度提升 45%
  4. TypeScript 支持:原生支持,类型推断更智能
  5. 新特性:Teleport、Suspense、多根节点

最重要的建议:

新项目直接用 Vue3,老项目根据情况逐步迁移。

Vue2 已经停止维护,未来是 Vue3 的时代。早升级,早受益!


参考资料


如果觉得这篇文章对你有帮助,欢迎点赞、收藏、关注三连支持! 💪

你的项目升级 Vue3 了吗?遇到过什么坑?欢迎在评论区分享!


本文首发于掘金,欢迎交流讨论

@tencent-weixin/openclaw-weixin 插件深度解析(四):API 协议与数据流设计

RESTful API、类型系统、同步缓冲区

API 协议是插件与微信服务器通信的基础,而数据流设计决定了消息如何在整个系统中流转。本文将深入剖析 OpenClaw WeChat 插件的 API 协议设计、数据类型系统、同步缓冲区机制以及日志与监控体系,帮助开发者理解其底层通信原理。

一、API 协议架构概览

OpenClaw WeChat 插件采用 RESTful API 与微信服务器通信,所有请求使用 JSON 格式,通过 HTTP/HTTPS 传输:

┌─────────────────────────────────────────────────────────────────────────┐
│                         API Protocol Stack                               │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                          │
│  ┌─────────────────────────────────────────────────────────────────┐   │
│  │                        Application Layer                         │   │
│  │  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐  ┌─────────┐ │   │
│  │  │ getUpdates  │  │ sendMessage │  │ getUploadUrl│  │getConfig│ │   │
│  │  └─────────────┘  └─────────────┘  └─────────────┘  └─────────┘ │   │
│  └─────────────────────────────────────────────────────────────────┘   │
│                                                                          │
│  ┌─────────────────────────────────────────────────────────────────┐   │
│  │                      Transport Layer (HTTP)                      │   │
│  │  POST /ilink/bot/getupdates          JSON Request/Response      │   │
│  │  POST /ilink/bot/sendmessage                                    │   │
│  │  POST /ilink/bot/getuploadurl                                   │   │
│  │  POST /ilink/bot/getconfig                                      │   │
│  │  POST /ilink/bot/sendtyping                                     │   │
│  └─────────────────────────────────────────────────────────────────┘   │
│                                                                          │
│  ┌─────────────────────────────────────────────────────────────────┐   │
│  │                      Security Layer                              │   │
│  │  Authorization: Bearer <token>                                   │   │
│  │  AuthorizationType: ilink_bot_token                              │   │
│  │  X-WECHAT-UIN: <random>                                          │   │
│  │  SKRouteTag: <optional>                                          │   │
│  └─────────────────────────────────────────────────────────────────┘   │
│                                                                          │
└─────────────────────────────────────────────────────────────────────────┘

二、核心 API 接口详解

2.1 API 选项配置

所有 API 调用共享统一的选项配置:

export type WeixinApiOptions = {
  baseUrl: string;
  token?: string;
  timeoutMs?: number;
  /** Long-poll timeout for getUpdates (server may hold the request up to this). */
  longPollTimeoutMs?: number;
};

默认超时配置根据 API 类型区分:

const DEFAULT_LONG_POLL_TIMEOUT_MS = 35_000;
const DEFAULT_API_TIMEOUT_MS = 15_000;
const DEFAULT_CONFIG_TIMEOUT_MS = 10_000;

2.2 通用请求构建

所有 API 请求共享统一的请求构建逻辑:

async function apiFetch(params: {
  baseUrl: string;
  endpoint: string;
  body: string;
  token?: string;
  timeoutMs: number;
  label: string;
}): Promise<string> {
  const base = ensureTrailingSlash(params.baseUrl);
  const url = new URL(params.endpoint, base);
  const hdrs = buildHeaders({ token: params.token, body: params.body });
  logger.debug(`POST ${redactUrl(url.toString())} body=${redactBody(params.body)}`);

  const controller = new AbortController();
  const t = setTimeout(() => controller.abort(), params.timeoutMs);
  try {
    const res = await fetch(url.toString(), {
      method: "POST",
      headers: hdrs,
      body: params.body,
      signal: controller.signal,
    });
    clearTimeout(t);
    const rawText = await res.text();
    logger.debug(`${params.label} status=${res.status} raw=${redactBody(rawText)}`);
    if (!res.ok) {
      throw new Error(`${params.label} ${res.status}: ${rawText}`);
    }
    return rawText;
  } catch (err) {
    clearTimeout(t);
    throw err;
  }
}

2.3 请求头构建

请求头包含身份验证和路由信息:

function buildHeaders(opts: { token?: string; body: string }): Record<string, string> {
  const headers: Record<string, string> = {
    "Content-Type": "application/json",
    AuthorizationType: "ilink_bot_token",
    "Content-Length": String(Buffer.byteLength(opts.body, "utf-8")),
    "X-WECHAT-UIN": randomWechatUin(),
  };
  if (opts.token?.trim()) {
    headers.Authorization = `Bearer ${opts.token.trim()}`;
  }
  const routeTag = loadConfigRouteTag();
  if (routeTag) {
    headers.SKRouteTag = routeTag;
  }
  return headers;
}

/** X-WECHAT-UIN header: random uint32 -> decimal string -> base64. */
function randomWechatUin(): string {
  const uint32 = crypto.randomBytes(4).readUInt32BE(0);
  return Buffer.from(String(uint32), "utf-8").toString("base64");
}

2.4 GetUpdates 长轮询

GetUpdates 是消息接收的核心接口,采用长轮询机制:

export async function getUpdates(
  params: GetUpdatesReq & {
    baseUrl: string;
    token?: string;
    timeoutMs?: number;
  },
): Promise<GetUpdatesResp> {
  const timeout = params.timeoutMs ?? DEFAULT_LONG_POLL_TIMEOUT_MS;
  try {
    const rawText = await apiFetch({
      baseUrl: params.baseUrl,
      endpoint: "ilink/bot/getupdates",
      body: JSON.stringify({
        get_updates_buf: params.get_updates_buf ?? "",
        base_info: buildBaseInfo(),
      }),
      token: params.token,
      timeoutMs: timeout,
      label: "getUpdates",
    });
    const resp: GetUpdatesResp = JSON.parse(rawText);
    return resp;
  } catch (err) {
    // Long-poll timeout is normal; return empty response so caller can retry
    if (err instanceof Error && err.name === "AbortError") {
      logger.debug(`getUpdates: client-side timeout after ${timeout}ms, returning empty response`);
      return { ret: 0, msgs: [], get_updates_buf: params.get_updates_buf };
    }
    throw err;
  }
}

长轮询的特点:

  • 客户端设置 35 秒超时
  • 服务器保持连接直到有新消息或超时
  • 客户端超时视为正常情况,自动重试
  • 返回 get_updates_buf 用于下次请求

2.5 发送消息

SendMessage 用于向用户发送消息:

export async function sendMessage(
  params: WeixinApiOptions & { body: SendMessageReq },
): Promise<void> {
  await apiFetch({
    baseUrl: params.baseUrl,
    endpoint: "ilink/bot/sendmessage",
    body: JSON.stringify({ ...params.body, base_info: buildBaseInfo() }),
    token: params.token,
    timeoutMs: params.timeoutMs ?? DEFAULT_API_TIMEOUT_MS,
    label: "sendMessage",
  });
}

2.6 获取上传 URL

GetUploadUrl 用于获取 CDN 上传的预签名 URL:

export async function getUploadUrl(
  params: GetUploadUrlReq & WeixinApiOptions,
): Promise<GetUploadUrlResp> {
  const rawText = await apiFetch({
    baseUrl: params.baseUrl,
    endpoint: "ilink/bot/getuploadurl",
    body: JSON.stringify({
      filekey: params.filekey,
      media_type: params.media_type,
      to_user_id: params.to_user_id,
      rawsize: params.rawsize,
      rawfilemd5: params.rawfilemd5,
      filesize: params.filesize,
      no_need_thumb: params.no_need_thumb,
      aeskey: params.aeskey,
      base_info: buildBaseInfo(),
    }),
    token: params.token,
    timeoutMs: params.timeoutMs ?? DEFAULT_API_TIMEOUT_MS,
    label: "getUploadUrl",
  });
  const resp: GetUploadUrlResp = JSON.parse(rawText);
  return resp;
}

2.7 获取配置

GetConfig 用于获取用户的配置信息,包括 typing_ticket:

export async function getConfig(
  params: WeixinApiOptions & { ilinkUserId: string; contextToken?: string },
): Promise<GetConfigResp> {
  const rawText = await apiFetch({
    baseUrl: params.baseUrl,
    endpoint: "ilink/bot/getconfig",
    body: JSON.stringify({
      ilink_user_id: params.ilinkUserId,
      context_token: params.contextToken,
      base_info: buildBaseInfo(),
    }),
    token: params.token,
    timeoutMs: params.timeoutMs ?? DEFAULT_CONFIG_TIMEOUT_MS,
    label: "getConfig",
  });
  const resp: GetConfigResp = JSON.parse(rawText);
  return resp;
}

2.8 发送打字指示器

SendTyping 用于向用户显示"正在输入"状态:

export async function sendTyping(
  params: WeixinApiOptions & { body: SendTypingReq },
): Promise<void> {
  await apiFetch({
    baseUrl: params.baseUrl,
    endpoint: "ilink/bot/sendtyping",
    body: JSON.stringify({ ...params.body, base_info: buildBaseInfo() }),
    token: params.token,
    timeoutMs: params.timeoutMs ?? DEFAULT_CONFIG_TIMEOUT_MS,
    label: "sendTyping",
  });
}

三、数据类型系统

3.1 基础信息类型

每个 API 请求都包含基础信息:

export interface BaseInfo {
  channel_version?: string;
}

function readChannelVersion(): string {
  try {
    const dir = path.dirname(fileURLToPath(import.meta.url));
    const pkgPath = path.resolve(dir, "..", "..", "package.json");
    const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")) as { version?: string };
    return pkg.version ?? "unknown";
  } catch {
    return "unknown";
  }
}

const CHANNEL_VERSION = readChannelVersion();

export function buildBaseInfo(): BaseInfo {
  return { channel_version: CHANNEL_VERSION };
}

3.2 消息类型定义

消息系统支持多种类型的消息项:

export const MessageItemType = {
  NONE: 0,
  TEXT: 1,
  IMAGE: 2,
  VOICE: 3,
  FILE: 4,
  VIDEO: 5,
} as const;

export const MessageType = {
  NONE: 0,
  USER: 1,
  BOT: 2,
} as const;

export const MessageState = {
  NEW: 0,
  GENERATING: 1,
  FINISH: 2,
} as const;

3.3 消息项结构

消息项采用联合类型设计,通过 type 字段区分:

export interface MessageItem {
  type?: number;
  create_time_ms?: number;
  update_time_ms?: number;
  is_completed?: boolean;
  msg_id?: string;
  ref_msg?: RefMessage;
  text_item?: TextItem;
  image_item?: ImageItem;
  voice_item?: VoiceItem;
  file_item?: FileItem;
  video_item?: VideoItem;
}

export interface TextItem {
  text?: string;
}

export interface ImageItem {
  media?: CDNMedia;
  thumb_media?: CDNMedia;
  aeskey?: string;
  url?: string;
  mid_size?: number;
  thumb_size?: number;
  hd_size?: number;
}

export interface VoiceItem {
  media?: CDNMedia;
  encode_type?: number;
  sample_rate?: number;
  playtime?: number;
  text?: string;
}

export interface FileItem {
  media?: CDNMedia;
  file_name?: string;
  md5?: string;
  len?: string;
}

export interface VideoItem {
  media?: CDNMedia;
  video_size?: number;
  play_length?: number;
  thumb_media?: CDNMedia;
}

3.4 CDN 媒体引用

媒体文件通过 CDN 引用访问:

export interface CDNMedia {
  encrypt_query_param?: string;
  aes_key?: string;
  encrypt_type?: number;
}

3.5 统一消息结构

WeixinMessage 是统一的消息结构:

export interface WeixinMessage {
  seq?: number;
  message_id?: number;
  from_user_id?: string;
  to_user_id?: string;
  client_id?: string;
  create_time_ms?: number;
  update_time_ms?: number;
  delete_time_ms?: number;
  session_id?: string;
  group_id?: string;
  message_type?: number;
  message_state?: number;
  item_list?: MessageItem[];
  context_token?: string;
}

关键字段说明:

  • seq:消息序列号,用于排序
  • message_id:唯一消息标识
  • from_user_id / to_user_id:发送者和接收者
  • client_id:客户端生成的消息 ID
  • create_time_ms:消息创建时间(毫秒时间戳)
  • session_id:会话标识
  • item_list:消息内容项列表
  • context_token:上下文令牌,回复时必须携带

3.6 GetUpdates 请求/响应

export interface GetUpdatesReq {
  /** @deprecated compat only, will be removed */
  sync_buf?: string;
  /** Full context buf cached locally; send "" when none (first request or after reset). */
  get_updates_buf?: string;
}

export interface GetUpdatesResp {
  ret?: number;
  errcode?: number;
  errmsg?: string;
  msgs?: WeixinMessage[];
  get_updates_buf?: string;
  longpolling_timeout_ms?: number;
}

3.7 打字状态

export const TypingStatus = {
  TYPING: 1,
  CANCEL: 2,
} as const;

export interface SendTypingReq {
  ilink_user_id?: string;
  typing_ticket?: string;
  status?: number;
}

四、同步缓冲区机制

4.1 同步缓冲区的作用

同步缓冲区(sync buffer)是实现消息不丢失的关键机制:

┌─────────────────────────────────────────────────────────────────────────┐
│                      Sync Buffer Flow                                    │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                          │
│  Client                    Weixin Server                                 │
│    │                            │                                        │
│    │  1. getUpdates(buf="")    │                                        │
│    │ -------------------------> │                                        │
│    │                            │                                        │
│    │  2. msgs: [A, B, C]       │                                        │
│    │     new_buf: "XYZ123"     │                                        │
│    │ <------------------------- │                                        │
│    │                            │                                        │
│    │  [Save "XYZ123" to file]  │                                        │
│    │                            │                                        │
│    │  3. getUpdates(buf="XYZ123")                                       │
│    │ -------------------------> │                                        │
│    │                            │                                        │
│    │  [Server knows client has A, B, C]                                 │
│    │                            │                                        │
│    │  4. msgs: [D, E]          │                                        │
│    │     new_buf: "ABC789"     │                                        │
│    │ <------------------------- │                                        │
│    │                            │                                        │
│    │  [Save "ABC789" to file]  │                                        │
│                                                                          │
└─────────────────────────────────────────────────────────────────────────┘

4.2 同步缓冲区存储

export type SyncBufData = {
  get_updates_buf: string;
};

export function getSyncBufFilePath(accountId: string): string {
  return path.join(resolveAccountsDir(), `${accountId}.sync.json`);
}

export function saveGetUpdatesBuf(filePath: string, getUpdatesBuf: string): void {
  const dir = path.dirname(filePath);
  fs.mkdirSync(dir, { recursive: true });
  fs.writeFileSync(filePath, JSON.stringify({ get_updates_buf: getUpdatesBuf }, null, 0), "utf-8");
}

4.3 多层兼容性回退

同步缓冲区加载支持多层回退:

export function loadGetUpdatesBuf(filePath: string): string | undefined {
  const value = readSyncBufFile(filePath);
  if (value !== undefined) return value;

  // Compat: if given path uses a normalized accountId (e.g. "b0f5860fdecb-im-bot.sync.json"),
  // also try the old raw-ID filename (e.g. "b0f5860fdecb@im.bot.sync.json").
  const accountId = path.basename(filePath, ".sync.json");
  const rawId = deriveRawAccountId(accountId);
  if (rawId) {
    const compatPath = path.join(resolveAccountsDir(), `${rawId}.sync.json`);
    const compatValue = readSyncBufFile(compatPath);
    if (compatValue !== undefined) return compatValue;
  }

  // Legacy fallback: old single-account installs stored syncbuf without accountId.
  return readSyncBufFile(getLegacySyncBufDefaultJsonPath());
}

回退层级:

  1. 主路径:规范化账号 ID 的同步文件
  2. 兼容路径:原始格式账号 ID 的同步文件
  3. 遗留路径:单账号时代的默认同步文件

五、状态目录管理

5.1 状态目录解析

插件使用统一的状态目录存储所有持久化数据:

export function resolveStateDir(): string {
  return (
    process.env.OPENCLAW_STATE_DIR?.trim() ||
    process.env.CLAWDBOT_STATE_DIR?.trim() ||
    path.join(os.homedir(), ".openclaw")
  );
}

环境变量优先级:

  1. OPENCLAW_STATE_DIR:首选环境变量
  2. CLAWDBOT_STATE_DIR:向后兼容的旧变量名
  3. 默认路径:~/.openclaw

5.2 目录结构

~/.openclaw/
├── openclaw-weixin/
│   ├── accounts.json              # 账号索引
│   ├── accounts/
│   │   ├── {accountId}.json       # 账号凭证
│   │   └── {accountId}.sync.json  # 同步缓冲区
│   └── debug-mode.json            # 调试模式状态
└── credentials/
    └── openclaw-weixin-{accountId}-allowFrom.json  # 授权列表

六、日志系统

6.1 日志架构

插件使用与 OpenClaw 核心统一的日志格式:

const MAIN_LOG_DIR = path.join("/tmp", "openclaw");
const SUBSYSTEM = "gateway/channels/openclaw-weixin";
const RUNTIME = "node";
const RUNTIME_VERSION = process.versions.node;
const HOSTNAME = os.hostname() || "unknown";

6.2 日志级别

const LEVEL_IDS: Record<string, number> = {
  TRACE: 1,
  DEBUG: 2,
  INFO: 3,
  WARN: 4,
  ERROR: 5,
  FATAL: 6,
};

const DEFAULT_LOG_LEVEL = "INFO";

6.3 日志记录实现

function writeLog(level: string, message: string, accountId?: string): void {
  const levelId = LEVEL_IDS[level] ?? LEVEL_IDS.INFO;
  if (levelId < minLevelId) return;

  const now = new Date();
  const loggerName = buildLoggerName(accountId);
  const prefixedMessage = accountId ? `[${accountId}] ${message}` : message;
  const entry = JSON.stringify({
    "0": loggerName,
    "1": prefixedMessage,
    _meta: {
      runtime: RUNTIME,
      runtimeVersion: RUNTIME_VERSION,
      hostname: HOSTNAME,
      name: loggerName,
      parentNames: PARENT_NAMES,
      date: now.toISOString(),
      logLevelId: LEVEL_IDS[level] ?? LEVEL_IDS.INFO,
      logLevelName: level,
    },
    time: toLocalISO(now),
  });

  try {
    if (!logDirEnsured) {
      fs.mkdirSync(MAIN_LOG_DIR, { recursive: true });
      logDirEnsured = true;
    }
    fs.appendFileSync(resolveMainLogPath(), `${entry}\n`, "utf-8");
  } catch {
    // Best-effort; never block on logging failures.
  }
}

6.4 日志格式

日志采用 JSON Lines 格式,便于结构化处理:

{
  "0": "gateway/channels/openclaw-weixin/b0f5860fdecb-im-bot",
  "1": "[b0f5860fdecb-im-bot] inbound message: from=xxx@im.wechat types=1",
  "_meta": {
    "runtime": "node",
    "runtimeVersion": "22.0.0",
    "hostname": "myhost",
    "name": "gateway/channels/openclaw-weixin/b0f5860fdecb-im-bot",
    "parentNames": ["openclaw"],
    "date": "2026-03-22T10:30:00.000Z",
    "logLevelId": 3,
    "logLevelName": "INFO"
  },
  "time": "2026-03-22T18:30:00.000+08:00"
}

6.5 子日志器

支持按账号创建子日志器:

export type Logger = {
  info(message: string): void;
  debug(message: string): void;
  warn(message: string): void;
  error(message: string): void;
  withAccount(accountId: string): Logger;
  getLogFilePath(): string;
  close(): void;
};

function createLogger(accountId?: string): Logger {
  return {
    info(message: string): void {
      writeLog("INFO", message, accountId);
    },
    // ... 其他级别
    withAccount(id: string): Logger {
      return createLogger(id);
    },
  };
}

七、敏感信息脱敏

7.1 脱敏工具函数

export function truncate(s: string | undefined, max: number): string {
  if (!s) return "";
  if (s.length <= max) return s;
  return `${s.slice(0, max)}…(len=${s.length})`;
}

export function redactToken(token: string | undefined, prefixLen = 6): string {
  if (!token) return "(none)";
  if (token.length <= prefixLen) return `****(len=${token.length})`;
  return `${token.slice(0, prefixLen)}…(len=${token.length})`;
}

export function redactBody(body: string | undefined, maxLen = 200): string {
  if (!body) return "(empty)";
  if (body.length <= maxLen) return body;
  return `${body.slice(0, maxLen)}…(truncated, totalLen=${body.length})`;
}

export function redactUrl(rawUrl: string): string {
  try {
    const u = new URL(rawUrl);
    const base = `${u.origin}${u.pathname}`;
    return u.search ? `${base}?<redacted>` : base;
  } catch {
    return truncate(rawUrl, 80);
  }
}

7.2 脱敏策略

  • Token:显示前 6 个字符,隐藏其余部分
  • 请求体:截断至 200 字符
  • URL:隐藏查询字符串(可能包含签名)
  • 空值:明确标记为 "(none)" 或 "(empty)"

八、ID 生成与随机数

8.1 消息 ID 生成

export function generateId(prefix: string): string {
  return `${prefix}:${Date.now()}-${crypto.randomBytes(4).toString("hex")}`;
}

格式:{prefix}:{timestamp}-{8-char hex}

示例:openclaw-weixin:1711090800000-a1b2c3d4

8.2 临时文件名生成

export function tempFileName(prefix: string, ext: string): string {
  return `${prefix}-${Date.now()}-${crypto.randomBytes(4).toString("hex")}${ext}`;
}

格式:{prefix}-{timestamp}-{8-char hex}{ext}

示例:weixin-remote-1711090800000-a1b2c3d4.jpg

8.3 设计考量

  • 时间戳:确保基本的有序性
  • 随机数:防止冲突,增强不可预测性
  • 前缀:便于识别和分类
  • crypto 模块:使用加密安全的随机数生成

九、总结

OpenClaw WeChat 插件的 API 协议与数据流设计展现了以下特点:

  1. RESTful API:统一的 HTTP JSON 接口,易于理解和调试
  2. 长轮询机制:实现低延迟消息接收,同时保持简单性
  3. 类型安全:完整的 TypeScript 类型定义,编译时检查
  4. 同步缓冲:确保消息不丢失,支持断点续传
  5. 统一日志:与 OpenClaw 核心一致的日志格式,便于集中分析
  6. 安全脱敏:敏感信息自动脱敏,防止日志泄露
  7. ID 生成:时间戳+随机数的混合策略,兼顾有序性和唯一性

这些设计不仅保证了系统的稳定性和可靠性,也为开发者提供了清晰的接口契约和调试手段。在下一篇文章中,我们将探讨进阶开发与实践,包括调试技巧、性能优化和故障排查。

@tencent-weixin/openclaw-weixin 插件深度解析(二):消息处理系统架构

长轮询、入站/出站消息、斜杠命令

消息处理是即时通讯插件的核心能力。本文将深入剖析 OpenClaw WeChat 插件的消息处理系统,包括入站消息的处理流程、出站消息的发送机制、媒体文件的处理、斜杠命令系统以及错误处理与通知机制。通过详细的源码解读,帮助开发者理解其设计原理和实现细节。

一、消息处理架构概览

OpenClaw WeChat 插件的消息处理系统采用经典的"生产者-消费者"模式,结合长轮询机制实现实时消息收发:

┌─────────────────────────────────────────────────────────────────────────┐
│                         Message Processing Architecture                  │
├─────────────────────────────────────────────────────────────────────────┤
│  ┌──────────────┐      ┌──────────────┐      ┌──────────────────────┐  │
│  │   Monitor    │ ───> │   Process    │ ───> │     Dispatch         │  │
│  │  (Long Poll) │      │   Message    │      │   Reply Dispatcher   │  │
│  └──────────────┘      └──────────────┘      └──────────────────────┘  │
│         │                     │                         │               │
│         ▼                     ▼                         ▼               │
│  ┌──────────────┐      ┌──────────────┐      ┌──────────────────────┐  │
│  │ getUpdates   │      │ Media        │      │   AI Pipeline        │  │
│  │ Sync Buffer  │      │ Download     │      │   (Agent Reply)      │  │
│  └──────────────┘      └──────────────┘      └──────────────────────┘  │
├─────────────────────────────────────────────────────────────────────────┤
│                         Outbound Flow                                    │
├─────────────────────────────────────────────────────────────────────────┤
│  ┌──────────────┐      ┌──────────────┐      ┌──────────────────────┐  │
│  │   Deliver    │ ───> │   Upload     │ ───> │    Send Message      │  │
│  │   Callback   │      │   to CDN     │      │    (Weixin API)      │  │
│  └──────────────┘      └──────────────┘      └──────────────────────┘  │
└─────────────────────────────────────────────────────────────────────────┘

这种架构的优势在于:职责分离清晰,便于独立测试和维护;支持媒体文件的异步处理;通过长轮询实现低延迟消息接收;完善的错误处理和重试机制。

二、长轮询监控器(Monitor)

2.1 监控器核心循环

监控器是消息处理的入口,负责通过长轮询从微信服务器获取消息:

export async function monitorWeixinProvider(opts: MonitorWeixinOpts): Promise<void> {
  const {
    baseUrl,
    cdnBaseUrl,
    token,
    accountId,
    config,
    abortSignal,
    longPollTimeoutMs,
    setStatus,
  } = opts;
  const log = opts.runtime?.log ?? (() => {});
  const errLog = opts.runtime?.error ?? ((m: string) => log(m));
  const aLog: Logger = logger.withAccount(accountId);

  aLog.info(`waiting for Weixin runtime...`);
  let channelRuntime: PluginRuntime["channel"];
  try {
    const pluginRuntime = await waitForWeixinRuntime();
    channelRuntime = pluginRuntime.channel;
    aLog.info(`Weixin runtime acquired, channelRuntime type: ${typeof channelRuntime}`);
  } catch (err) {
    aLog.error(`waitForWeixinRuntime() failed: ${String(err)}`);
    throw err;
  }

  log(`weixin monitor started (${baseUrl}, account=${accountId})`);

监控器首先等待运行时初始化完成,这是与 OpenClaw 框架集成的关键步骤。

2.2 同步缓冲区管理

为了实现断点续传和消息不丢失,插件使用同步缓冲区(sync buffer)机制:

const syncFilePath = getSyncBufFilePath(accountId);
aLog.debug(`syncFilePath: ${syncFilePath}`);

const previousGetUpdatesBuf = loadGetUpdatesBuf(syncFilePath);
let getUpdatesBuf = previousGetUpdatesBuf ?? "";

if (previousGetUpdatesBuf) {
  log(`[weixin] resuming from previous sync buf (${getUpdatesBuf.length} bytes)`);
  aLog.debug(`Using previous get_updates_buf (${getUpdatesBuf.length} bytes)`);
} else {
  log(`[weixin] no previous sync buf, starting fresh`);
  aLog.info(`No previous get_updates_buf found, starting fresh`);
}

同步缓冲区的工作原理:

  1. 首次启动时,get_updates_buf 为空字符串
  2. 每次成功获取消息后,服务器返回新的 get_updates_buf
  3. 插件将其持久化到本地文件
  4. 重启后从文件恢复,确保消息连续性

2.3 长轮询与错误处理

监控器核心循环实现了完善的错误处理和退避策略:

while (!abortSignal?.aborted) {
  try {
    aLog.debug(
      `getUpdates: get_updates_buf=${getUpdatesBuf.substring(0, 50)}..., timeoutMs=${nextTimeoutMs}`,
    );
    const resp = await getUpdates({
      baseUrl,
      token,
      get_updates_buf: getUpdatesBuf,
      timeoutMs: nextTimeoutMs,
    });

    if (resp.longpolling_timeout_ms != null && resp.longpolling_timeout_ms > 0) {
      nextTimeoutMs = resp.longpolling_timeout_ms;
      aLog.debug(`Updated next poll timeout: ${nextTimeoutMs}ms`);
    }

    const isApiError =
      (resp.ret !== undefined && resp.ret !== 0) ||
      (resp.errcode !== undefined && resp.errcode !== 0);

    if (isApiError) {
      const isSessionExpired =
        resp.errcode === SESSION_EXPIRED_ERRCODE || resp.ret === SESSION_EXPIRED_ERRCODE;

      if (isSessionExpired) {
        pauseSession(accountId);
        const pauseMs = getRemainingPauseMs(accountId);
        errLog(
          `weixin getUpdates: session expired (errcode ${SESSION_EXPIRED_ERRCODE}), pausing bot for ${Math.ceil(pauseMs / 60_000)} min`,
        );
        consecutiveFailures = 0;
        await sleep(pauseMs, abortSignal);
        continue;
      }

      consecutiveFailures += 1;
      if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
        errLog(
          `weixin getUpdates: ${MAX_CONSECUTIVE_FAILURES} consecutive failures, backing off 30s`,
        );
        consecutiveFailures = 0;
        await sleep(BACKOFF_DELAY_MS, abortSignal);
      } else {
        await sleep(RETRY_DELAY_MS, abortSignal);
      }
      continue;
    }

    consecutiveFailures = 0;
    setStatus?.({ accountId, lastEventAt: Date.now() });

    if (resp.get_updates_buf != null && resp.get_updates_buf !== "") {
      saveGetUpdatesBuf(syncFilePath, resp.get_updates_buf);
      getUpdatesBuf = resp.get_updates_buf;
      aLog.debug(`Saved new get_updates_buf (${getUpdatesBuf.length} bytes)`);
    }

    const list = resp.msgs ?? [];
    for (const full of list) {
      // 处理每条消息...
    }
  } catch (err) {
    // 异常处理...
  }
}

错误处理策略包括:

  • 会话过期:暂停该账号 1 小时,避免频繁请求导致封号
  • 连续失败:最多容忍 3 次连续失败,之后退避 30 秒
  • 一般错误:2 秒后重试
  • 优雅退出:响应 abortSignal,确保资源正确释放

三、入站消息处理流程

3.1 消息处理入口

processOneMessage 是入站消息处理的核心函数,负责完整的处理流水线:

export async function processOneMessage(
  full: WeixinMessage,
  deps: ProcessMessageDeps,
): Promise<void> {
  if (!deps?.channelRuntime) {
    logger.error(
      `processOneMessage: channelRuntime is undefined, skipping message from=${full.from_user_id}`,
    );
    deps.errLog("processOneMessage: channelRuntime is undefined, skip");
    return;
  }

  const receivedAt = Date.now();
  const debug = isDebugMode(deps.accountId);
  const debugTrace: string[] = [];
  const debugTs: Record<string, number> = { received: receivedAt };

3.2 斜杠命令处理

在处理 AI 回复之前,首先检查是否是斜杠命令:

const textBody = extractTextBody(full.item_list);
if (textBody.startsWith("/")) {
  const slashResult = await handleSlashCommand(textBody, {
    to: full.from_user_id ?? "",
    contextToken: full.context_token,
    baseUrl: deps.baseUrl,
    token: deps.token,
    accountId: deps.accountId,
    log: deps.log,
    errLog: deps.errLog,
  }, receivedAt, full.create_time_ms);
  if (slashResult.handled) {
    logger.info(`[weixin] Slash command handled, skipping AI pipeline`);
    return;
  }
}

斜杠命令系统允许用户执行一些快捷操作,如 /echo/toggle-debug,这些命令直接响应,不经过 AI 处理管道。

3.3 媒体文件下载

微信消息可能包含图片、视频、文件或语音等媒体内容。插件需要下载并解密这些文件:

const mediaOpts: WeixinInboundMediaOpts = {};

// Find the first downloadable media item (priority: IMAGE > VIDEO > FILE > VOICE).
// When none found in the main item_list, fall back to media referenced via a quoted message.
const mainMediaItem =
  full.item_list?.find(
    (i) => i.type === MessageItemType.IMAGE && i.image_item?.media?.encrypt_query_param,
  ) ??
  full.item_list?.find(
    (i) => i.type === MessageItemType.VIDEO && i.video_item?.media?.encrypt_query_param,
  ) ??
  full.item_list?.find(
    (i) => i.type === MessageItemType.FILE && i.file_item?.media?.encrypt_query_param,
  ) ??
  full.item_list?.find(
    (i) =>
      i.type === MessageItemType.VOICE &&
      i.voice_item?.media?.encrypt_query_param &&
      !i.voice_item.text,
  );

const refMediaItem = !mainMediaItem
  ? full.item_list?.find(
      (i) =>
        i.type === MessageItemType.TEXT &&
        i.ref_msg?.message_item &&
        isMediaItem(i.ref_msg.message_item!),
    )?.ref_msg?.message_item
  : undefined;

const mediaDownloadStart = Date.now();
const mediaItem = mainMediaItem ?? refMediaItem;
if (mediaItem) {
  const label = refMediaItem ? "ref" : "inbound";
  const downloaded = await downloadMediaFromItem(mediaItem, {
    cdnBaseUrl: deps.cdnBaseUrl,
    saveMedia: deps.channelRuntime.media.saveMediaBuffer,
    log: deps.log,
    errLog: deps.errLog,
    label,
  });
  Object.assign(mediaOpts, downloaded);
}
const mediaDownloadMs = Date.now() - mediaDownloadStart;

媒体处理的优先级设计:

  1. 主消息媒体:优先处理消息本身附带的媒体
  2. 引用消息媒体:如果主消息没有媒体,检查是否引用了媒体消息
  3. 类型优先级:图片 > 视频 > 文件 > 语音
  4. 语音特殊处理:如果语音已转文字(有 text 字段),跳过下载

3.4 用户授权检查

在将消息路由给 AI 之前,需要检查发送者是否有权限:

const { senderAllowedForCommands, commandAuthorized } =
  await resolveSenderCommandAuthorizationWithRuntime({
    cfg: deps.config,
    rawBody,
    isGroup: false,
    dmPolicy: "pairing",
    configuredAllowFrom: [],
    configuredGroupAllowFrom: [],
    senderId,
    isSenderAllowed: (id: string, list: string[]) => list.length === 0 || list.includes(id),
    readAllowFromStore: async () => {
      const fromStore = readFrameworkAllowFromList(deps.accountId);
      if (fromStore.length > 0) return fromStore;
      const uid = loadWeixinAccount(deps.accountId)?.userId?.trim();
      return uid ? [uid] : [];
    },
    runtime: deps.channelRuntime.commands,
  });

const directDmOutcome = resolveDirectDmAuthorizationOutcome({
  isGroup: false,
  dmPolicy: "pairing",
  senderAllowedForCommands,
});

if (directDmOutcome === "disabled" || directDmOutcome === "unauthorized") {
  logger.info(
    `authorization: dropping message from=${senderId} outcome=${directDmOutcome}`,
  );
  return;
}

授权检查采用"配对"(pairing)模式:

  • 只有通过 QR 码登录授权的用户才能与 Bot 交互
  • 授权列表存储在框架的 allowFrom 文件中
  • 支持向后兼容:如果没有配对文件,使用登录时的 userId 作为备选

3.5 消息路由与会话管理

通过 OpenClaw 框架的路由系统,确定消息应该由哪个 Agent 处理:

const route = deps.channelRuntime.routing.resolveAgentRoute({
  cfg: deps.config,
  channel: "openclaw-weixin",
  accountId: deps.accountId,
  peer: { kind: "direct", id: ctx.To },
});

logger.debug(
  `resolveAgentRoute: agentId=${route.agentId ?? "(none)"} sessionKey=${route.sessionKey ?? "(none)"} mainSessionKey=${route.mainSessionKey ?? "(none)"}`,
);

if (!route.agentId) {
  logger.error(
    `resolveAgentRoute: no agentId resolved for peer=${ctx.To} accountId=${deps.accountId} — message will not be dispatched`,
  );
}

ctx.SessionKey = route.sessionKey;
const storePath = deps.channelRuntime.session.resolveStorePath(deps.config.session?.store, {
  agentId: route.agentId,
});
const finalized = deps.channelRuntime.reply.finalizeInboundContext(ctx);

路由解析后,消息上下文被"最终化"(finalize),准备进入 AI 处理管道。

3.6 入站会话记录

将消息记录到会话存储,用于维护对话上下文:

await deps.channelRuntime.session.recordInboundSession({
  storePath,
  sessionKey: route.sessionKey,
  ctx: finalized,
  updateLastRoute: {
    sessionKey: route.mainSessionKey,
    channel: "openclaw-weixin",
    to: ctx.To,
    accountId: deps.accountId,
  },
  onRecordError: (err) => deps.errLog(`recordInboundSession: ${String(err)}`),
});

const contextToken = getContextTokenFromMsgContext(ctx);
if (contextToken) {
  setContextToken(deps.accountId, full.from_user_id ?? "", contextToken);
}

这里同时缓存了 context_token,这是后续回复消息时必需的参数。

四、出站消息发送机制

4.1 回复分发器

OpenClaw 框架提供了回复分发器(Reply Dispatcher)机制,用于管理 AI 生成的回复:

const humanDelay = deps.channelRuntime.reply.resolveHumanDelayConfig(deps.config, route.agentId);

const hasTypingTicket = Boolean(deps.typingTicket);
const typingCallbacks = createTypingCallbacks({
  start: hasTypingTicket
    ? () =>
        sendTyping({
          baseUrl: deps.baseUrl,
          token: deps.token,
          body: {
            ilink_user_id: ctx.To,
            typing_ticket: deps.typingTicket!,
            status: TypingStatus.TYPING,
          },
        })
    : async () => {},
  stop: hasTypingTicket
    ? () =>
        sendTyping({
          baseUrl: deps.baseUrl,
          token: deps.token,
          body: {
            ilink_user_id: ctx.To,
            typing_ticket: deps.typingTicket!,
            status: TypingStatus.CANCEL,
          },
        })
    : async () => {},
  onStartError: (err) => deps.log(`[weixin] typing send error: ${String(err)}`),
  onStopError: (err) => deps.log(`[weixin] typing cancel error: ${String(err)}`),
  keepaliveIntervalMs: 5000,
});

打字指示器(Typing Indicator)通过 typingTicket 实现,让用户体验更加自然。

4.2 消息投递回调

deliver 回调函数负责实际的消息发送:

const { dispatcher, replyOptions, markDispatchIdle } =
  deps.channelRuntime.reply.createReplyDispatcherWithTyping({
    humanDelay,
    typingCallbacks,
    deliver: async (payload) => {
      const text = markdownToPlainText(payload.text ?? "");
      const mediaUrl = payload.mediaUrl ?? payload.mediaUrls?.[0];

      logger.debug(`outbound payload: ${redactBody(JSON.stringify(payload))}`);
      logger.info(
        `outbound: to=${ctx.To} contextToken=${redactToken(contextToken)} textLen=${text.length} mediaUrl=${mediaUrl ? "present" : "none"}`,
      );

      try {
        if (mediaUrl) {
          let filePath: string;
          if (!mediaUrl.includes("://") || mediaUrl.startsWith("file://")) {
            // Local path handling
            if (mediaUrl.startsWith("file://")) {
              filePath = new URL(mediaUrl).pathname;
            } else if (!path.isAbsolute(mediaUrl)) {
              filePath = path.resolve(mediaUrl);
            } else {
              filePath = mediaUrl;
            }
          } else if (mediaUrl.startsWith("http://") || mediaUrl.startsWith("https://")) {
            filePath = await downloadRemoteImageToTemp(mediaUrl, MEDIA_OUTBOUND_TEMP_DIR);
          } else {
            await sendMessageWeixin({ to: ctx.To, text, opts: {
              baseUrl: deps.baseUrl,
              token: deps.token,
              contextToken,
            }});
            return;
          }
          await sendWeixinMediaFile({
            filePath,
            to: ctx.To,
            text,
            opts: { baseUrl: deps.baseUrl, token: deps.token, contextToken },
            cdnBaseUrl: deps.cdnBaseUrl,
          });
        } else {
          await sendMessageWeixin({ to: ctx.To, text, opts: {
            baseUrl: deps.baseUrl,
            token: deps.token,
            contextToken,
          }});
        }
      } catch (err) {
        logger.error(`outbound: FAILED to=${ctx.To} err=${String(err)}`);
        throw err;
      }
    },
    onError: (err, info) => {
      // Error handling...
    },
  });

投递逻辑支持多种媒体来源:

  • 本地文件:绝对路径、相对路径或 file:// URL
  • 远程 URL:自动下载到临时目录
  • 纯文本:直接发送文字消息

4.3 Markdown 转纯文本

AI 生成的回复通常是 Markdown 格式,需要转换为纯文本以适应微信:

export function markdownToPlainText(text: string): string {
  let result = text;
  // Code blocks: strip fences, keep code content
  result = result.replace(/```[^\n]*\n?([\s\S]*?)```/g, (_, code: string) => code.trim());
  // Images: remove entirely
  result = result.replace(/!\[[^\]]*\]\([^)]*\)/g, "");
  // Links: keep display text only
  result = result.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1");
  // Tables: remove separator rows, then strip leading/trailing pipes
  result = result.replace(/^\|[\s:|-]+\|$/gm, "");
  result = result.replace(/^\|(.+)\|$/gm, (_, inner: string) =>
    inner.split("|").map((cell) => cell.trim()).join("  "),
  );
  result = stripMarkdown(result);
  return result;
}

转换规则包括:

  • 代码块:保留代码内容,去除围栏标记
  • 图片:完全移除(图片会作为独立媒体发送)
  • 链接:保留显示文本,去除 URL
  • 表格:转换为文本格式

五、媒体文件处理

5.1 媒体发送流程

sendWeixinMediaFile 函数根据文件类型选择不同的上传和发送策略:

export async function sendWeixinMediaFile(params: {
  filePath: string;
  to: string;
  text: string;
  opts: WeixinApiOptions & { contextToken?: string };
  cdnBaseUrl: string;
}): Promise<{ messageId: string }> {
  const { filePath, to, text, opts, cdnBaseUrl } = params;
  const mime = getMimeFromFilename(filePath);
  const uploadOpts: WeixinApiOptions = { baseUrl: opts.baseUrl, token: opts.token };

  if (mime.startsWith("video/")) {
    const uploaded = await uploadVideoToWeixin({
      filePath,
      toUserId: to,
      opts: uploadOpts,
      cdnBaseUrl,
    });
    return sendVideoMessageWeixin({ to, text, uploaded, opts });
  }

  if (mime.startsWith("image/")) {
    const uploaded = await uploadFileToWeixin({
      filePath,
      toUserId: to,
      opts: uploadOpts,
      cdnBaseUrl,
    });
    return sendImageMessageWeixin({ to, text, uploaded, opts });
  }

  // File attachment: pdf, doc, zip, etc.
  const fileName = path.basename(filePath);
  const uploaded = await uploadFileAttachmentToWeixin({
    filePath,
    fileName,
    toUserId: to,
    opts: uploadOpts,
    cdnBaseUrl,
  });
  return sendFileMessageWeixin({ to, text, fileName, uploaded, opts });
}

5.2 图片消息构建

图片消息需要包含加密参数和 AES 密钥:

export async function sendImageMessageWeixin(params: {
  to: string;
  text: string;
  uploaded: UploadedFileInfo;
  opts: WeixinApiOptions & { contextToken?: string };
}): Promise<{ messageId: string }> {
  const { to, text, uploaded, opts } = params;
  if (!opts.contextToken) {
    throw new Error("sendImageMessageWeixin: contextToken is required");
  }

  const imageItem: MessageItem = {
    type: MessageItemType.IMAGE,
    image_item: {
      media: {
        encrypt_query_param: uploaded.downloadEncryptedQueryParam,
        aes_key: Buffer.from(uploaded.aeskey).toString("base64"),
        encrypt_type: 1,
      },
      mid_size: uploaded.fileSizeCiphertext,
    },
  };

  return sendMediaItems({ to, text, mediaItem: imageItem, opts, label: "sendImageMessageWeixin" });
}

5.3 媒体项发送

当消息同时包含文字和媒体时,分别发送为独立的消息项:

async function sendMediaItems(params: {
  to: string;
  text: string;
  mediaItem: MessageItem;
  opts: WeixinApiOptions & { contextToken?: string };
  label: string;
}): Promise<{ messageId: string }> {
  const { to, text, mediaItem, opts, label } = params;

  const items: MessageItem[] = [];
  if (text) {
    items.push({ type: MessageItemType.TEXT, text_item: { text } });
  }
  items.push(mediaItem);

  let lastClientId = "";
  for (const item of items) {
    lastClientId = generateClientId();
    const req: SendMessageReq = {
      msg: {
        from_user_id: "",
        to_user_id: to,
        client_id: lastClientId,
        message_type: MessageType.BOT,
        message_state: MessageState.FINISH,
        item_list: [item],
        context_token: opts.contextToken ?? undefined,
      },
    };
    await sendMessageApi({
      baseUrl: opts.baseUrl,
      token: opts.token,
      timeoutMs: opts.timeoutMs,
      body: req,
    });
  }

  return { messageId: lastClientId };
}

六、斜杠命令系统

6.1 命令处理架构

斜杠命令系统提供了一种快捷方式,让用户可以直接执行特定操作:

export async function handleSlashCommand(
  content: string,
  ctx: SlashCommandContext,
  receivedAt: number,
  eventTimestamp?: number,
): Promise<SlashCommandResult> {
  const trimmed = content.trim();
  if (!trimmed.startsWith("/")) {
    return { handled: false };
  }

  const spaceIdx = trimmed.indexOf(" ");
  const command = spaceIdx === -1 ? trimmed.toLowerCase() : trimmed.slice(0, spaceIdx).toLowerCase();
  const args = spaceIdx === -1 ? "" : trimmed.slice(spaceIdx + 1);

  logger.info(`[weixin] Slash command: ${command}, args: ${args.slice(0, 50)}`);

  try {
    switch (command) {
      case "/echo":
        await handleEcho(ctx, args, receivedAt, eventTimestamp);
        return { handled: true };
      case "/toggle-debug": {
        const enabled = toggleDebugMode(ctx.accountId);
        await sendReply(ctx, enabled ? "Debug 模式已开启" : "Debug 模式已关闭");
        return { handled: true };
      }
      default:
        return { handled: false };
    }
  } catch (err) {
    logger.error(`[weixin] Slash command error: ${String(err)}`);
    try {
      await sendReply(ctx, `❌ 指令执行失败: ${String(err).slice(0, 200)}`);
    } catch {
      // 发送错误消息也失败了
    }
    return { handled: true };
  }
}

6.2 Echo 命令实现

/echo 命令用于测试通道延迟:

async function handleEcho(
  ctx: SlashCommandContext,
  args: string,
  receivedAt: number,
  eventTimestamp?: number,
): Promise<void> {
  const message = args.trim();
  if (message) {
    await sendReply(ctx, message);
  }
  const eventTs = eventTimestamp ?? 0;
  const platformDelay = eventTs > 0 ? `${receivedAt - eventTs}ms` : "N/A";
  const timing = [
    "⏱ 通道耗时",
    `├ 事件时间: ${eventTs > 0 ? new Date(eventTs).toISOString() : "N/A"}`,
    `├ 平台→插件: ${platformDelay}`,
    `└ 插件处理: ${Date.now() - receivedAt}ms`,
  ].join("\n");
  await sendReply(ctx, timing);
}

6.3 Debug 模式切换

/toggle-debug 命令用于开关调试模式:

export function toggleDebugMode(accountId: string): boolean {
  const state = loadState();
  const next = !state.accounts[accountId];
  state.accounts[accountId] = next;
  try {
    saveState(state);
  } catch (err) {
    logger.error(`debug-mode: failed to persist state: ${String(err)}`);
  }
  return next;
}

调试模式状态持久化到磁盘,确保网关重启后设置不丢失。

七、错误处理与通知

7.1 错误分类与处理

消息发送失败时,系统会尝试向用户发送错误通知:

onError: (err, info) => {
  deps.errLog(`weixin reply ${info.kind}: ${String(err)}`);
  const errMsg = err instanceof Error ? err.message : String(err);
  let notice: string;
  if (errMsg.includes("contextToken is required")) {
    logger.warn(`onError: contextToken missing, cannot send error notice to=${ctx.To}`);
    return;
  } else if (errMsg.includes("remote media download failed") || errMsg.includes("fetch")) {
    notice = `⚠️ 媒体文件下载失败,请检查链接是否可访问。`;
  } else if (
    errMsg.includes("getUploadUrl") ||
    errMsg.includes("CDN upload") ||
    errMsg.includes("upload_param")
  ) {
    notice = `⚠️ 媒体文件上传失败,请稍后重试。`;
  } else {
    notice = `⚠️ 消息发送失败:${errMsg}`;
  }
  void sendWeixinErrorNotice({
    to: ctx.To,
    contextToken,
    message: notice,
    baseUrl: deps.baseUrl,
    token: deps.token,
    errLog: deps.errLog,
  });
}

7.2 错误通知发送

错误通知采用"fire-and-forget"模式,不影响主流程:

export async function sendWeixinErrorNotice(params: {
  to: string;
  contextToken: string | undefined;
  message: string;
  baseUrl: string;
  token?: string;
  errLog: (m: string) => void;
}): Promise<void> {
  if (!params.contextToken) {
    logger.warn(`sendWeixinErrorNotice: no contextToken for to=${params.to}, cannot notify user`);
    return;
  }
  try {
    await sendMessageWeixin({ to: params.to, text: params.message, opts: {
      baseUrl: params.baseUrl,
      token: params.token,
      contextToken: params.contextToken,
    }});
    logger.debug(`sendWeixinErrorNotice: sent to=${params.to}`);
  } catch (err) {
    params.errLog(`[weixin] sendWeixinErrorNotice failed to=${params.to}: ${String(err)}`);
  }
}

八、调试模式与性能追踪

8.1 全链路耗时统计

当调试模式开启时,插件会在每条 AI 回复后追加详细的耗时统计:

if (debug && contextToken) {
  const dispatchDoneAt = Date.now();
  const eventTs = full.create_time_ms ?? 0;
  const platformDelay = eventTs > 0 ? `${receivedAt - eventTs}ms` : "N/A";
  const inboundProcessMs = (debugTs.preDispatch ?? receivedAt) - receivedAt;
  const aiMs = dispatchDoneAt - (debugTs.preDispatch ?? receivedAt);
  const totalTime = eventTs > 0 ? `${dispatchDoneAt - eventTs}ms` : `${dispatchDoneAt - receivedAt}ms`;

  debugTrace.push(
    "── 耗时 ──",
    `├ 平台→插件: ${platformDelay}`,
    `├ 入站处理(auth+route+media): ${inboundProcessMs}ms (mediaDownload: ${mediaDownloadMs}ms)`,
    `├ AI生成+回复: ${aiMs}ms`,
    `├ 总耗时: ${totalTime}`,
    `└ eventTime: ${eventTs > 0 ? new Date(eventTs).toISOString() : "N/A"}`,
  );

  const timingText = `⏱ Debug 全链路\n${debugTrace.join("\n")}`;
  await sendMessageWeixin({
    to: ctx.To,
    text: timingText,
    opts: { baseUrl: deps.baseUrl, token: deps.token, contextToken },
  });
}

8.2 调试追踪信息

调试模式下会记录完整的处理轨迹:

if (debug) {
  const itemTypes = full.item_list?.map((i) => i.type).join(",") ?? "none";
  debugTrace.push(
    "── 收消息 ──",
    `│ seq=${full.seq ?? "?"} msgId=${full.message_id ?? "?"} from=${full.from_user_id ?? "?"}`,
    `│ body="${textBody.slice(0, 40)}${textBody.length > 40 ? "…" : ""}" (len=${textBody.length}) itemTypes=[${itemTypes}]`,
    `│ sessionId=${full.session_id ?? "?"} contextToken=${full.context_token ? "present" : "none"}`,
  );
}

九、总结

OpenClaw WeChat 插件的消息处理系统展现了以下设计亮点:

  1. 长轮询架构:实现低延迟消息接收,支持断点续传
  2. 分层处理:监控、处理、发送职责分离,便于维护
  3. 媒体处理:支持多种媒体类型,自动下载解密
  4. 授权机制:基于配对的用户授权,确保安全性
  5. 错误恢复:完善的错误处理和用户通知机制
  6. 调试支持:全链路耗时追踪,便于性能优化

这些设计不仅保证了系统的稳定性和可靠性,也为开发者提供了丰富的调试和监控手段。在下一篇文章中,我们将深入探讨 CDN 媒体服务系统的加密与上传机制。

阶段二:为什么先设计指令集,编译器和运行时才能稳定对齐?

本章目标

这一章的任务是把“协议层”设计清楚。读完以后,你应该能回答:

  1. 一条 VM 指令由哪些部分组成?
  2. 为什么不能简单地“一个语法点对应一个 opcode”?
  3. registerslotconstant pool 为什么必须分离?
  4. 为什么 INIT_SLOTSTORE_SLOT 要从一开始就分开?

先看地图:指令集在整条链路中的位置

flowchart LR
    A["Lowering<br/>生成 IR"] --> B["Instruction Set<br/>定义动作协议"]
    B --> C["Emit<br/>编码为字节码"]
    B --> D["Runtime<br/>按协议解释执行"]

指令集不是实现细节,而是编译器和运行时共享的一份合同:

  • lowering 依赖它决定“我能发出哪些动作”。
  • emit 依赖它决定“这些动作如何编码”。
  • runtime 依赖它决定“数字该怎么解释”。

因此,指令集一旦混乱,三个阶段会一起变得难以维护。


为什么“一个语法点一个 opcode”不是好设计

JavaScript 的语法种类很多,但 VM 需要的不是“语法名录”,而是“可组合的基础动作”。例如:

var x = 40 + 2;

从源码角度看,它是“变量声明 + 二元表达式”;从 VM 角度看,它只需要拆成下面几步:

load_const  r0, 40
load_const  r1, 2
binary      r2, r0, r1, +
init_slot   slot0, r2

也就是说,高层语法会在 lowering 阶段被拆开,而底层 opcode 更适合围绕“最小动作”设计。

更稳的设计思路

类别 代表指令 作用
加载类 LOAD_CONST LOAD_SLOT LOAD_GLOBAL 把值加载到寄存器
存储类 INIT_SLOT STORE_SLOT STORE_GLOBAL 把寄存器结果写回某处
运算类 BINARY UNARY 在寄存器之间做计算
控制流 JUMP JUMP_IF_FALSE RETURN 改变执行路径

这类分层的好处是:语法可以继续扩,底层协议不必同步膨胀。


一条指令到底由什么组成

先看最小例子:

LOAD_CONST r0, 3

它至少包含两部分:

组成部分 含义
opcode 做什么
operand 对谁做、结果放哪、额外参数是什么

编码以后,同一条指令可能变成:

[1, 0, 3]

这里的关键不在数字本身,而在“读写规则必须一致”:

  • emit 写入几个数字
  • runtime 就必须按同样顺序读出几个数字

这也是为什么指令宽度要尽早固定。否则运行时的 pc 很容易错位。


为什么 registerslotconstant pool 要分离

第二章第一次把变量系统补上后,最容易混淆的就是这三类存储位置。

概念 典型内容 负责的问题
Register r0, r1, r2 当前表达式算到了哪里
Slot slot0, slot1 某个变量绑定住在哪里
Constant Pool 40, 2, "__result" 字节码中会重复引用哪些常量

三者分离后,系统会得到三个直接收益:

  1. 表达式求值不必和变量绑定耦合。
  2. 字节码不必反复内嵌相同字面量。
  3. 运行时的数据流与环境模型可以各自演进。

为什么 INIT_SLOTSTORE_SLOT 不能合并

这两个动作表面都像“往 slot 写值”,但语义完全不同:

指令 语义时机 后续扩展价值
INIT_SLOT 绑定第一次被初始化 let / const / TDZ 留出状态位
STORE_SLOT 已存在绑定被再次赋值 为可变绑定建立正常写路径

教程第二步对应的示例文件是:

  • docs/examples/tutorial-jsvm/02-slots-and-env.js

里面最关键的不是 opcode 数量,而是变量写入被拆成了两个阶段:

function writeSlot(env, slot, value, isInit) {
  if (isInit) {
    env.values[slot] = value
    env.states[slot] = 1
    return value
  }

  if (!env.states[slot]) {
    throw new Error(`slot ${slot} is not initialized`)
  }

  env.values[slot] = value
  return value
}

这段代码体现的是“状态机”思维,而不是“赋值就是覆盖”的直觉式实现。


第二章的最小成果:让变量第一次拥有自己的位置

教程示例中,下面这段源码:

var x = 40 + 2;
__result = x;

会被手工写成如下 program

const program = {
  slotCount: 1,
  constants: [40, 2, '__result'],
  bytecode: [
    OPCODES.LOAD_CONST, 0, 0,
    OPCODES.LOAD_CONST, 1, 1,
    OPCODES.BINARY, 2, 0, 1, BINARY_OPS.ADD,
    OPCODES.INIT_SLOT, 0, 2,
    OPCODES.LOAD_SLOT, 3, 0,
    OPCODES.STORE_GLOBAL, 2, 3,
    OPCODES.RETURN, 3,
  ],
}

如果按“执行视图”观察,它对应的是一条非常清晰的流水线:

步骤 指令 状态变化
1 LOAD_CONST r0, 40 把常量放进寄存器
2 LOAD_CONST r1, 2 再准备第二个操作数
3 BINARY r2, r0, r1, + 得到临时结果
4 INIT_SLOT slot0, r2 把变量 x 初始化到环境中
5 LOAD_SLOT r3, slot0 把变量值取回寄存器
6 STORE_GLOBAL "__result", r3 把结果写回宿主对象

这里最关键的结构变化是:变量值第一次不再“寄宿”于寄存器,而是进入了 env.values[slot]


指令集设计时,应该优先守住哪些原则

原则一:让运行时读取规则尽可能稳定

指令的编码规则一旦固定,pc 才能可预测地推进。

原则二:让高层语义拆成少量可复用动作

这样 lowering 才不会和 opcode 表一起失控膨胀。

原则三:为后续语义提前留接口

INIT_SLOT/STORE_SLOT 的分离,就是为提升、TDZ、不可变绑定预留空间。

原则四:让调试时能看出数据流

寄存器式 IR 与字节码最大的工程价值之一,就是更容易观察每一步的输入输出。


本章小结

这一章真正建立的是“协议意识”:

  • 指令集不是随手起名,而是编译器与运行时的共享合同。
  • opcode 设计应围绕最小动作,而不是围绕语法表面名称。
  • register / slot / constant pool 的分离,是系统稳定扩展的前提。
  • INIT_SLOTSTORE_SLOT 的区分,为 JavaScript 变量语义留出了落地空间。

下一章开始,我们就不再手写 program 对象,而是把源码真正降成 IR。

阶段一:从 0 看懂 JSVMP 架构,先在脑子里搭出一台最小 JSVM

本章目标

这一章要先把“机器全貌”搭出来。读完以后,你应该能回答:

  1. JSVMJSVMP、编译器、解释器之间是什么关系?
  2. 一段普通 JavaScript 进入系统后,会依次经历哪些形态?
  3. 为什么本项目选择寄存器机,而不是栈机?
  4. registerslotenv 为什么必须分离?

先看整机:一段源码在系统里的生命周期

flowchart LR
    A["Source<br/>var x = 40 + 2"] --> B["AST<br/>语法树"]
    B --> C["IR<br/>线性执行步骤"]
    C --> D["Bytecode<br/>数字协议"]
    D --> E["Runtime<br/>解释器循环"]
    E --> F["Result<br/>执行结果"]

这条流水线说明了一件事:JSVMP 不是“把源码塞进一段混淆代码里”,而是把源码翻译成另一套执行协议,再由内嵌虚拟机解释执行。

更精确地说,JSVMP = 编译期翻译 + 运行时重放语义


为什么 VM 的核心,其实只是一个状态机

先看最小解释器骨架:

function run(program) {
  const regs = []
  const code = program.bytecode
  let pc = 0

  while (pc < code.length) {
    const op = code[pc++]

    switch (op) {
      case OPCODES.LOAD_CONST:
        // ... 读操作数,写寄存器 ...
        break
      case OPCODES.BINARY:
        // ... 取寄存器,做运算,写回结果 ...
        break
      case OPCODES.RETURN:
        // ... 结束并返回 ...
        break
    }
  }
}

这段代码足以暴露 VM 的三件基础事实:

  • pc 负责指出“下一条指令从哪里开始读”。
  • regs 负责保存表达式求值过程中的中间结果。
  • switch(op) 负责把数字协议还原成真实动作。

从架构角度看,VM 的本质并不神秘。真正的难点在于:编译器输出的协议,必须和这个状态机逐项对齐。


为什么 AST 之后还要有 IR 这一层

先看同一段代码在两种表示下的差异:

源码

var x = 40 + 2;
__result = x;

AST 视角:强调“结构”

{
  "type": "VariableDeclaration",
  "declarations": [
    {
      "id": { "name": "x" },
      "init": {
        "type": "BinaryExpression",
        "left": { "type": "NumericLiteral", "value": 40 },
        "right": { "type": "NumericLiteral", "value": 2 }
      }
    }
  ]
}

IR 视角:强调“顺序”

load_const   r0, 40
load_const   r1, 2
binary       r2, r0, r1, +
init_slot    slot0, r2
load_slot    r3, slot0
store_global "__result", r3

两者都重要,但职责不同:

表示层 擅长表达什么 不擅长表达什么
AST 源码的嵌套结构 线性执行顺序
IR 逐步执行的动作序列 高层语法层次

这也是本系列教程把“AST -> IR”单独拿出来讲的原因。


为什么这里选择寄存器机,而不是栈机

同样是计算 40 + 2,两类 VM 的指令风格完全不同。

栈机:中间结果隐含在栈顶

PUSH 40
PUSH 2
ADD

寄存器机:中间结果显式落在目标位

LOAD_CONST r0, 40
LOAD_CONST r1, 2
BINARY     r2, r0, r1, +

本项目选择寄存器机,不是因为它“更高级”,而是因为它更贴合 lowering 的输出习惯:

  • AST 展平之后会自然产生大量临时值。
  • 这些临时值在寄存器模型里可以拥有稳定编号。
  • 当控制流、函数调用、对象访问逐步加入后,寄存器式 IR 更容易检查和调试。

两种模型的对比

维度 栈机 寄存器机
中间结果位置 隐含在栈顶 显式写在目标寄存器
指令长度 通常更短 通常更长
可读性 需要追踪栈变化 直接看到数据流向
调试体验 更依赖心算 更适合打印状态

为什么变量不能直接“住在寄存器里”

从执行角度看,表达式结果和变量绑定是两类完全不同的东西。

概念 作用 生命周期
Register 保存临时计算结果 通常只覆盖当前表达式
Slot 保存变量绑定对应的位置 伴随作用域存活
Env 管理一组 slot,并串成作用域链 伴随函数/块级作用域存活

可以把它们理解成三种不同的存储设施:

  • register 是桌面便签,适合临时放中间结果。
  • slot 是编号抽屉,适合保存变量绑定。
  • env 是整组抽屉组成的文件柜,负责向外层作用域链接。

这组分层会直接决定后面如何实现闭包与提升。


编译期和运行时为什么必须保持同构

编译器在 lowering 阶段会算出一个变量应该如何被访问:

load_slot dst=r4 depth=1 slot=0

这条指令其实已经携带了运行时假设:

  • 当前函数的环境不是目标环境。
  • 需要沿着 env.parent 向外走 1 层。
  • 到达目标环境后,从 slot0 读取值。

因此,编译器里的作用域分析和运行时里的环境链必须描述同一件事。它们不是“相似”,而是“同构”。

一旦两者对不齐,就会出现这类问题:

  • 编译期认为变量在外层,运行时却找错了层级。
  • 编译期把某个绑定当成可读,运行时却仍处于未初始化状态。

从最小示例看整机如何第一次跑通

教程第一步对应的配套文件是:

  • docs/examples/tutorial-jsvm/01-handwritten-register-vm.js

它只做一件事:用 LOAD_CONSTBINARYRETURN 三种指令跑通 40 + 2

const program = {
  constants: [40, 2],
  bytecode: [
    OPCODES.LOAD_CONST, 0, 0,
    OPCODES.LOAD_CONST, 1, 1,
    OPCODES.BINARY, 2, 0, 1, BINARY_OPS.ADD,
    OPCODES.RETURN, 2,
  ],
}

这个例子之所以重要,不在于它功能多,而在于它第一次把下面四个零件同时摆上桌面:

  1. 指令协议
  2. 运行时状态
  3. 字节码输入
  4. 返回出口

后面的章节,都是在这个最小框架上逐步补语义能力。


本章小结

这一章真正要建立的是“坐标系”:

  • JSVMP 是一条完整的编译执行流水线,不是单点技巧。
  • AST、IR、Bytecode、Runtime 各自负责不同层次的问题。
  • 寄存器机更适合承载 lowering 之后的线性步骤。
  • register / slot / env 的边界,是后续所有运行时语义的基础。

带着这套坐标再进入下一章,指令集就不再只是“列一张 opcode 表”,而会成为连接编译器与运行时的协议层。

实测 Claude 多 Agent 开发:项目经理开局摸鱼,我成了救火队员

最近玩了一下 Claude 的多 Agent 协作功能 —— 通过接入 Tmux 分屏同时拉起项目经理、前端、后端三个角色,让它们组成一个团队帮我做一个完整的博客系统。使用过程中踩了不少坑,记录一下真实感受。

屏幕录制 2026-03-22 222232.gif

安装方法(windows)

我电脑是windows,这里只介绍windows安装tmux方法

先要开启WSL,进入 WSL,执行:

sudo apt update
sudo apt install tmux -y

安装之后输入tmux,下面有绿色的条就是成功了

为了鼠标可以在不同agent窗口中进行点击 编辑 tmux 配置:

nano ~/.tmux.conf
set -g mouse on
set -g base-index 1

然后配置Claude开启多智能体团队功能

进入

~/.claude/settings.json

settings.json

{
  "env": {
    "ANTHROPIC_AUTH_TOKEN": "375c1cd4195447ea83b3b5c31ab09006.x7gXXB9BuNtcJjPw",
    "ANTHROPIC_BASE_URL": "https://open.bigmodel.cn/api/anthropic",
    "API_TIMEOUT_MS": "3000000",
    "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": 1,
    "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1"  --改这个
  },
  "permissions": {
    "allow": [
      "Bash(ask *)",
      "Bash(ccb-ping *)",
      "Bash(pend *)"
    ],
    "deny": []
  },
  "teammateMode": "tmux" --改这个
}
  • CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: "1"开启多智能体团队功能(实验性开关)
  • teammateMode: "tmux":指定用 tmux 来管理每个 agent 的独立会话

项目要先运行WSL,然后再运行tmux,再在里面启动claude

下发指令

给claude指令:

帮我创建一个 Agent Team,包含项目经理、前端工程师、后端工程师,等待我发布项目指令。

4abf27553eab0240675330c37578e827.png

我是让ai做一个博客系统,给出需求之后,项目经理进行规划,做了梳理功能、确定技术栈、设计数据库表、输出接口文档。

image.png

中间出现了WSL 里的服务连不上 Windows 上的 MySQL,手动解决了一下。 其他都是模型自动完成的。

开发过程中,后端自动帮我集成了一个数据库可视化管理工具,能直接在浏览器里查看表里的数据、新增编辑删除记录,还能清晰看到文章、分类、标签之间的关联关系。

image.png

进度方面是前端更快一些,不足的是项目经理在输出文档之后就没说过话了(可恶既然摸鱼,下次玩一定给pm加个kpi考核),正常好像应该是项目经理去push前后端进度吧,但是我这个team不太规范,是前后端一直在跟架构师去沟通。

image.png

验收, 前端打开页面,出现解决了下面问题

  • .tsx 写成了 .ts,导致页面直接报错
  • 引入了 @tanstack/react-query-devtools 却没装依赖,服务起不来
  • 代码里直接 import { AxiosResponse } from 'axios',新版 axios 根本不导出这个类型,直接 SyntaxError

前端生成的页面马马虎虎,然后分类这个标签切换不过去。

image.png

后端方面,设计的接口有点难评价,基本都跑不通。

image.png

总结:

我觉得之后要加一个任务完成之后的验收环节,整个开发流程还是不太规范。项目经理前期规划做得尚可,但后续全程缺位,没有推动进度、没有协调矛盾;前后端各自为战,遇到问题直接找我,缺少中间的统筹和监督,导致代码出现很多低级 bug,接口也无法正常联动,最后还是得自己手动排查、修改。

@tencent-weixin/openclaw-weixin 源码ContextToken 持久化改造:实现微信自定义消息发送能力

概述

在 OpenClaw 微信插件的开发过程中,一个核心挑战是如何实现可靠的出站消息发送(Outbound Messaging)。微信后端 API 要求每条出站消息都必须携带一个 context_token,这个令牌是通过 getupdates 接口在接收消息时返回的。原始实现将 contextToken 仅存储在内存中,导致每次网关重启或使用 CLI 命令时,出站消息发送都会失败。

本文将详细介绍如何通过引入持久化的 Context Token 存储机制,解决这一问题,从而实现稳定可靠的自定义消息发送能力。


问题背景

微信 API 的 Context Token 机制

微信的消息协议设计了一个重要的安全机制:context_token。这个令牌具有以下特点:

  1. 按消息发放:每次调用 getupdates 接口获取新消息时,服务器会为该对话返回一个 context_token
  2. 发送时必须携带:调用 sendmessage 接口发送消息时,必须将收到的 context_token 原样回传
  3. 用于会话关联:微信后端通过 context_token 来关联对话上下文,确保消息发送的合法性

原始实现的局限性

在改造之前,contextToken 仅以简单的内存 Map 形式存储:

// 原始实现 - 仅内存存储
const contextTokenStore = new Map<string, string>();

export function setContextToken(accountId: string, userId: string, token: string): void {
  const k = `${accountId}:${userId}`;
  contextTokenStore.set(k, token);  // 仅内存存储,进程结束即丢失
}

export function getContextToken(accountId: string, userId: string): string | undefined {
  return contextTokenStore.get(`${accountId}:${userId}`);
}

这种实现方式导致了以下问题:

场景 问题描述
网关重启 插件进程重启后,内存中的 contextToken 全部丢失,无法发送消息
CLI 命令 openclaw message send 命令会重新加载插件,无法访问之前的内存状态
首次出站 如果没有收到过该用户的消息,就没有 contextToken,无法主动发送消息

错误示例

当尝试在没有 contextToken 的情况下发送消息时,系统会抛出错误:

Error: sendWeixinOutbound: contextToken is required

或者:

Error: sendMessageWeixin: contextToken is required

解决方案:持久化 Context Token 存储

架构设计

为了解决上述问题,我们设计了一个双层存储架构

┌─────────────────────────────────────────────────────────────┐
│                    Context Token 存储架构                     │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  ┌──────────────────┐      ┌──────────────────────────┐    │
│  │  In-Memory Cache │      │  Persistent Storage      │    │
│  │  (Map)           │◄────►│  (FileSystem)            │    │
│  │                  │      │                          │    │
│  │  - 快速访问       │      │  - 进程间共享             │    │
│  │  - 运行时缓存     │      │  - 重启后恢复             │    │
│  │  - 毫秒级读取     │      │  - CLI 可访问            │    │
│  └──────────────────┘      └──────────────────────────┘    │
│           ▲                          ▲                     │
│           │                          │                     │
│           └──────────┬───────────────┘                     │
│                      │                                     │
│              ┌───────┴───────┐                            │
│              │  Token Store  │                            │
│              │   Manager     │                            │
│              └───────────────┘                            │
│                      │                                     │
│           ┌──────────┼──────────┐                         │
│           ▼          ▼          ▼                         │
│      ┌────────┐ ┌────────┐ ┌────────┐                    │
│      │ set()  │ │ get()  │ │clear() │                    │
│      └────────┘ └────────┘ └────────┘                    │
│                                                             │
└─────────────────────────────────────────────────────────────┘

存储路径设计

持久化文件存储在用户主目录下的 OpenClaw 配置目录中:

~/.openclaw/openclaw-weixin/context-tokens/
├── {accountId-1}/
│   ├── user1_im_wechat.json
│   ├── user2_im_wechat.json
│   └── ...
├── {accountId-2}/
│   ├── user3_im_wechat.json
│   └── ...
└── ...

每个文件对应一个 (accountId, userId) 组合,存储该对话的最新 contextToken


核心代码实现

1. 持久化存储模块:context-token-store.ts

这是整个持久化机制的基础模块,负责与文件系统交互。

import fs from "node:fs";
import path from "node:path";

import { resolveStateDir } from "./state-dir.js";
import { logger } from "../util/logger.js";

// ---------------------------------------------------------------------------
// Persistent Context Token Store
// ---------------------------------------------------------------------------

/**
 * Context token persistence for outbound messaging.
 * 
 * The Weixin API requires a context_token for every outbound message, which is
 * issued per-message by the getupdates API. This store persists the latest
 * contextToken to disk so that outbound messages can be sent even after the
 * gateway restarts or when using CLI commands.
 * 
 * Storage path: ~/.openclaw/openclaw-weixin/context-tokens/{accountId}/{userId}.json
 */

interface ContextTokenData {
  token: string;
  updatedAt: string;
}

function resolveContextTokensDir(): string {
  return path.join(resolveStateDir(), "openclaw-weixin", "context-tokens");
}

function resolveContextTokenPath(accountId: string, userId: string): string {
  // Sanitize userId for filesystem safety (replace @ and other special chars)
  const safeUserId = userId.replace(/[^a-zA-Z0-9_-]/g, "_");
  return path.join(resolveContextTokensDir(), accountId, `${safeUserId}.json`);
}

1.1 保存 Token:persistContextToken

/**
 * Persist a context token to disk.
 * Called when an inbound message is received with a new context_token.
 */
export function persistContextToken(accountId: string, userId: string, token: string): void {
  try {
    const filePath = resolveContextTokenPath(accountId, userId);
    const dir = path.dirname(filePath);
    
    // 确保目录存在(递归创建)
    fs.mkdirSync(dir, { recursive: true });
    
    const data: ContextTokenData = {
      token,
      updatedAt: new Date().toISOString(),
    };
    
    // 写入 JSON 文件,格式化便于调试
    fs.writeFileSync(filePath, JSON.stringify(data, null, 2), "utf-8");
    
    // 设置文件权限为仅所有者可读写(安全考虑)
    try {
      fs.chmodSync(filePath, 0o600);
    } catch {
      // best-effort
    }
    
    logger.debug(`persistContextToken: saved token for ${accountId}:${userId}`);
  } catch (err) {
    logger.error(`persistContextToken: failed to save token: ${String(err)}`);
  }
}

关键点说明

  • 路径安全处理userId 可能包含特殊字符(如 @),通过正则替换为下划线确保文件系统安全
  • 递归目录创建:使用 fs.mkdirSync(dir, { recursive: true }) 确保多级目录自动创建
  • 权限控制:设置 0o600 权限,仅允许文件所有者可读写,保护敏感 token 数据
  • 错误处理:采用 "best-effort" 策略,即使持久化失败也不影响主流程

1.2 加载 Token:loadPersistedContextToken

/**
 * Load a persisted context token from disk.
 * Returns undefined if no token exists or loading fails.
 */
export function loadPersistedContextToken(accountId: string, userId: string): string | undefined {
  try {
    const filePath = resolveContextTokenPath(accountId, userId);
    
    if (!fs.existsSync(filePath)) {
      return undefined;
    }
    
    const raw = fs.readFileSync(filePath, "utf-8");
    const data = JSON.parse(raw) as ContextTokenData;
    
    // 验证 token 格式
    if (typeof data.token === "string" && data.token.trim()) {
      logger.debug(`loadPersistedContextToken: loaded token for ${accountId}:${userId}`);
      return data.token;
    }
    
    return undefined;
  } catch (err) {
    logger.debug(`loadPersistedContextToken: failed to load token: ${String(err)}`);
    return undefined;
  }
}

关键点说明

  • 防御性编程:文件不存在、JSON 解析失败、token 格式不正确时都返回 undefined
  • 格式验证:确保加载的 token 是非空字符串

1.3 清除 Token:clearPersistedContextToken

/**
 * Clear a persisted context token (e.g., on session timeout or logout).
 */
export function clearPersistedContextToken(accountId: string, userId: string): void {
  try {
    const filePath = resolveContextTokenPath(accountId, userId);
    
    if (fs.existsSync(filePath)) {
      fs.unlinkSync(filePath);
      logger.debug(`clearPersistedContextToken: cleared token for ${accountId}:${userId}`);
    }
  } catch (err) {
    logger.error(`clearPersistedContextToken: failed to clear token: ${String(err)}`);
  }
}

1.4 批量加载:loadAllPersistedContextTokens

/**
 * Load all persisted context tokens for an account.
 * Returns a map of userId -> token.
 */
export function loadAllPersistedContextTokens(accountId: string): Map<string, string> {
  const result = new Map<string, string>();
  
  try {
    const accountDir = path.join(resolveContextTokensDir(), accountId);
    
    if (!fs.existsSync(accountDir)) {
      return result;
    }
    
    const files = fs.readdirSync(accountDir);
    
    for (const file of files) {
      if (!file.endsWith(".json")) continue;
      
      // Convert filename back to userId (approximate, since we sanitized it)
      const safeUserId = file.slice(0, -5); // remove .json
      const filePath = path.join(accountDir, file);
      
      try {
        const raw = fs.readFileSync(filePath, "utf-8");
        const data = JSON.parse(raw) as ContextTokenData;
        
        if (typeof data.token === "string" && data.token.trim()) {
          // Store with the safe userId - the actual lookup will use the same sanitization
          result.set(safeUserId, data.token);
        }
      } catch {
        // Skip invalid files
      }
    }
    
    logger.debug(`loadAllPersistedContextTokens: loaded ${result.size} tokens for ${accountId}`);
  } catch (err) {
    logger.debug(`loadAllPersistedContextTokens: failed to load tokens: ${String(err)}`);
  }
  
  return result;
}

2. 双层存储管理:inbound.ts

在持久化存储之上,我们构建了一个双层存储管理器,协调内存缓存和持久化存储的交互。

import { logger } from "../util/logger.js";
import { generateId } from "../util/random.js";
import type { WeixinMessage, MessageItem } from "../api/types.js";
import { MessageItemType } from "../api/types.js";
import {
  persistContextToken,
  loadPersistedContextToken,
  loadAllPersistedContextTokens,
} from "../storage/context-token-store.js";

// ---------------------------------------------------------------------------
// Context token store (in-process cache + persistent storage)
// ---------------------------------------------------------------------------

/**
 * contextToken is issued per-message by the Weixin getupdates API and must
 * be echoed verbatim in every outbound send. 
 * 
 * This store uses both in-memory cache and persistent storage:
 * - In-memory: fast access during gateway runtime
 * - Persistent: allows outbound messaging after gateway restart or via CLI
 * 
 * Storage path: ~/.openclaw/openclaw-weixin/context-tokens/{accountId}/{userId}.json
 */
const contextTokenStore = new Map<string, string>();

function contextTokenKey(accountId: string, userId: string): string {
  return `${accountId}:${userId}`;
}

2.1 存储 Token:setContextToken

/** 
 * Store a context token for a given account+user pair.
 * Persists to disk for CLI/outbound access after restart.
 */
export function setContextToken(accountId: string, userId: string, token: string): void {
  const k = contextTokenKey(accountId, userId);
  logger.debug(`setContextToken: key=${k}`);
  
  // 1. 写入内存缓存(快速访问)
  contextTokenStore.set(k, token);
  
  // 2. 同时持久化到磁盘(跨进程共享)
  persistContextToken(accountId, userId, token);
}

设计要点

  • 双写策略:每次设置 token 时,同时更新内存和磁盘
  • 以内存为准:内存缓存是运行时权威数据源
  • 磁盘为备份:磁盘存储用于进程重启后的恢复

2.2 获取 Token:getContextToken

/** 
 * Retrieve the cached context token for a given account+user pair.
 * Falls back to persisted storage if not in memory.
 */
export function getContextToken(accountId: string, userId: string): string | undefined {
  const k = contextTokenKey(accountId, userId);
  
  // 1. 首先检查内存缓存
  const cached = contextTokenStore.get(k);
  if (cached !== undefined) {
    logger.debug(`getContextToken: key=${k} found=in-memory storeSize=${contextTokenStore.size}`);
    return cached;
  }
  
  // 2. 内存未命中,尝试从持久化存储加载
  const persisted = loadPersistedContextToken(accountId, userId);
  if (persisted !== undefined) {
    // 回填到内存缓存,加速后续访问
    contextTokenStore.set(k, persisted);
    logger.debug(`getContextToken: key=${k} found=persisted storeSize=${contextTokenStore.size}`);
    return persisted;
  }
  
  logger.debug(`getContextToken: key=${k} found=false storeSize=${contextTokenStore.size}`);
  return undefined;
}

缓存策略

  • L1 缓存(内存):纳秒级访问速度
  • L2 缓存(磁盘):毫秒级访问速度
  • 回填机制:从磁盘加载后自动回填到内存,形成缓存预热

2.3 预加载 Token:preloadContextTokens

/**
 * Pre-load all persisted context tokens for an account into memory.
 * Called when an account starts to enable immediate outbound messaging.
 */
export function preloadContextTokens(accountId: string): void {
  const tokens = loadAllPersistedContextTokens(accountId);
  for (const [safeUserId, token] of tokens) {
    // Use the safe userId as the key (it was sanitized for filesystem)
    const k = `${accountId}:${safeUserId}`;
    contextTokenStore.set(k, token);
  }
  logger.info(`preloadContextTokens: loaded ${tokens.size} tokens for ${accountId}`);
}

使用场景

  • 网关启动时预加载所有历史 token
  • 账号重新连接时恢复会话状态
  • 确保重启后立即具备消息发送能力

3. 网关集成:channel.ts

持久化存储机制需要在网关生命周期中正确集成,才能发挥作用。

3.1 导入依赖

import path from "node:path";

import type { ChannelPlugin, OpenClawConfig } from "openclaw/plugin-sdk";
import { normalizeAccountId } from "openclaw/plugin-sdk";

import {
  registerWeixinAccountId,
  loadWeixinAccount,
  saveWeixinAccount,
  listWeixinAccountIds,
  resolveWeixinAccount,
  triggerWeixinChannelReload,
  DEFAULT_BASE_URL,
} from "./auth/accounts.js";
import type { ResolvedWeixinAccount } from "./auth/accounts.js";
import { assertSessionActive } from "./api/session-guard.js";
import { getContextToken, preloadContextTokens } from "./messaging/inbound.js";
import { logger } from "./util/logger.js";
// ... 其他导入

3.2 出站消息发送

async function sendWeixinOutbound(params: {
  cfg: OpenClawConfig;
  to: string;
  text: string;
  accountId?: string | null;
  contextToken?: string;
  mediaUrl?: string;
}): Promise<{ channel: string; messageId: string }> {
  const account = resolveWeixinAccount(params.cfg, params.accountId);
  const aLog = logger.withAccount(account.accountId);
  
  // 验证会话状态
  assertSessionActive(account.accountId);
  
  if (!account.configured) {
    aLog.error(`sendWeixinOutbound: account not configured`);
    throw new Error("weixin not configured: please run `openclaw channels login --channel openclaw-weixin`");
  }
  
  // 关键验证:必须有 contextToken
  if (!params.contextToken) {
    aLog.error(`sendWeixinOutbound: contextToken missing, refusing to send to=${params.to}`);
    throw new Error("sendWeixinOutbound: contextToken is required");
  }
  
  const result = await sendMessageWeixin({ 
    to: params.to, 
    text: params.text, 
    opts: {
      baseUrl: account.baseUrl,
      token: account.token,
      contextToken: params.contextToken,
    }
  });
  
  return { channel: "openclaw-weixin", messageId: result.messageId };
}

3.3 出站消息配置

export const weixinPlugin: ChannelPlugin<ResolvedWeixinAccount> = {
  // ... 其他配置
  
  outbound: {
    deliveryMode: "direct",
    textChunkLimit: 4000,
    
    // 发送文本消息
    sendText: async (ctx) => {
      const result = await sendWeixinOutbound({
        cfg: ctx.cfg,
        to: ctx.to,
        text: ctx.text,
        accountId: ctx.accountId,
        // 从存储中获取 contextToken
        contextToken: getContextToken(ctx.accountId!, ctx.to),
      });
      return result;
    },
    
    // 发送媒体消息
    sendMedia: async (ctx) => {
      const account = resolveWeixinAccount(ctx.cfg, ctx.accountId);
      const aLog = logger.withAccount(account.accountId);
      assertSessionActive(account.accountId);
      
      if (!account.configured) {
        aLog.error(`sendMedia: account not configured`);
        throw new Error(
          "weixin not configured: please run `openclaw channels login --channel openclaw-weixin`",
        );
      }

      const mediaUrl = ctx.mediaUrl;

      if (mediaUrl && (isLocalFilePath(mediaUrl) || isRemoteUrl(mediaUrl))) {
        let filePath: string;
        if (isLocalFilePath(mediaUrl)) {
          filePath = resolveLocalPath(mediaUrl);
          aLog.debug(`sendMedia: uploading local file ${filePath}`);
        } else {
          aLog.debug(`sendMedia: downloading remote mediaUrl=${mediaUrl.slice(0, 80)}...`);
          filePath = await downloadRemoteImageToTemp(mediaUrl, MEDIA_OUTBOUND_TEMP_DIR);
          aLog.debug(`sendMedia: remote image downloaded to ${filePath}`);
        }
        
        // 获取 contextToken 用于媒体发送
        const contextToken = getContextToken(account.accountId, ctx.to);
        const result = await sendWeixinMediaFile({
          filePath,
          to: ctx.to,
          text: ctx.text ?? "",
          opts: { baseUrl: account.baseUrl, token: account.token, contextToken },
          cdnBaseUrl: account.cdnBaseUrl,
        });
        return { channel: "openclaw-weixin", messageId: result.messageId };
      }

      // 回退到纯文本发送
      const result = await sendWeixinOutbound({
        cfg: ctx.cfg,
        to: ctx.to,
        text: ctx.text ?? "",
        accountId: ctx.accountId,
        contextToken: getContextToken(ctx.accountId!, ctx.to),
      });
      return result;
    },
  },
  
  // ... 其他配置
};

3.4 网关启动时预加载

gateway: {
  startAccount: async (ctx) => {
    logger.debug(`startAccount entry`);
    if (!ctx) {
      logger.warn(`gateway.startAccount: called with undefined ctx, skipping`);
      return;
    }
    const account = ctx.account;
    const aLog = logger.withAccount(account.accountId);
    aLog.debug(`about to call monitorWeixinProvider`);
    aLog.info(`starting weixin webhook`);

    ctx.setStatus?.({
      accountId: account.accountId,
      running: true,
      lastStartAt: Date.now(),
      lastEventAt: Date.now(),
    });

    if (!account.configured) {
      aLog.error(`account not configured`);
      ctx.log?.error?.(
        `[${account.accountId}] weixin not logged in — run: openclaw channels login --channel openclaw-weixin`,
      );
      ctx.setStatus?.({ accountId: account.accountId, running: false });
      throw new Error("weixin not configured: missing token");
    }

    ctx.log?.info?.(`[${account.accountId}] starting weixin provider (${DEFAULT_BASE_URL})`);

    const logPath = aLog.getLogFilePath();
    ctx.log?.info?.(`[${account.accountId}] weixin logs: ${logPath}`);

    // ═══════════════════════════════════════════════════════════════════
    // 关键:启动时预加载持久化的 context tokens
    // 这使得网关重启后立即具备出站消息发送能力
    // ═══════════════════════════════════════════════════════════════════
    preloadContextTokens(account.accountId);

    return monitorWeixinProvider({
      baseUrl: account.baseUrl,
      cdnBaseUrl: account.cdnBaseUrl,
      token: account.token,
      accountId: account.accountId,
      config: ctx.cfg,
      runtime: ctx.runtime,
      abortSignal: ctx.abortSignal,
      setStatus: ctx.setStatus,
    });
  },
  
  // ... 其他网关方法
}

4. 消息发送实现:send.ts

最后,我们来看实际的消息发送实现,它依赖于前面构建的 contextToken 机制。

import type { ReplyPayload } from "openclaw/plugin-sdk";
import { stripMarkdown } from "openclaw/plugin-sdk";

import { sendMessage as sendMessageApi } from "../api/api.js";
import type { WeixinApiOptions } from "../api/api.js";
import { logger } from "../util/logger.js";
import { generateId } from "../util/random.js";
import type { MessageItem, SendMessageReq } from "../api/types.js";
import { MessageItemType, MessageState, MessageType } from "../api/types.js";
import type { UploadedFileInfo } from "../cdn/upload.js";

function generateClientId(): string {
  return generateId("openclaw-weixin");
}

/**
 * Convert markdown-formatted model reply to plain text for Weixin delivery.
 * Preserves newlines; strips markdown syntax.
 */
export function markdownToPlainText(text: string): string {
  let result = text;
  // Code blocks: strip fences, keep code content
  result = result.replace(/```[^\n]*\n?([\s\S]*?)```/g, (_, code: string) => code.trim());
  // Images: remove entirely
  result = result.replace(/!\[[^\]]*\]\([^)]*\)/g, "");
  // Links: keep display text only
  result = result.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1");
  // Tables: remove separator rows, then strip leading/trailing pipes and convert inner pipes to spaces
  result = result.replace(/^\|[\s:|-]+\|$/gm, "");
  result = result.replace(/^\|(.+)\|$/gm, (_, inner: string) =>
    inner.split("|").map((cell) => cell.trim()).join("  "),
  );
  result = stripMarkdown(result);
  return result;
}

4.1 构建消息请求

/** Build a SendMessageReq containing a single text message. */
function buildTextMessageReq(params: {
  to: string;
  text: string;
  contextToken?: string;
  clientId: string;
}): SendMessageReq {
  const { to, text, contextToken, clientId } = params;
  const item_list: MessageItem[] = text
    ? [{ type: MessageItemType.TEXT, text_item: { text } }]
    : [];
  return {
    msg: {
      from_user_id: "",
      to_user_id: to,
      client_id: clientId,
      message_type: MessageType.BOT,
      message_state: MessageState.FINISH,
      item_list: item_list.length ? item_list : undefined,
      context_token: contextToken ?? undefined,  // 关键:传递 context_token
    },
  };
}

/** Build a SendMessageReq from a reply payload (text only; image send uses sendImageMessageWeixin). */
function buildSendMessageReq(params: {
  to: string;
  contextToken?: string;
  payload: ReplyPayload;
  clientId: string;
}): SendMessageReq {
  const { to, contextToken, payload, clientId } = params;
  return buildTextMessageReq({
    to,
    text: payload.text ?? "",
    contextToken,
    clientId,
  });
}

4.2 发送文本消息

/**
 * Send a plain text message downstream.
 * contextToken is required for all reply sends; missing it breaks conversation association.
 */
export async function sendMessageWeixin(params: {
  to: string;
  text: string;
  opts: WeixinApiOptions & { contextToken?: string };
}): Promise<{ messageId: string }> {
  const { to, text, opts } = params;
  
  // 严格检查:没有 contextToken 拒绝发送
  if (!opts.contextToken) {
    logger.error(`sendMessageWeixin: contextToken missing, refusing to send to=${to}`);
    throw new Error("sendMessageWeixin: contextToken is required");
  }
  
  const clientId = generateClientId();
  const req = buildSendMessageReq({
    to,
    contextToken: opts.contextToken,
    payload: { text },
    clientId,
  });
  
  try {
    await sendMessageApi({
      baseUrl: opts.baseUrl,
      token: opts.token,
      timeoutMs: opts.timeoutMs,
      body: req,
    });
  } catch (err) {
    logger.error(`sendMessageWeixin: failed to=${to} clientId=${clientId} err=${String(err)}`);
    throw err;
  }
  
  return { messageId: clientId };
}

4.3 发送媒体消息

/**
 * Send one or more MessageItems (optionally preceded by a text caption) downstream.
 * Each item is sent as its own request so that item_list always has exactly one entry.
 */
async function sendMediaItems(params: {
  to: string;
  text: string;
  mediaItem: MessageItem;
  opts: WeixinApiOptions & { contextToken?: string };
  label: string;
}): Promise<{ messageId: string }> {
  const { to, text, mediaItem, opts, label } = params;

  const items: MessageItem[] = [];
  if (text) {
    items.push({ type: MessageItemType.TEXT, text_item: { text } });
  }
  items.push(mediaItem);

  let lastClientId = "";
  for (const item of items) {
    lastClientId = generateClientId();
    const req: SendMessageReq = {
      msg: {
        from_user_id: "",
        to_user_id: to,
        client_id: lastClientId,
        message_type: MessageType.BOT,
        message_state: MessageState.FINISH,
        item_list: [item],
        context_token: opts.contextToken ?? undefined,  // 传递 context_token
      },
    };
    try {
      await sendMessageApi({
        baseUrl: opts.baseUrl,
        token: opts.token,
        timeoutMs: opts.timeoutMs,
        body: req,
      });
    } catch (err) {
      logger.error(
        `${label}: failed to=${to} clientId=${lastClientId} err=${String(err)}`,
      );
      throw err;
    }
  }

  logger.debug(`${label}: success to=${to} clientId=${lastClientId}`);
  return { messageId: lastClientId };
}

4.4 发送图片消息

/**
 * Send an image message downstream using a previously uploaded file.
 * Optionally include a text caption as a separate TEXT item before the image.
 *
 * ImageItem fields:
 *   - media.encrypt_query_param: CDN download param
 *   - media.aes_key: AES key, base64-encoded
 *   - mid_size: original ciphertext file size
 */
export async function sendImageMessageWeixin(params: {
  to: string;
  text: string;
  uploaded: UploadedFileInfo;
  opts: WeixinApiOptions & { contextToken?: string };
}): Promise<{ messageId: string }> {
  const { to, text, uploaded, opts } = params;
  
  // 同样需要 contextToken
  if (!opts.contextToken) {
    logger.error(`sendImageMessageWeixin: contextToken missing, refusing to send to=${to}`);
    throw new Error("sendImageMessageWeixin: contextToken is required");
  }
  
  logger.debug(
    `sendImageMessageWeixin: to=${to} filekey=${uploaded.filekey} fileSize=${uploaded.fileSize} aeskey=present`,
  );

  const imageItem: MessageItem = {
    type: MessageItemType.IMAGE,
    image_item: {
      media: {
        encrypt_query_param: uploaded.downloadEncryptedQueryParam,
        aes_key: Buffer.from(uploaded.aeskey).toString("base64"),
        encrypt_type: 1,
      },
      mid_size: uploaded.fileSizeCiphertext,
    },
  };

  return sendMediaItems({ to, text, mediaItem: imageItem, opts, label: "sendImageMessageWeixin" });
}

4.5 发送视频和文件消息

/**
 * Send a video message downstream using a previously uploaded file.
 * VideoItem: media (CDN ref), video_size (ciphertext bytes).
 * Includes an optional text caption sent as a separate TEXT item first.
 */
export async function sendVideoMessageWeixin(params: {
  to: string;
  text: string;
  uploaded: UploadedFileInfo;
  opts: WeixinApiOptions & { contextToken?: string };
}): Promise<{ messageId: string }> {
  const { to, text, uploaded, opts } = params;
  if (!opts.contextToken) {
    logger.error(`sendVideoMessageWeixin: contextToken missing, refusing to send to=${to}`);
    throw new Error("sendVideoMessageWeixin: contextToken is required");
  }

  const videoItem: MessageItem = {
    type: MessageItemType.VIDEO,
    video_item: {
      media: {
        encrypt_query_param: uploaded.downloadEncryptedQueryParam,
        aes_key: Buffer.from(uploaded.aeskey).toString("base64"),
        encrypt_type: 1,
      },
      video_size: uploaded.fileSizeCiphertext,
    },
  };

  return sendMediaItems({ to, text, mediaItem: videoItem, opts, label: "sendVideoMessageWeixin" });
}

/**
 * Send a file attachment downstream using a previously uploaded file.
 * FileItem: media (CDN ref), file_name, len (plaintext bytes as string).
 * Includes an optional text caption sent as a separate TEXT item first.
 */
export async function sendFileMessageWeixin(params: {
  to: string;
  text: string;
  fileName: string;
  uploaded: UploadedFileInfo;
  opts: WeixinApiOptions & { contextToken?: string };
}): Promise<{ messageId: string }> {
  const { to, text, fileName, uploaded, opts } = params;
  if (!opts.contextToken) {
    logger.error(`sendFileMessageWeixin: contextToken missing, refusing to send to=${to}`);
    throw new Error("sendFileMessageWeixin: contextToken is required");
  }
  
  const fileItem: MessageItem = {
    type: MessageItemType.FILE,
    file_item: {
      media: {
        encrypt_query_param: uploaded.downloadEncryptedQueryParam,
        aes_key: Buffer.from(uploaded.aeskey).toString("base64"),
        encrypt_type: 1,
      },
      file_name: fileName,
      len: String(uploaded.fileSize),
    },
  };

  return sendMediaItems({ to, text, mediaItem: fileItem, opts, label: "sendFileMessageWeixin" });
}

数据流全景图

以下是改造后的完整数据流:

┌─────────────────────────────────────────────────────────────────────────────┐
│                           入站消息流程 (Inbound)                              │
└─────────────────────────────────────────────────────────────────────────────┘

  ┌──────────────┐
  │ Weixin API   │  getupdates 返回消息 + context_token
  └──────┬───────┘
         │
         ▼
  ┌──────────────┐
  │  monitor.ts  │  解析消息,提取 context_token
  └──────┬───────┘
         │
         ▼
  ┌──────────────┐     ┌─────────────────┐
  │  inbound.ts  │────►│ 内存 Map 缓存    │
  │ setContextToken    └─────────────────┘
  └──────┬───────┘              │
         │                       │
         │              ┌────────▼────────┐
         │              │ 持久化存储      │
         └─────────────►│ (JSON 文件)     │
                        └─────────────────┘


┌─────────────────────────────────────────────────────────────────────────────┐
│                           出站消息流程 (Outbound)                             │
└─────────────────────────────────────────────────────────────────────────────┘

  ┌──────────────────┐
  │  发送请求来源     │
  │  - AI 自动回复    │
  │  - CLI 命令      │
  │  - 定时任务      │
  └────────┬─────────┘
           │
           ▼
  ┌──────────────────┐
  │   channel.tsgetContextToken(accountId, userId)
  │   sendText()     │
  └────────┬─────────┘
           │
           ▼
  ┌──────────────────┐     命中?    ┌─────────────────┐
  │   inbound.ts     │─────────────►│ 返回内存缓存    │
  │  getContextToken │              └─────────────────┘
  └────────┬─────────┘
           │ 未命中
           ▼
  ┌──────────────────┐     存在?    ┌─────────────────┐
  │ context-token-   │─────────────►│ 加载 + 回填缓存 │
  │ store.ts         │              │ 返回 token      │
  │ loadPersisted... │              └─────────────────┘
  └────────┬─────────┘
           │ 不存在
           ▼
  ┌──────────────────┐
  │  抛出错误         │
  │ "contextToken    │
  │  is required"    │
  └──────────────────┘
           │
           ▼ (token 存在)
  ┌──────────────────┐
  │    send.ts       │  构建请求 + context_token
  │ sendMessageWeixin│
  └────────┬─────────┘
           │
           ▼
  ┌──────────────────┐
  │    api.ts        │  HTTP POST sendmessage
  │ sendMessageApi   │
  └────────┬─────────┘
           │
           ▼
  ┌──────────────────┐
  │   Weixin API     │  消息发送成功
  └──────────────────┘

使用场景示例

场景 1:AI 自动回复

当用户发送消息触发 AI 响应时,流程如下:

// 1. 用户发送消息
const inboundMsg = await getUpdates();  // 返回 { ..., context_token: "abc123" }

// 2. 存储 contextToken
setContextToken("account-1", "user@im.wechat", "abc123");
// 同时写入:
// - 内存: contextTokenStore.set("account-1:user@im.wechat", "abc123")
// - 磁盘: ~/.openclaw/.../account-1/user_im_wechat.json

// 3. AI 生成回复
const reply = await ai.generateResponse(inboundMsg.body);

// 4. 发送回复(自动获取 contextToken)
await sendText({
  to: "user@im.wechat",
  text: reply,
  accountId: "account-1",
  // getContextToken("account-1", "user@im.wechat") 自动返回 "abc123"
});

场景 2:网关重启后恢复

// 网关启动
async function startAccount(ctx) {
  // 预加载所有持久化的 token
  preloadContextTokens("account-1");
  // 从磁盘加载 ~/.openclaw/.../account-1/*.json
  // 恢复到内存缓存
  
  // 现在可以立即发送消息,即使没有收到新消息
  await sendText({
    to: "user@im.wechat",
    text: "网关已重启,服务恢复正常",
    accountId: "account-1",
    // getContextToken 会从内存缓存返回之前持久化的 token
  });
}

场景 3:CLI 命令发送消息

# 使用 openclaw agent 命令(在网关会话中)
openclaw agent --session-id <session-id> --message "Hello" --deliver

# 由于 contextToken 已持久化,即使 CLI 重新加载插件也能获取到 token

总结

通过引入持久化的 Context Token 存储机制,我们解决了微信插件在出站消息发送方面的核心限制:

改进点 改造前 改造后
存储位置 仅内存 内存 + 磁盘
网关重启 Token 丢失,无法发送 从磁盘恢复,立即可用
CLI 命令 无法获取 Token 从磁盘读取 Token
首次出站 必须等待入站消息 使用历史 Token 即可发送
可靠性

核心设计原则

  1. 双写策略:内存和磁盘同时更新,确保数据一致性
  2. 分层缓存:内存优先,磁盘兜底,兼顾速度和可靠性
  3. 预加载机制:启动时批量恢复,避免冷启动延迟
  4. 防御性编程:所有文件操作都有错误处理,单点故障不影响整体
  5. 安全第一:敏感数据设置严格的文件权限(0o600)

代码文件清单

文件路径 职责
src/storage/context-token-store.ts 持久化存储实现
src/messaging/inbound.ts 双层存储管理器
src/channel.ts 网关集成和出站发送
src/messaging/send.ts 消息发送实现

这套机制确保了 OpenClaw 微信插件在各种场景下都能稳定可靠地发送消息,为 AI 助手与微信用户的交互提供了坚实的基础。

@tencent-weixin/openclaw-weixin 插件深度解析(三):CDN 媒体服务深度解析

媒体文件的处理是即时通讯插件的核心能力之一。微信采用 CDN(内容分发网络)存储媒体文件,并通过 AES-128-ECB 加密保护数据安全。本文将深入剖析 OpenClaw WeChat 插件的 CDN 媒体服务系统,包括上传流程、加密机制、下载解密、语音转码等关键技术实现。

一、CDN 媒体服务架构概览

微信的媒体文件存储采用分层架构,结合了业务服务器和 CDN 边缘节点:

┌─────────────────────────────────────────────────────────────────────────┐
│                      CDN Media Service Architecture                      │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                          │
│   ┌─────────────┐      ┌─────────────┐      ┌─────────────────────┐     │
│   │   Client    │      │   Weixin    │      │       CDN Node      │     │
│   │   (Plugin)  │ <--> │    API      │ <--> │  (Edge Server)      │     │
│   └─────────────┘      └─────────────┘      └─────────────────────┘     │
│          │                                              │                │
│          │  1. getUploadUrl (filekey, aeskey, md5)      │                │
│          │ <------------------------------------------  │                │
│          │                                              │                │
│          │  2. upload (encrypted bytes)                 │                │
│          │ -------------------------------------------> │                │
│          │                                              │                │
│          │  3. download_param (for future access)       │                │
│          │ <------------------------------------------  │                │
│          │                                              │                │
│          │  4. download (encrypted bytes)               │                │
│          │ <------------------------------------------  │                │
│          │                                              │                │
│          │  5. decrypt (AES-128-ECB)                    │                │
│          │  (local)                                     │                │
│                                                                          │
└─────────────────────────────────────────────────────────────────────────┘

这种架构的优势在于:敏感媒体文件不经过业务服务器,直接上传到 CDN;AES-128-ECB 加密确保数据在传输和存储过程中的安全性;CDN 边缘节点提供高可用、低延迟的访问;下载参数(download_param)实现了访问控制。

二、媒体上传流程

2.1 上传流程概览

媒体上传是一个多步骤流程,涉及加密、元数据准备、CDN 上传:

export type UploadedFileInfo = {
  filekey: string;
  /** 由 upload_param 上传后 CDN 返回的下载加密参数 */
  downloadEncryptedQueryParam: string;
  /** AES-128-ECB key, hex-encoded */
  aeskey: string;
  /** Plaintext file size in bytes */
  fileSize: number;
  /** Ciphertext file size in bytes (AES-128-ECB with PKCS7 padding) */
  fileSizeCiphertext: number;
};

async function uploadMediaToCdn(params: {
  filePath: string;
  toUserId: string;
  opts: WeixinApiOptions;
  cdnBaseUrl: string;
  mediaType: (typeof UploadMediaType)[keyof typeof UploadMediaType];
  label: string;
}): Promise<UploadedFileInfo> {
  const { filePath, toUserId, opts, cdnBaseUrl, mediaType, label } = params;

  const plaintext = await fs.readFile(filePath);
  const rawsize = plaintext.length;
  const rawfilemd5 = crypto.createHash("md5").update(plaintext).digest("hex");
  const filesize = aesEcbPaddedSize(rawsize);
  const filekey = crypto.randomBytes(16).toString("hex");
  const aeskey = crypto.randomBytes(16);

  logger.debug(
    `${label}: file=${filePath} rawsize=${rawsize} filesize=${filesize} md5=${rawfilemd5} filekey=${filekey}`,
  );

  const uploadUrlResp = await getUploadUrl({
    ...opts,
    filekey,
    media_type: mediaType,
    to_user_id: toUserId,
    rawsize,
    rawfilemd5,
    filesize,
    no_need_thumb: true,
    aeskey: aeskey.toString("hex"),
  });

  const uploadParam = uploadUrlResp.upload_param;
  if (!uploadParam) {
    throw new Error(`${label}: getUploadUrl returned no upload_param`);
  }

  const { downloadParam: downloadEncryptedQueryParam } = await uploadBufferToCdn({
    buf: plaintext,
    uploadParam,
    filekey,
    cdnBaseUrl,
    aeskey,
    label: `${label}[orig filekey=${filekey}]`,
  });

  return {
    filekey,
    downloadEncryptedQueryParam,
    aeskey: aeskey.toString("hex"),
    fileSize: rawsize,
    fileSizeCiphertext: filesize,
  };
}

上传流程的关键步骤:

  1. 读取文件:获取原始文件内容
  2. 计算元数据:原始大小、MD5 哈希、加密后大小
  3. 生成密钥:随机生成 filekey 和 AES 密钥
  4. 获取上传 URL:向微信 API 申请预签名上传 URL
  5. 上传加密文件:使用 AES-128-ECB 加密后上传到 CDN
  6. 获取下载参数:CDN 返回用于后续下载的加密参数

2.2 上传类型封装

插件为不同类型的媒体提供了便捷的封装函数:

/** Upload a local image file to the Weixin CDN with AES-128-ECB encryption. */
export async function uploadFileToWeixin(params: {
  filePath: string;
  toUserId: string;
  opts: WeixinApiOptions;
  cdnBaseUrl: string;
}): Promise<UploadedFileInfo> {
  return uploadMediaToCdn({
    ...params,
    mediaType: UploadMediaType.IMAGE,
    label: "uploadFileToWeixin",
  });
}

/** Upload a local video file to the Weixin CDN. */
export async function uploadVideoToWeixin(params: {
  filePath: string;
  toUserId: string;
  opts: WeixinApiOptions;
  cdnBaseUrl: string;
}): Promise<UploadedFileInfo> {
  return uploadMediaToCdn({
    ...params,
    mediaType: UploadMediaType.VIDEO,
    label: "uploadVideoToWeixin",
  });
}

/** Upload a local file attachment (non-image, non-video) to the Weixin CDN. */
export async function uploadFileAttachmentToWeixin(params: {
  filePath: string;
  fileName: string;
  toUserId: string;
  opts: WeixinApiOptions;
  cdnBaseUrl: string;
}): Promise<UploadedFileInfo> {
  return uploadMediaToCdn({
    ...params,
    mediaType: UploadMediaType.FILE,
    label: "uploadFileAttachmentToWeixin",
  });
}

媒体类型常量定义:

export const UploadMediaType = {
  IMAGE: 1,
  VIDEO: 2,
  FILE: 3,
  VOICE: 4,
} as const;

2.3 CDN 上传实现

实际的 CDN 上传操作在 uploadBufferToCdn 中实现:

const UPLOAD_MAX_RETRIES = 3;

export async function uploadBufferToCdn(params: {
  buf: Buffer;
  uploadParam: string;
  filekey: string;
  cdnBaseUrl: string;
  label: string;
  aeskey: Buffer;
}): Promise<{ downloadParam: string }> {
  const { buf, uploadParam, filekey, cdnBaseUrl, label, aeskey } = params;
  const ciphertext = encryptAesEcb(buf, aeskey);
  const cdnUrl = buildCdnUploadUrl({ cdnBaseUrl, uploadParam, filekey });
  logger.debug(`${label}: CDN POST url=${redactUrl(cdnUrl)} ciphertextSize=${ciphertext.length}`);

  let downloadParam: string | undefined;
  let lastError: unknown;

  for (let attempt = 1; attempt <= UPLOAD_MAX_RETRIES; attempt++) {
    try {
      const res = await fetch(cdnUrl, {
        method: "POST",
        headers: { "Content-Type": "application/octet-stream" },
        body: new Uint8Array(ciphertext),
      });
      if (res.status >= 400 && res.status < 500) {
        const errMsg = res.headers.get("x-error-message") ?? (await res.text());
        logger.error(
          `${label}: CDN client error attempt=${attempt} status=${res.status} errMsg=${errMsg}`,
        );
        throw new Error(`CDN upload client error ${res.status}: ${errMsg}`);
      }
      if (res.status !== 200) {
        const errMsg = res.headers.get("x-error-message") ?? `status ${res.status}`;
        logger.error(
          `${label}: CDN server error attempt=${attempt} status=${res.status} errMsg=${errMsg}`,
        );
        throw new Error(`CDN upload server error: ${errMsg}`);
      }
      downloadParam = res.headers.get("x-encrypted-param") ?? undefined;
      if (!downloadParam) {
        throw new Error("CDN upload response missing x-encrypted-param header");
      }
      logger.debug(`${label}: CDN upload success attempt=${attempt}`);
      break;
    } catch (err) {
      lastError = err;
      if (err instanceof Error && err.message.includes("client error")) throw err;
      if (attempt < UPLOAD_MAX_RETRIES) {
        logger.error(`${label}: attempt ${attempt} failed, retrying... err=${String(err)}`);
      }
    }
  }

  if (!downloadParam) {
    throw lastError instanceof Error
      ? lastError
      : new Error(`CDN upload failed after ${UPLOAD_MAX_RETRIES} attempts`);
  }
  return { downloadParam };
}

CDN 上传的关键设计点:

  • 重试机制:最多 3 次重试,客户端错误(4xx)立即失败,服务器错误(5xx)可重试
  • 错误分类:通过 HTTP 状态码区分错误类型
  • 响应头解析:从 x-encrypted-param 获取下载参数
  • URL 脱敏:日志中对 URL 进行脱敏处理,防止敏感信息泄露

三、AES-128-ECB 加密机制

3.1 加密算法实现

微信 CDN 使用 AES-128-ECB 模式进行加密,这是对称加密的一种:

import { createCipheriv, createDecipheriv } from "node:crypto";

/** Encrypt buffer with AES-128-ECB (PKCS7 padding is default). */
export function encryptAesEcb(plaintext: Buffer, key: Buffer): Buffer {
  const cipher = createCipheriv("aes-128-ecb", key, null);
  return Buffer.concat([cipher.update(plaintext), cipher.final()]);
}

/** Decrypt buffer with AES-128-ECB (PKCS7 padding). */
export function decryptAesEcb(ciphertext: Buffer, key: Buffer): Buffer {
  const decipher = createDecipheriv("aes-128-ecb", key, null);
  return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
}

/** Compute AES-128-ECB ciphertext size (PKCS7 padding to 16-byte boundary). */
export function aesEcbPaddedSize(plaintextSize: number): number {
  return Math.ceil((plaintextSize + 1) / 16) * 16;
}

3.2 填充机制

AES-128-ECB 要求数据长度是 16 字节(128 位)的倍数。PKCS7 填充规则:

  • 如果数据长度已经是 16 的倍数,添加 16 字节的填充(值为 16)
  • 否则,添加 n 字节的填充(值为 n),使总长度达到 16 的倍数

例如,一个 100 字节的数据:

原始大小: 100 字节
填充后大小: ceil((100 + 1) / 16) * 16 = ceil(6.3125) * 16 = 7 * 16 = 112 字节
填充字节数: 12 字节(每个值为 12)

3.3 安全考量

AES-128-ECB 模式的特点:

  • 优点:简单、并行化、无需初始化向量(IV)
  • 缺点:相同的明文块会产生相同的密文块,可能泄露模式信息
  • 微信的选择:对于媒体文件,ECB 模式的缺点影响较小,因为文件内容通常具有足够的随机性

密钥管理策略:

  • 每个文件使用独立的随机 AES 密钥
  • 密钥通过业务服务器传递给接收方
  • 密钥不持久化存储,仅在传输过程中使用

四、媒体下载与解密

4.1 下载流程

媒体下载是上传的逆过程,涉及 CDN 下载和本地解密:

/**
 * Download and AES-128-ECB decrypt a CDN media file. Returns plaintext Buffer.
 */
export async function downloadAndDecryptBuffer(
  encryptedQueryParam: string,
  aesKeyBase64: string,
  cdnBaseUrl: string,
  label: string,
): Promise<Buffer> {
  const key = parseAesKey(aesKeyBase64, label);
  const url = buildCdnDownloadUrl(encryptedQueryParam, cdnBaseUrl);
  logger.debug(`${label}: fetching url=${url}`);
  const encrypted = await fetchCdnBytes(url, label);
  logger.debug(`${label}: downloaded ${encrypted.byteLength} bytes, decrypting`);
  const decrypted = decryptAesEcb(encrypted, key);
  logger.debug(`${label}: decrypted ${decrypted.length} bytes`);
  return decrypted;
}

4.2 AES 密钥解析

微信的 AES 密钥有两种编码格式,需要兼容处理:

/**
 * Parse CDNMedia.aes_key into a raw 16-byte AES key.
 *
 * Two encodings are seen in the wild:
 *   - base64(raw 16 bytes)          → images
 *   - base64(hex string of 16 bytes) → file / voice / video
 */
function parseAesKey(aesKeyBase64: string, label: string): Buffer {
  const decoded = Buffer.from(aesKeyBase64, "base64");
  if (decoded.length === 16) {
    return decoded;
    }
  if (decoded.length === 32 && /^[0-9a-fA-F]{32}$/.test(decoded.toString("ascii"))) {
    // hex-encoded key: base64 → hex string → raw bytes
    return Buffer.from(decoded.toString("ascii"), "hex");
  }
  const msg = `${label}: aes_key must decode to 16 raw bytes or 32-char hex string, got ${decoded.length} bytes`;
  logger.error(msg);
  throw new Error(msg);
}

密钥格式说明:

  • 格式 1:直接 base64 编码的 16 字节原始密钥(主要用于图片)
  • 格式 2:base64 编码的 32 字符十六进制字符串(主要用于文件、语音、视频)

4.3 CDN URL 构建

CDN 上传和下载 URL 的构建规则:

/** Build a CDN download URL from encrypt_query_param. */
export function buildCdnDownloadUrl(encryptedQueryParam: string, cdnBaseUrl: string): string {
  return `${cdnBaseUrl}/download?encrypted_query_param=${encodeURIComponent(encryptedQueryParam)}`;
}

/** Build a CDN upload URL from upload_param and filekey. */
export function buildCdnUploadUrl(params: {
  cdnBaseUrl: string;
  uploadParam: string;
  filekey: string;
}): string {
  return `${params.cdnBaseUrl}/upload?encrypted_query_param=${encodeURIComponent(params.uploadParam)}&filekey=${encodeURIComponent(params.filekey)}`;
}

4.4 媒体类型处理

不同类型的媒体文件有不同的处理逻辑:

export async function downloadMediaFromItem(
  item: WeixinMessage["item_list"] extends (infer T)[] | undefined ? T : never,
  deps: {
    cdnBaseUrl: string;
    saveMedia: SaveMediaFn;
    log: (msg: string) => void;
    errLog: (msg: string) => void;
    label: string;
  },
): Promise<WeixinInboundMediaOpts> {
  const { cdnBaseUrl, saveMedia, log, errLog, label } = deps;
  const result: WeixinInboundMediaOpts = {};

  if (item.type === MessageItemType.IMAGE) {
    const img = item.image_item;
    if (!img?.media?.encrypt_query_param) return result;
    const aesKeyBase64 = img.aeskey
      ? Buffer.from(img.aeskey, "hex").toString("base64")
      : img.media.aes_key;
    
    const buf = aesKeyBase64
      ? await downloadAndDecryptBuffer(
          img.media.encrypt_query_param,
          aesKeyBase64,
          cdnBaseUrl,
          `${label} image`,
        )
      : await downloadPlainCdnBuffer(
          img.media.encrypt_query_param,
          cdnBaseUrl,
          `${label} image-plain`,
        );
    const saved = await saveMedia(buf, undefined, "inbound", WEIXIN_MEDIA_MAX_BYTES);
    result.decryptedPicPath = saved.path;
  }
  // ... 语音、文件、视频的处理
}

五、语音转码处理

5.1 SILK 格式简介

微信语音消息使用 SILK(Skype Lite)格式,这是一种高效的语音编码格式:

  • 采样率:24000 Hz(微信默认)
  • 编码方式:自适应多速率(AMR)的变体
  • 优点:高压缩率、低带宽占用
  • 缺点:需要转码才能在大多数播放器中使用

5.2 SILK 转 WAV 实现

插件支持将 SILK 格式转码为通用的 WAV 格式:

const SILK_SAMPLE_RATE = 24_000;

/**
 * Wrap raw pcm_s16le bytes in a WAV container.
 * Mono channel, 16-bit signed little-endian.
 */
function pcmBytesToWav(pcm: Uint8Array, sampleRate: number): Buffer {
  const pcmBytes = pcm.byteLength;
  const totalSize = 44 + pcmBytes;
  const buf = Buffer.allocUnsafe(totalSize);
  let offset = 0;

  // RIFF header
  buf.write("RIFF", offset);
  offset += 4;
  buf.writeUInt32LE(totalSize - 8, offset);
  offset += 4;
  buf.write("WAVE", offset);
  offset += 4;

  // fmt chunk
  buf.write("fmt ", offset);
  offset += 4;
  buf.writeUInt32LE(16, offset);
  offset += 4; // fmt chunk size
  buf.writeUInt16LE(1, offset);
  offset += 2; // PCM format
  buf.writeUInt16LE(1, offset);
  offset += 2; // mono
  buf.writeUInt32LE(sampleRate, offset);
  offset += 4;
  buf.writeUInt32LE(sampleRate * 2, offset);
  offset += 4; // byte rate (mono 16-bit)
  buf.writeUInt16LE(2, offset);
  offset += 2; // block align
  buf.writeUInt16LE(16, offset);
  offset += 2; // bits per sample

  // data chunk
  buf.write("data", offset);
  offset += 4;
  buf.writeUInt32LE(pcmBytes, offset);
  offset += 4;

  Buffer.from(pcm.buffer, pcm.byteOffset, pcm.byteLength).copy(buf, offset);

  return buf;
}

5.3 转码流程

使用 silk-wasm 库进行解码:

export async function silkToWav(silkBuf: Buffer): Promise<Buffer | null> {
  try {
    const { decode } = await import("silk-wasm");

    logger.debug(`silkToWav: decoding ${silkBuf.length} bytes of SILK`);
    const result = await decode(silkBuf, SILK_SAMPLE_RATE);
    logger.debug(
      `silkToWav: decoded duration=${result.duration}ms pcmBytes=${result.data.byteLength}`,
    );

    const wav = pcmBytesToWav(result.data, SILK_SAMPLE_RATE);
    logger.debug(`silkToWav: WAV size=${wav.length}`);
    return wav;
  } catch (err) {
    logger.warn(`silkToWav: transcode failed, will use raw silk err=${String(err)}`);
    return null;
  }
}

转码失败时的回退策略:

if (item.type === MessageItemType.VOICE) {
  const voice = item.voice_item;
  if (!voice?.media?.encrypt_query_param || !voice.media.aes_key) return result;
  
  const silkBuf = await downloadAndDecryptBuffer(
    voice.media.encrypt_query_param,
    voice.media.aes_key,
    cdnBaseUrl,
    `${label} voice`,
  );
  
  const wavBuf = await silkToWav(silkBuf);
  if (wavBuf) {
    const saved = await saveMedia(wavBuf, "audio/wav", "inbound", WEIXIN_MEDIA_MAX_BYTES);
    result.decryptedVoicePath = saved.path;
    result.voiceMediaType = "audio/wav";
  } else {
    // 转码失败,保存原始 SILK 文件
    const saved = await saveMedia(silkBuf, "audio/silk", "inbound", WEIXIN_MEDIA_MAX_BYTES);
    result.decryptedVoicePath = saved.path;
    result.voiceMediaType = "audio/silk";
  }
}

六、MIME 类型处理

6.1 MIME 类型映射

插件维护了常见文件扩展名与 MIME 类型的映射表:

const EXTENSION_TO_MIME: Record<string, string> = {
  ".pdf": "application/pdf",
  ".doc": "application/msword",
  ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
  ".xls": "application/vnd.ms-excel",
  ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
  ".ppt": "application/vnd.ms-powerpoint",
  ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
  ".txt": "text/plain",
  ".csv": "text/csv",
  ".zip": "application/zip",
  ".mp3": "audio/mpeg",
  ".wav": "audio/wav",
  ".mp4": "video/mp4",
  ".png": "image/png",
  ".jpg": "image/jpeg",
  ".jpeg": "image/jpeg",
  ".gif": "image/gif",
  ".webp": "image/webp",
  // ... 更多类型
};

const MIME_TO_EXTENSION: Record<string, string> = {
  "image/jpeg": ".jpg",
  "image/png": ".png",
  "image/gif": ".gif",
  "video/mp4": ".mp4",
  "audio/mpeg": ".mp3",
  "application/pdf": ".pdf",
  // ... 反向映射
};

6.2 MIME 类型解析函数

/** Get MIME type from filename extension. */
export function getMimeFromFilename(filename: string): string {
  const ext = path.extname(filename).toLowerCase();
  return EXTENSION_TO_MIME[ext] ?? "application/octet-stream";
}

/** Get file extension from MIME type. */
export function getExtensionFromMime(mimeType: string): string {
  const ct = mimeType.split(";")[0].trim().toLowerCase();
  return MIME_TO_EXTENSION[ct] ?? ".bin";
}

/** Get file extension from Content-Type header or URL path. */
export function getExtensionFromContentTypeOrUrl(contentType: string | null, url: string): string {
  if (contentType) {
    const ext = getExtensionFromMime(contentType);
    if (ext !== ".bin") return ext;
  }
  const ext = path.extname(new URL(url).pathname).toLowerCase();
  const knownExts = new Set(Object.keys(EXTENSION_TO_MIME));
  return knownExts.has(ext) ? ext : ".bin";
}

七、远程媒体下载

7.1 远程 URL 下载

当 AI 需要发送远程图片时,插件会先下载到本地临时文件:

/**
 * Download a remote media URL (image, video, file) to a local temp file in destDir.
 */
export async function downloadRemoteImageToTemp(url: string, destDir: string): Promise<string> {
  logger.debug(`downloadRemoteImageToTemp: fetching url=${url}`);
  const res = await fetch(url);
  if (!res.ok) {
    const msg = `remote media download failed: ${res.status} ${res.statusText} url=${url}`;
    logger.error(`downloadRemoteImageToTemp: ${msg}`);
    throw new Error(msg);
  }
  const buf = Buffer.from(await res.arrayBuffer());
  logger.debug(`downloadRemoteImageToTemp: downloaded ${buf.length} bytes`);
  await fs.mkdir(destDir, { recursive: true });
  const ext = getExtensionFromContentTypeOrUrl(res.headers.get("content-type"), url);
  const name = tempFileName("weixin-remote", ext);
  const filePath = path.join(destDir, name);
  await fs.writeFile(filePath, buf);
  logger.debug(`downloadRemoteImageToTemp: saved to ${filePath} ext=${ext}`);
  return filePath;
}

7.2 临时文件管理

下载的远程文件保存在临时目录,由框架统一管理生命周期:

const MEDIA_OUTBOUND_TEMP_DIR = "/tmp/openclaw/weixin/media/outbound-temp";

八、配置缓存管理

8.1 用户配置缓存

为了优化性能,插件缓存每个用户的配置信息(如 typing_ticket):

export interface CachedConfig {
  typingTicket: string;
}

const CONFIG_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
const CONFIG_CACHE_INITIAL_RETRY_MS = 2_000;
const CONFIG_CACHE_MAX_RETRY_MS = 60 * 60 * 1000;

export class WeixinConfigManager {
  private cache = new Map<string, ConfigCacheEntry>();

  constructor(
    private apiOpts: { baseUrl: string; token?: string },
    private log: (msg: string) => void,
  ) {}

  async getForUser(userId: string, contextToken?: string): Promise<CachedConfig> {
    const now = Date.now();
    const entry = this.cache.get(userId);
    const shouldFetch = !entry || now >= entry.nextFetchAt;

    if (shouldFetch) {
      let fetchOk = false;
      try {
        const resp = await getConfig({
          baseUrl: this.apiOpts.baseUrl,
          token: this.apiOpts.token,
          ilinkUserId: userId,
          contextToken,
        });
        if (resp.ret === 0) {
          this.cache.set(userId, {
            config: { typingTicket: resp.typing_ticket ?? "" },
            everSucceeded: true,
            nextFetchAt: now + Math.random() * CONFIG_CACHE_TTL_MS,
            retryDelayMs: CONFIG_CACHE_INITIAL_RETRY_MS,
          });
          fetchOk = true;
        }
      } catch (err) {
        this.log(`[weixin] getConfig failed for ${userId} (ignored): ${String(err)}`);
      }
      
      if (!fetchOk) {
        // 指数退避重试
        const prevDelay = entry?.retryDelayMs ?? CONFIG_CACHE_INITIAL_RETRY_MS;
        const nextDelay = Math.min(prevDelay * 2, CONFIG_CACHE_MAX_RETRY_MS);
        if (entry) {
          entry.nextFetchAt = now + nextDelay;
          entry.retryDelayMs = nextDelay;
        }
      }
    }

    return this.cache.get(userId)?.config ?? { typingTicket: "" };
  }
}

8.2 缓存策略

配置缓存采用以下策略:

  • TTL:24 小时,随机分布避免缓存雪崩
  • 失败重试:指数退避,从 2 秒到最大 1 小时
  • 内存存储:每个用户独立的缓存条目
  • 优雅降级:获取失败时返回空配置,不影响主流程

九、总结

OpenClaw WeChat 插件的 CDN 媒体服务系统展现了以下技术特点:

  1. 安全传输:AES-128-ECB 加密确保媒体文件安全
  2. 分层存储:业务服务器与 CDN 分离,提升性能和可靠性
  3. 类型支持:图片、视频、文件、语音等多种媒体类型
  4. 语音转码:SILK 到 WAV 的自动转码,提升兼容性
  5. 容错设计:重试机制、失败回退、优雅降级
  6. 性能优化:配置缓存、MIME 类型快速识别

这些设计不仅满足了微信平台的特殊要求,也为开发者提供了稳定可靠的媒体处理能力。在下一篇文章中,我们将探讨 API 协议与数据流设计的细节。

@tencent-weixin/openclaw-weixin 插件深度解析(一):认证与会话管理机制

QR 码登录、账户管理、Session Guard

在即时通讯插件的开发中,认证与会话管理是核心基础设施。本文将深入剖析 OpenClaw WeChat 插件的认证体系,包括 QR 码登录流程、账户配对机制、多账号管理以及会话状态保护等关键模块。通过详细的源码解读,帮助开发者理解其设计原理和实现细节。

一、架构概览

OpenClaw WeChat 插件的认证系统采用分层架构设计,主要包含以下核心模块:

┌─────────────────────────────────────────────────────────────┐
│                    Authentication Layer                      │
├─────────────────────────────────────────────────────────────┤
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────────┐  │
│  │  QR Code     │  │   Account    │  │   Session Guard  │  │
│  │  Login       │  │   Manager    │  │                  │  │
│  └──────────────┘  └──────────────┘  └──────────────────┘  │
├─────────────────────────────────────────────────────────────┤
│                    Storage Layer                             │
├─────────────────────────────────────────────────────────────┤
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────────┐  │
│  │ Account Index│  │ Account Data │  │  AllowFrom Store │  │
│  │ (accounts.json)│  │ (*.json)     │  │  (pairing)       │  │
│  └──────────────┘  └──────────────┘  └──────────────────┘  │
└─────────────────────────────────────────────────────────────┘

这种分层设计带来了几个显著优势:首先,认证逻辑与存储逻辑解耦,便于独立测试和维护;其次,支持多账号并发管理,每个账号拥有独立的凭证存储;最后,会话保护机制可以防止因频繁 API 调用导致的账号限制。

二、QR 码登录机制详解

2.1 登录流程状态机

QR 码登录是一个典型的异步流程,涉及多个状态转换。插件实现了完整的状态机来管理这个过程:

type ActiveLogin = {
  sessionKey: string;
  id: string;
  qrcode: string;
  qrcodeUrl: string;
  startedAt: number;
  botToken?: string;
  status?: "wait" | "scaned" | "confirmed" | "expired";
  error?: string;
};

登录状态包含四个阶段:

  • wait:二维码已生成,等待用户扫描
  • scaned:用户已扫码,在微信端确认中
  • confirmed:用户确认登录,获取到 bot_token
  • expired:二维码过期,需要刷新

这种设计使得登录过程可以被中断和恢复,支持在 CLI 和 Gateway 两种模式下使用。

2.2 二维码获取与长轮询

登录流程始于向微信服务器申请二维码。插件通过 fetchQRCode 函数发起请求:

async function fetchQRCode(apiBaseUrl: string, botType: string): Promise<QRCodeResponse> {
  const base = apiBaseUrl.endsWith("/") ? apiBaseUrl : `${apiBaseUrl}/`;
  const url = new URL(`ilink/bot/get_bot_qrcode?bot_type=${encodeURIComponent(botType)}`, base);
  logger.info(`Fetching QR code from: ${url.toString()}`);

  const headers: Record<string, string> = {};
  const routeTag = loadConfigRouteTag();
  if (routeTag) {
    headers.SKRouteTag = routeTag;
  }

  const response = await fetch(url.toString(), { headers });
  if (!response.ok) {
    const body = await response.text().catch(() => "(unreadable)");
    logger.error(`QR code fetch failed: ${response.status} ${response.statusText} body=${body}`);
    throw new Error(`Failed to fetch QR code: ${response.status} ${response.statusText}`);
  }
  return await response.json();
}

这里有几个值得注意的设计点。首先是 SKRouteTag 请求头的支持,这允许通过配置指定路由标签,在多租户或代理场景下非常有用。其次是详细的错误日志记录,包括状态码和响应体,便于问题排查。

获取二维码后,插件进入长轮询状态检查阶段:

async function pollQRStatus(apiBaseUrl: string, qrcode: string): Promise<StatusResponse> {
  const base = apiBaseUrl.endsWith("/") ? apiBaseUrl : `${apiBaseUrl}/`;
  const url = new URL(`ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(qrcode)}`, base);
  logger.debug(`Long-poll QR status from: ${url.toString()}`);

  const headers: Record<string, string> = {
    "iLink-App-ClientVersion": "1",
  };
  const routeTag = loadConfigRouteTag();
  if (routeTag) {
    headers.SKRouteTag = routeTag;
  }

  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), QR_LONG_POLL_TIMEOUT_MS);
  try {
    const response = await fetch(url.toString(), { headers, signal: controller.signal });
    clearTimeout(timer);
    logger.debug(`pollQRStatus: HTTP ${response.status}, reading body...`);
    const rawText = await response.text();
    logger.debug(`pollQRStatus: body=${rawText.substring(0, 200)}`);
    if (!response.ok) {
      logger.error(`QR status poll failed: ${response.status} ${response.statusText} body=${rawText}`);
      throw new Error(`Failed to poll QR status: ${response.status} ${response.statusText}`);
    }
    return JSON.parse(rawText) as StatusResponse;
  } catch (err) {
    clearTimeout(timer);
    if (err instanceof Error && err.name === "AbortError") {
      logger.debug(`pollQRStatus: client-side timeout after ${QR_LONG_POLL_TIMEOUT_MS}ms, returning wait`);
      return { status: "wait" };
    }
    throw err;
  }
}

长轮询的实现使用了 AbortController 来处理超时。客户端设置 35 秒的超时时间,如果服务器在此时间内没有返回,则视为正常的长轮询超时,返回 "wait" 状态继续下一轮轮询。这种设计避免了保持长连接导致的资源浪费,同时保证了实时性。

2.3 登录会话管理与自动刷新

登录会话在内存中通过 activeLogins Map 进行管理:

const activeLogins = new Map<string, ActiveLogin>();
const ACTIVE_LOGIN_TTL_MS = 5 * 60_000;

每个登录会话有 5 分钟的有效期。插件实现了自动清理机制来防止内存泄漏:

function purgeExpiredLogins(): void {
  for (const [id, login] of activeLogins) {
    if (!isLoginFresh(login)) {
      activeLogins.delete(id);
    }
  }
}

function isLoginFresh(login: ActiveLogin): boolean {
  return Date.now() - login.startedAt < ACTIVE_LOGIN_TTL_MS;
}

更重要的是,插件支持二维码自动刷新机制。当二维码过期时,系统会自动重新获取新的二维码,最多尝试 3 次:

case "expired": {
  qrRefreshCount++;
  if (qrRefreshCount > MAX_QR_REFRESH_COUNT) {
    logger.warn(
      `waitForWeixinLogin: QR expired ${MAX_QR_REFRESH_COUNT} times, giving up sessionKey=${opts.sessionKey}`,
    );
    activeLogins.delete(opts.sessionKey);
    return {
      connected: false,
      message: "登录超时:二维码多次过期,请重新开始登录流程。",
    };
  }

  process.stdout.write(`\n⏳ 二维码已过期,正在刷新...(${qrRefreshCount}/${MAX_QR_REFRESH_COUNT})\n`);
  
  try {
    const botType = opts.botType || DEFAULT_ILINK_BOT_TYPE;
    const qrResponse = await fetchQRCode(opts.apiBaseUrl, botType);
    activeLogin.qrcode = qrResponse.qrcode;
    activeLogin.qrcodeUrl = qrResponse.qrcode_img_content;
    activeLogin.startedAt = Date.now();
    scannedPrinted = false;
    logger.info(`waitForWeixinLogin: new QR code obtained`);
    process.stdout.write(`🔄 新二维码已生成,请重新扫描\n\n`);
  } catch (refreshErr) {
    logger.error(`waitForWeixinLogin: failed to refresh QR code: ${String(refreshErr)}`);
    activeLogins.delete(opts.sessionKey);
    return {
      connected: false,
      message: `刷新二维码失败: ${String(refreshErr)}`,
    };
  }
  break;
}

这种自动刷新机制大大提升了用户体验,用户无需在二维码过期后手动重新开始整个登录流程。

2.4 登录完成与凭证保存

当用户确认登录后,服务器返回 confirmed 状态和 bot_token。插件会将这些凭证持久化存储:

case "confirmed": {
  if (!statusResponse.ilink_bot_id) {
    activeLogins.delete(opts.sessionKey);
    logger.error("Login confirmed but ilink_bot_id missing from response");
    return {
      connected: false,
      message: "登录失败:服务器未返回 ilink_bot_id。",
    };
  }

  activeLogin.botToken = statusResponse.bot_token;
  activeLogins.delete(opts.sessionKey);

  logger.info(
    `✅ Login confirmed! ilink_bot_id=${statusResponse.ilink_bot_id}`,
  );

  return {
    connected: true,
    botToken: statusResponse.bot_token,
    accountId: statusResponse.ilink_bot_id,
    baseUrl: statusResponse.baseurl,
    userId: statusResponse.ilink_user_id,
    message: "✅ 与微信连接成功!",
  };
}

注意这里在登录成功后立即清理了内存中的登录会话,这是为了防止凭证在内存中长时间驻留带来的安全风险。

三、账户管理系统

3.1 账户索引与数据存储

OpenClaw WeChat 插件支持多账号管理,每个账号拥有独立的凭证文件。账户系统采用双层存储结构:

账户索引(accounts.json):记录所有已登录的账号 ID 列表

function resolveAccountIndexPath(): string {
  return path.join(resolveWeixinStateDir(), "accounts.json");
}

export function listIndexedWeixinAccountIds(): string[] {
  const filePath = resolveAccountIndexPath();
  try {
    if (!fs.existsSync(filePath)) return [];
    const raw = fs.readFileSync(filePath, "utf-8");
    const parsed = JSON.parse(raw);
    if (!Array.isArray(parsed)) return [];
    return parsed.filter((id): id is string => typeof id === "string" && id.trim() !== "");
  } catch {
    return [];
  }
}

账户数据文件({accountId}.json):存储每个账号的详细凭证信息

export type WeixinAccountData = {
  token?: string;
  savedAt?: string;
  baseUrl?: string;
  userId?: string;
};

function resolveAccountPath(accountId: string): string {
  return path.join(resolveAccountsDir(), `${accountId}.json`);
}

这种分离设计使得账号列表的读取非常轻量,而详细的凭证数据只在需要时才加载。

3.2 账户数据的安全存储

凭证保存时,插件采取了多项安全措施:

export function saveWeixinAccount(
  accountId: string,
  update: { token?: string; baseUrl?: string; userId?: string },
): void {
  const dir = resolveAccountsDir();
  fs.mkdirSync(dir, { recursive: true });

  const existing = loadWeixinAccount(accountId) ?? {};

  const token = update.token?.trim() || existing.token;
  const baseUrl = update.baseUrl?.trim() || existing.baseUrl;
  const userId =
    update.userId !== undefined
      ? update.userId.trim() || undefined
      : existing.userId?.trim() || undefined;

  const data: WeixinAccountData = {
    ...(token ? { token, savedAt: new Date().toISOString() } : {}),
    ...(baseUrl ? { baseUrl } : {}),
    ...(userId ? { userId } : {}),
  };

  const filePath = resolveAccountPath(accountId);
  fs.writeFileSync(filePath, JSON.stringify(data, null, 2), "utf-8");
  try {
    fs.chmodSync(filePath, 0o600);
  } catch {
    // best-effort
  }
}

关键安全特性包括:

  1. 文件权限控制:使用 chmod 0o600 确保只有文件所有者可以读写
  2. 数据合并策略:新数据与现有数据合并,避免意外覆盖
  3. 时间戳记录:记录凭证保存时间,便于审计和过期检查

3.3 向后兼容性设计

插件在演进过程中经历了从单账号到多账号的架构变更。为了保证现有用户的平滑升级,实现了多层兼容性回退:

export function loadWeixinAccount(accountId: string): WeixinAccountData | null {
  // Primary: try given accountId (normalized IDs written after this change).
  const primary = readAccountFile(resolveAccountPath(accountId));
  if (primary) return primary;

  // Compatibility: if the given ID is normalized, derive the old raw filename
  // (e.g. "b0f5860fdecb-im-bot" → "b0f5860fdecb@im.bot") for existing installs.
  const rawId = deriveRawAccountId(accountId);
  if (rawId) {
    const compat = readAccountFile(resolveAccountPath(rawId));
    if (compat) return compat;
  }

  // Legacy fallback: read token from old single-account credentials file.
  const token = loadLegacyToken();
  if (token) return { token };

  return null;
}

兼容性层级包括:

  1. 主路径:使用规范化后的账号 ID 读取
  2. 兼容路径:将规范化 ID 还原为原始格式读取(处理 @ 符号被替换为 - 的情况)
  3. 遗留路径:从旧的单账号凭证文件读取

这种设计确保了任何历史版本的凭证数据都能被正确加载。

3.4 账号配置解析

账号解析时,插件会合并配置文件和存储的凭证数据:

export type ResolvedWeixinAccount = {
  accountId: string;
  baseUrl: string;
  cdnBaseUrl: string;
  token?: string;
  enabled: boolean;
  configured: boolean;
  name?: string;
};

export function resolveWeixinAccount(
  cfg: OpenClawConfig,
  accountId?: string | null,
): ResolvedWeixinAccount {
  const raw = accountId?.trim();
  if (!raw) {
    throw new Error("weixin: accountId is required (no default account)");
  }
  const id = normalizeAccountId(raw);
  const section = cfg.channels?.["openclaw-weixin"] as WeixinSectionConfig | undefined;
  const accountCfg: WeixinAccountConfig = section?.accounts?.[id] ?? section ?? {};

  const accountData = loadWeixinAccount(id);
  const token = accountData?.token?.trim() || undefined;
  const stateBaseUrl = accountData?.baseUrl?.trim() || "";

  return {
    accountId: id,
    baseUrl: stateBaseUrl || DEFAULT_BASE_URL,
    cdnBaseUrl: accountCfg.cdnBaseUrl?.trim() || CDN_BASE_URL,
    token,
    enabled: accountCfg.enabled !== false,
    configured: Boolean(token),
    name: accountCfg.name?.trim() || undefined,
  };
}

解析逻辑遵循以下优先级:

  1. baseUrl:存储的 baseUrl → 配置中的 baseUrl → 默认值
  2. cdnBaseUrl:配置中的 cdnBaseUrl → 默认值
  3. enabled:默认为 true,除非显式设置为 false
  4. configured:基于是否存在有效 token 判断

四、用户配对机制

4.1 AllowFrom 文件系统

OpenClaw 框架采用"配对"模式管理用户授权——只有经过授权的用户才能与 Bot 交互。插件通过 pairing.ts 模块与框架的授权系统对接:

export function resolveFrameworkAllowFromPath(accountId: string): string {
  const base = safeKey("openclaw-weixin");
  const safeAccount = safeKey(accountId);
  return path.join(resolveCredentialsDir(), `${base}-${safeAccount}-allowFrom.json`);
}

文件路径遵循框架规范:{channel}-{accountId}-allowFrom.json

4.2 用户注册与文件锁

用户注册时需要写入 allowFrom 文件,为了防止并发冲突,插件使用了文件锁机制:

const LOCK_OPTIONS = {
  retries: { retries: 3, factor: 2, minTimeout: 100, maxTimeout: 2000 },
  stale: 10_000,
};

export async function registerUserInFrameworkStore(params: {
  accountId: string;
  userId: string;
}): Promise<{ changed: boolean }> {
  const { accountId, userId } = params;
  const trimmedUserId = userId.trim();
  if (!trimmedUserId) return { changed: false };

  const filePath = resolveFrameworkAllowFromPath(accountId);
  const dir = path.dirname(filePath);
  fs.mkdirSync(dir, { recursive: true });

  if (!fs.existsSync(filePath)) {
    const initial: AllowFromFileContent = { version: 1, allowFrom: [] };
    fs.writeFileSync(filePath, JSON.stringify(initial, null, 2), "utf-8");
  }

  return await withFileLock(filePath, LOCK_OPTIONS, async () => {
    let content: AllowFromFileContent = { version: 1, allowFrom: [] };
    try {
      const raw = fs.readFileSync(filePath, "utf-8");
      const parsed = JSON.parse(raw) as AllowFromFileContent;
      if (Array.isArray(parsed.allowFrom)) {
        content = parsed;
      }
    } catch {
      // If read/parse fails, start fresh
    }

    if (content.allowFrom.includes(trimmedUserId)) {
      return { changed: false };
    }

    content.allowFrom.push(trimmedUserId);
    fs.writeFileSync(filePath, JSON.stringify(content, null, 2), "utf-8");
    logger.info(
      `registerUserInFrameworkStore: added userId=${trimmedUserId} accountId=${accountId}`,
    );
    return { changed: true };
  });
}

文件锁配置采用了指数退避策略:

  • 最多重试 3 次
  • 退避因子为 2
  • 最小等待 100ms,最大 2000ms
  • 锁文件 10 秒后视为过期

这种设计在高并发场景下能有效避免文件写入冲突,同时防止死锁。

五、会话保护与限流机制

5.1 Session Guard 设计

微信服务器在检测到异常行为时会返回特定的错误码(-14 表示会话过期),如果插件继续频繁请求可能导致账号被限制。为此,插件实现了 Session Guard 机制:

const SESSION_PAUSE_DURATION_MS = 60 * 60 * 1000;
export const SESSION_EXPIRED_ERRCODE = -14;

const pauseUntilMap = new Map<string, number>();

export function pauseSession(accountId: string): void {
  const until = Date.now() + SESSION_PAUSE_DURATION_MS;
  pauseUntilMap.set(accountId, until);
  logger.info(
    `session-guard: paused accountId=${accountId} until=${new Date(until).toISOString()}`,
  );
}

当检测到会话过期错误时,插件会自动暂停该账号的所有 API 调用 1 小时。

5.2 暂停状态检查

每次 API 调用前都会检查会话状态:

export function assertSessionActive(accountId: string): void {
  if (isSessionPaused(accountId)) {
    const remainingMin = Math.ceil(getRemainingPauseMs(accountId) / 60_000);
    throw new Error(
      `session paused for accountId=${accountId}, ${remainingMin} min remaining (errcode ${SESSION_EXPIRED_ERRCODE})`,
    );
  }
}

这个检查在消息发送流程中被调用:

async function sendWeixinOutbound(params: {
  cfg: OpenClawConfig;
  to: string;
  text: string;
  accountId?: string | null;
  contextToken?: string;
  mediaUrl?: string;
}): Promise<{ channel: string; messageId: string }> {
  const account = resolveWeixinAccount(params.cfg, params.accountId);
  const aLog = logger.withAccount(account.accountId);
  assertSessionActive(account.accountId);
  if (!account.configured) {
    aLog.error(`sendWeixinOutbound: account not configured`);
    throw new Error("weixin not configured: please run `openclaw channels login --channel openclaw-weixin`");
  }
  // ... 发送逻辑
}

5.3 自动恢复机制

暂停状态是自动过期的,无需手动干预:

export function isSessionPaused(accountId: string): boolean {
  const until = pauseUntilMap.get(accountId);
  if (until === undefined) return false;
  if (Date.now() >= until) {
    pauseUntilMap.delete(accountId);
    return false;
  }
  return true;
}

当暂停时间到期后,系统会自动清理该账号的暂停状态,恢复正常服务。

六、运行时上下文管理

6.1 全局运行时对象

插件通过全局变量管理运行时上下文:

let pluginRuntime: PluginRuntime | null = null;

export function setWeixinRuntime(next: PluginRuntime): void {
  pluginRuntime = next;
  logger.info(`[runtime] setWeixinRuntime called, runtime set successfully`);
}

export function getWeixinRuntime(): PluginRuntime {
  if (!pluginRuntime) {
    throw new Error("Weixin runtime not initialized");
  }
  return pluginRuntime;
}

6.2 异步等待机制

考虑到运行时可能在某些场景下尚未初始化,插件提供了异步等待机制:

const WAIT_INTERVAL_MS = 100;
const DEFAULT_TIMEOUT_MS = 10_000;

export async function waitForWeixinRuntime(
  timeoutMs = DEFAULT_TIMEOUT_MS,
): Promise<PluginRuntime> {
  const start = Date.now();
  while (!pluginRuntime) {
    if (Date.now() - start > timeoutMs) {
      throw new Error("Weixin runtime initialization timeout");
    }
    await new Promise((resolve) => setTimeout(resolve, WAIT_INTERVAL_MS));
  }
  return pluginRuntime;
}

6.3 多渠道运行时解析

在 Gateway 模式下,运行时可能通过上下文注入,插件优先使用上下文中的运行时:

export async function resolveWeixinChannelRuntime(params: {
  channelRuntime?: PluginChannelRuntime;
  waitTimeoutMs?: number;
}): Promise<PluginChannelRuntime> {
  if (params.channelRuntime) {
    logger.debug("[runtime] channelRuntime from gateway context");
    return params.channelRuntime;
  }
  if (pluginRuntime) {
    logger.debug("[runtime] channelRuntime from register() global");
    return pluginRuntime.channel;
  }
  logger.warn(
    "[runtime] no channelRuntime on ctx and no global runtime yet; waiting for register()",
  );
  const pr = await waitForWeixinRuntime(params.waitTimeoutMs ?? DEFAULT_TIMEOUT_MS);
  return pr.channel;
}

这种多层回退机制确保了插件在不同部署模式下都能正确获取运行时上下文。

七、Context Token 管理

7.1 上下文令牌的作用

Context Token 是微信 API 的重要安全机制,每个入站消息都会附带一个唯一的 context_token,出站回复时必须携带相同的 token 才能被服务器接受。

7.2 内存缓存实现

插件使用内存 Map 缓存 context token:

const contextTokenStore = new Map<string, string>();

function contextTokenKey(accountId: string, userId: string): string {
  return `${accountId}:${userId}`;
}

export function setContextToken(accountId: string, userId: string, token: string): void {
  const k = contextTokenKey(accountId, userId);
  logger.debug(`setContextToken: key=${k}`);
  contextTokenStore.set(k, token);
}

export function getContextToken(accountId: string, userId: string): string | undefined {
  const k = contextTokenKey(accountId, userId);
  const val = contextTokenStore.get(k);
  logger.debug(
    `getContextToken: key=${k} found=${val !== undefined} storeSize=${contextTokenStore.size}`,
  );
  return val;
}

7.3 设计考量

Context Token 不持久化存储,仅保存在内存中。这是因为:

  1. Token 具有时效性,通常只对一个会话有效
  2. 每次新的入站消息都会更新 token
  3. 持久化会增加复杂性且没有实际收益

这种设计简化了实现,同时满足了功能需求。

八、登录流程集成

8.1 CLI 模式登录

在 CLI 模式下,登录流程通过 auth.login 钩子实现:

auth: {
  login: async ({ cfg, accountId, verbose, runtime }) => {
    const account = resolveWeixinAccount(cfg, accountId);

    const log = (msg: string) => {
      runtime?.log?.(msg);
    };

    log(`正在启动微信扫码登录...`);
    const startResult: WeixinQrStartResult = await startWeixinLoginWithQr({
      accountId: account.accountId,
      apiBaseUrl: account.baseUrl,
      botType: DEFAULT_ILINK_BOT_TYPE,
      verbose: Boolean(verbose),
    });

    if (!startResult.qrcodeUrl) {
      log(startResult.message);
      throw new Error(startResult.message);
    }

    log(`\n使用微信扫描以下二维码,以完成连接:\n`);
    try {
      const qrcodeterminal = await import("qrcode-terminal");
      await new Promise<void>((resolve) => {
        qrcodeterminal.default.generate(startResult.qrcodeUrl!, { small: true }, (qr: string) => {
          console.log(qr);
          resolve();
        });
      });
    } catch (err) {
      log(`二维码链接: ${startResult.qrcodeUrl}`);
    }

    const waitResult: WeixinQrWaitResult = await waitForWeixinLogin({
      sessionKey: startResult.sessionKey,
      apiBaseUrl: account.baseUrl,
      timeoutMs: 480_000,
      verbose: Boolean(verbose),
      botType: DEFAULT_ILINK_BOT_TYPE,
    });

    if (waitResult.connected && waitResult.botToken && waitResult.accountId) {
      const normalizedId = normalizeAccountId(waitResult.accountId);
      saveWeixinAccount(normalizedId, {
        token: waitResult.botToken,
        baseUrl: waitResult.baseUrl,
        userId: waitResult.userId,
      });
      registerWeixinAccountId(normalizedId);
      log(`\n✅ 与微信连接成功!`);
    } else {
      throw new Error(waitResult.message);
    }
  },
}

8.2 Gateway 模式登录

Gateway 模式支持通过 HTTP API 进行登录,分为两个步骤:

步骤 1:获取二维码

gateway: {
  loginWithQrStart: async ({ accountId, force, timeoutMs, verbose }) => {
    const savedBaseUrl = accountId ? loadWeixinAccount(accountId)?.baseUrl?.trim() : "";
    const result: WeixinQrStartResult = await startWeixinLoginWithQr({
      accountId: accountId ?? undefined,
      apiBaseUrl: savedBaseUrl || DEFAULT_BASE_URL,
      botType: DEFAULT_ILINK_BOT_TYPE,
      force,
      timeoutMs,
      verbose,
    });
    return {
      qrDataUrl: result.qrcodeUrl,
      message: result.message,
      sessionKey: result.sessionKey,
    };
  },
}

步骤 2:等待登录结果

loginWithQrWait: async (params) => {
  const sessionKey = (params as { sessionKey?: string }).sessionKey || params.accountId || "";
  const savedBaseUrl = params.accountId
    ? loadWeixinAccount(params.accountId)?.baseUrl?.trim()
    : "";
  const result: WeixinQrWaitResult = await waitForWeixinLogin({
    sessionKey,
    apiBaseUrl: savedBaseUrl || DEFAULT_BASE_URL,
    timeoutMs: params.timeoutMs,
  });

  if (result.connected && result.botToken && result.accountId) {
    const normalizedId = normalizeAccountId(result.accountId);
    saveWeixinAccount(normalizedId, {
      token: result.botToken,
      baseUrl: result.baseUrl,
      userId: result.userId,
    });
    registerWeixinAccountId(normalizedId);
  }

  return {
    connected: result.connected,
    message: result.message,
    accountId: result.accountId,
  };
}

Gateway 模式的分步设计允许前端应用实现更好的用户体验,比如在二维码展示页面实时轮询登录状态。

九、总结

OpenClaw WeChat 插件的认证与会话管理系统展现了成熟的工程实践:

  1. 分层架构:清晰的模块划分使得代码易于理解和维护
  2. 状态管理:完整的状态机设计确保登录流程可靠执行
  3. 安全存储:文件权限控制和敏感信息脱敏保护用户凭证
  4. 兼容演进:多层回退机制保证平滑升级
  5. 容错设计:自动刷新、会话保护等机制提升系统稳定性
  6. 多模式支持:CLI 和 Gateway 两种模式满足不同部署需求

这些设计不仅适用于微信插件,也为其他即时通讯渠道的集成提供了有价值的参考模式。在下一篇文章中,我们将深入探讨消息处理系统的架构与实现。

OpenCode 深度解析:架构设计、工具链集成与工程化实践

"只用大家看得懂的内容来诠释技术!"

  • 目标读者:高级/资深前端工程师
  • 技术深度:★★★★☆

目录

  1. 架构哲学:从 REPL 到 Agent 的演进
  2. 核心引擎:LLM 编排与上下文管理
  3. 工具链深度解析:超越 API 调用的工程化设计
  4. 前端工程化实战:与现有工具链的融合
  5. 性能优化与极限场景
  6. 安全模型与威胁防护
  7. 扩展性设计:自定义工具与 Skill 系统
  8. 最佳实践与反模式

一、架构哲学:从 REPL 到 Agent 的演进

1.1 REPL 的局限性

传统的前端开发工具(Node.js REPL、Chrome DevTools Console)遵循命令-响应模型:

// REPL 模式:单次交互,无状态
> const sum = (a, b) => a + b
undefined
> sum(1, 2)
3
// 上下文丢失,每次从零开始

这种模式的问题在于:

  • 无状态:无法记住之前的操作和项目上下文
  • 无工具:只能执行 JavaScript,无法操作文件系统、运行构建命令
  • 无规划:需要用户自行拆解复杂任务

1.2 Agent 架构的核心突破

OpenCode 实现了 ReAct(Reasoning + Acting)模式,将 LLM 从"文本生成器"升级为"自主代理":

用户输入
    │
    ▼
┌────────────────────────────────────┐
│  Thought(推理)                    │
│  "用户要添加登录功能,我需要:"       │
│  1. 检查现有路由配置                 │
│  2. 创建登录组件                     │
│  3. 集成状态管理                     │
└─────────────┬───────────────────────┘
              │
              ▼
┌─────────────────────────────────────┐
│  Action(行动)                      │
│  Tool: Glob("**/routes.{ts,tsx}")   │
└─────────────┬───────────────────────┘
              │
              ▼
┌────────────────────────────────────┐
│  Observation(观察)                │
│  找到 src/routes/index.tsx          │
│  使用 React Router v6               │
└─────────────┬───────────────────────┘
              │
              ▼
        循环直到完成

关键洞察:这不是简单的 API 调用链,而是基于环境反馈的自主决策循环

1.3 与 LangChain/LlamaIndex 的对比

维度 LangChain LlamaIndex OpenCode
定位 通用 LLM 应用框架 数据检索增强 代码工程专用 Agent
上下文管理 手动维护 向量数据库 结构化工作目录 + 会话历史
工具集成 通用工具集 文档检索工具 代码专用工具(AST 操作、Git、构建)
前端工程 需自行集成 不适用 原生支持 Vite/Webpack/TypeScript
粒度控制 粗粒度 Chain 粗粒度 Pipeline 细粒度工具编排

设计选择分析

OpenCode 放弃了通用性,换取了代码领域的深度优化

  1. 工作目录即上下文:不需要显式的向量存储,文件系统本身就是最自然的知识库
  2. 确定性工具调用:不像 LangChain 的 Tool 需要 LLM 生成参数,OpenCode 的工具是类型安全的函数签名
  3. 副作用追踪:每个工具调用都记录操作日志,支持撤销和审计

1.4 状态机模型

OpenCode 的内部状态可以用有限状态机描述:

// 伪代码表示核心状态机
type State = 
  | 'IDLE'           // 等待用户输入
  | 'PLANNING'       // LLM 正在制定执行计划
  | 'EXECUTING'      // 正在执行工具调用
  | 'WAITING_USER'   // 需要用户确认(Question 工具)
  | 'ERROR'          // 执行出错
  | 'COMPLETED';     // 任务完成

type Event =
  | { type: 'USER_INPUT'; payload: string }
  | { type: 'LLM_RESPONSE'; payload: ToolCall[] }
  | { type: 'TOOL_COMPLETED'; payload: ToolResult }
  | { type: 'USER_CONFIRMED'; payload: Answer }
  | { type: 'ERROR_OCCURRED'; payload: Error };

// 状态转换
const transitions: Record<State, Partial<Record<Event['type'], State>>> = {
  IDLE: {
    USER_INPUT: 'PLANNING'
  },
  PLANNING: {
    LLM_RESPONSE: 'EXECUTING',
    ERROR_OCCURRED: 'ERROR'
  },
  EXECUTING: {
    TOOL_COMPLETED: 'PLANNING',  // 继续下一步
    USER_INPUT: 'WAITING_USER',  // 需要确认
    ERROR_OCCURRED: 'ERROR'
  },
  WAITING_USER: {
    USER_CONFIRMED: 'PLANNING'
  },
  ERROR: {
    USER_INPUT: 'PLANNING'  // 重试
  },
  COMPLETED: {
    USER_INPUT: 'PLANNING'
  }
};

工程意义:明确的状态边界使得错误恢复、超时处理、并发控制变得可预测。


二、核心引擎:LLM 编排与上下文管理

2.1 Token 预算的分配策略

Kimi-K2.5 的 128K 上下文窗口不是无限资源。OpenCode 实现了智能预算分配

interface ContextBudget {
  systemPrompt: number;        // 2K - 固定开销
  toolDefinitions: number;     // 3K - 11 个工具的 Schema
  conversationHistory: number; // 40K - 滚动窗口
  fileContents: number;        // 60K - 动态加载
  responseReserve: number;     // 23K - LLM 回复预留
}

// 动态调整策略
class ContextManager {
  private readonly MAX_TOKENS = 128000;
  private readonly SAFETY_MARGIN = 8000;
  
  calculateFileBudget(currentUsage: number): number {
    const available = this.MAX_TOKENS - currentUsage - this.SAFETY_MARGIN;
    
    // 策略 1:如果对话很长,压缩历史
    if (this.conversationHistory.length > 10) {
      return this.compressHistory(available);
    }
    
    // 策略 2:优先保留最近的文件内容
    return available * 0.7;
  }
  
  private compressHistory(availableTokens: number): number {
    // 保留最近 3 轮对话的完整内容
    // 更早的对话只保留摘要
    const recent = this.getRecentRounds(3);
    const summary = this.summarizeOlderRounds();
    
    this.conversationHistory = [...summary, ...recent];
    
    return this.calculateFileBudget(this.getCurrentUsage());
  }
}

关键优化点

  1. 惰性加载:只有在工具调用需要时才读取文件,而非一次性加载整个项目
  2. 内容摘要:对于大文件,先读取开头(了解结构)+ Grep 搜索(定位关键行)+ 局部精读
  3. LRU 缓存:最近访问的文件内容保留在上下文中,避免重复读取

2.2 工具选择的决策树

OpenCode 不是让 LLM "猜" 要用什么工具,而是通过结构化的决策流程

用户请求分析
    │
    ├─► 包含文件路径?
    │   ├─► 是 → 文件是否存在?
    │   │       ├─► 存在 → Read/Edit
    │   │       └─► 不存在 → Write
    │   └─► 否 → 继续
    │
    ├─► 需要搜索代码?
    │   ├─► 知道文件名 → Glob
    │   └─► 知道内容 → Grep
    │
    ├─► 需要执行命令?
    │   └─► Bash(Git、NPM、构建等)
    │
    ├─► 需要网络资源?
    │   └─► WebFetch
    │
    ├─► 任务可并行?
    │   └─► Task(子代理)
    │
    └─► 需要用户确认?
        └─► Question

为什么不用纯粹的 LLM 决策?

  • 成本:每次让 LLM 选择工具都要消耗 token
  • 延迟:需要等待 LLM 响应才能执行
  • 确定性:规则引擎的结果可预测、可测试

混合策略:规则引擎处理常见情况(80%),LLM 处理边界情况(20%)。

2.3 错误恢复与重试机制

interface RetryPolicy {
  maxAttempts: number;
  backoffStrategy: 'fixed' | 'exponential' | 'linear';
  retryableErrors: string[];
  fallbackAction?: ToolCall;
}

class ExecutionEngine {
  async executeWithRetry(toolCall: ToolCall, policy: RetryPolicy): Promise<Result> {
    for (let attempt = 1; attempt <= policy.maxAttempts; attempt++) {
      try {
        const result = await this.execute(toolCall);
        
        if (result.success) {
          return result;
        }
        
        // 分析错误类型
        if (!this.isRetryable(result.error, policy.retryableErrors)) {
          throw new NonRetryableError(result.error);
        }
        
        // 计算退避时间
        const delay = this.calculateBackoff(attempt, policy.backoffStrategy);
        await this.sleep(delay);
        
        // 尝试修复
        toolCall = await this.attemptRecovery(toolCall, result.error);
        
      } catch (error) {
        if (attempt === policy.maxAttempts && policy.fallbackAction) {
          return this.execute(policy.fallbackAction);
        }
        throw error;
      }
    }
  }
  
  private async attemptRecovery(toolCall: ToolCall, error: Error): Promise<ToolCall> {
    // 常见错误自动修复
    if (error.message.includes('ENOENT')) {
      // 文件不存在,改为创建
      return {
        ...toolCall,
        tool: 'Write',
        params: { ...toolCall.params, createIfNotExists: true }
      };
    }
    
    if (error.message.includes('EACCES')) {
      // 权限不足,提示用户
      await this.askUser(`需要提升权限来 ${toolCall.tool},是否继续?`);
    }
    
    return toolCall;
  }
}

三、工具链深度解析:超越 API 调用的工程化设计

3.1 文件操作工具的 ACID 特性

OpenCode 的文件操作实现了类似数据库的 ACID 保证:

// 事务性文件操作
interface FileTransaction {
  id: string;
  operations: FileOperation[];
  rollbackLog: RollbackAction[];
  commit(): Promise<void>;
  rollback(): Promise<void>;
}

class FileOperator {
  async edit(params: EditParams): Promise<void> {
    const tx = await this.beginTransaction();
    
    try {
      // 1. 读取原文件(用于回滚)
      const original = await this.read(params.filePath);
      tx.recordRollback('Write', { filePath: params.filePath, content: original });
      
      // 2. 执行编辑
      const newContent = this.applyEdit(original, params.oldString, params.newString);
      
      // 3. 写入临时文件
      const tempPath = `${params.filePath}.tmp.${Date.now()}`;
      await this.write(tempPath, newContent);
      
      // 4. 原子性替换
      await this.atomicReplace(tempPath, params.filePath);
      
      // 5. 提交事务
      await tx.commit();
      
    } catch (error) {
      // 6. 出错回滚
      await tx.rollback();
      throw error;
    }
  }
  
  private async atomicReplace(tempPath: string, targetPath: string): Promise<void> {
    // Unix: rename 是原子操作
    // Windows: 使用 MoveFileEx with MOVEFILE_REPLACE_EXISTING
    await fs.rename(tempPath, targetPath);
  }
}

工程价值

  • 即使进程崩溃,文件也不会处于半写状态
  • 支持撤销(Undo)操作
  • 并发编辑时不会丢失数据

3.2 Grep 的并行搜索策略

对于大型项目(10万+ 文件),线性搜索不可接受:

class ParallelGrep {
  private readonly WORKER_COUNT = 4;
  
  async search(pattern: string, path: string): Promise<Match[]> {
    // 1. 快速过滤:只搜索文本文件
    const files = await this.getSearchableFiles(path);
    
    // 2. 分片:按文件大小均匀分配
    const chunks = this.distributeFiles(files, this.WORKER_COUNT);
    
    // 3. 并行搜索
    const results = await Promise.all(
      chunks.map(chunk => this.searchChunk(pattern, chunk))
    );
    
    // 4. 合并与排序(按相关性)
    return this.mergeAndRank(results.flat());
  }
  
  private distributeFiles(files: FileInfo[], workerCount: number): FileInfo[][] {
    // 按文件大小排序,使用轮询分配确保负载均衡
    const sorted = files.sort((a, b) => b.size - a.size);
    const chunks: FileInfo[][] = Array.from({ length: workerCount }, () => []);
    
    sorted.forEach((file, index) => {
      chunks[index % workerCount].push(file);
    });
    
    return chunks;
  }
  
  private async searchChunk(pattern: string, files: FileInfo[]): Promise<Match[]> {
    // 使用 ripgrep(如果可用)或 Node.js 流式读取
    if (this.hasRipgrep()) {
      return this.searchWithRipgrep(pattern, files);
    }
    
    // 回退到原生实现
    return this.searchWithNode(pattern, files);
  }
}

性能对比

项目规模 线性搜索 并行搜索(4 workers) ripgrep
1000 文件 200ms 80ms 20ms
10000 文件 2s 600ms 150ms
100000 文件 20s 5s 1.2s

3.3 Bash 的沙箱与隔离

执行用户命令是最大的安全风险点:

interface SandboxConfig {
  allowedCommands: string[];      // 白名单:git, npm, node, yarn, pnpm
  blockedPatterns: RegExp[];      // 黑名单:rm -rf /, > /etc/passwd
  workingDirectory: string;       // 只能在这个目录下操作
  timeout: number;                // 最大执行时间
  maxOutputSize: number;          // 防止内存溢出
  env: Record<string, string>;    // 受限的环境变量
}

class SandboxedBash {
  async execute(command: string, config: SandboxConfig): Promise<ExecutionResult> {
    // 1. 命令解析与验证
    const parsed = this.parseCommand(command);
    
    if (!this.isAllowed(parsed, config)) {
      throw new SecurityError(`Command not allowed: ${command}`);
    }
    
    // 2. 路径规范化与检查
    const cwd = path.resolve(config.workingDirectory);
    if (!this.isWithinWorkingDir(cwd, config.workingDirectory)) {
      throw new SecurityError('Attempted directory traversal');
    }
    
    // 3. 使用受限 shell 执行
    const child = spawn('bash', ['-c', command], {
      cwd,
      env: this.sanitizeEnv(config.env),
      timeout: config.timeout,
      maxBuffer: config.maxOutputSize
    });
    
    // 4. 实时监控
    return new Promise((resolve, reject) => {
      const timeout = setTimeout(() => {
        child.kill('SIGTERM');
        reject(new TimeoutError(`Command timed out after ${config.timeout}ms`));
      }, config.timeout);
      
      child.on('close', (code) => {
        clearTimeout(timeout);
        resolve({ code, stdout, stderr });
      });
    });
  }
  
  private isAllowed(parsed: ParsedCommand, config: SandboxConfig): boolean {
    // 检查是否在白名单
    if (!config.allowedCommands.includes(parsed.command)) {
      return false;
    }
    
    // 检查是否匹配黑名单模式
    if (config.blockedPatterns.some(p => p.test(parsed.raw))) {
      return false;
    }
    
    return true;
  }
}

四、前端工程化实战:与现有工具链的融合

4.1 与 Vite 的深度集成

// OpenCode 理解 Vite 配置并据此决策
interface ViteProjectContext {
  config: ViteConfig;
  plugins: Plugin[];
  aliases: Record<string, string>;  // @/ -> ./src
  env: Record<string, string>;      // import.meta.env
}

class ViteIntegration {
  async analyzeProject(root: string): Promise<ViteProjectContext> {
    // 1. 读取 vite.config.ts
    const configPath = await this.findConfig(root);
    const configContent = await read(configPath);
    
    // 2. 解析配置(不执行,静态分析)
    const config = this.parseConfig(configContent);
    
    // 3. 提取关键信息
    return {
      config,
      plugins: this.extractPlugins(config),
      aliases: this.resolveAliases(config),
      env: await this.loadEnv(root, config.mode)
    };
  }
  
  // 根据 Vite 配置生成导入语句
  generateImport(source: string, ctx: ViteProjectContext): string {
    // 检查是否是路径别名
    for (const [alias, replacement] of Object.entries(ctx.aliases)) {
      if (source.startsWith(alias)) {
        return `import X from '${source}';`;
      }
    }
    
    // 检查是否是 npm 包
    if (this.isNpmPackage(source)) {
      return `import X from '${source}';`;
    }
    
    // 相对路径
    return `import X from './${source}';`;
  }
}

实际应用场景

当用户说"创建一个新的 API 客户端",OpenCode 会:

  1. 读取 vite.config.ts 发现使用了 @/ 别名指向 src/
  2. src/api/client.ts 创建文件(而非 ./api/client.ts
  3. 使用项目已有的 HTTP 客户端(axios/fetch/ky)
  4. 遵循现有的错误处理模式

4.2 TypeScript 类型系统的利用

OpenCode 不仅生成 TypeScript 代码,还利用类型信息进行决策

class TypeScriptAnalyzer {
  // 分析类型定义来理解数据结构
  async analyzeInterface(filePath: string, interfaceName: string): Promise<TypeInfo> {
    const content = await read(filePath);
    
    // 使用 TypeScript Compiler API
    const sourceFile = ts.createSourceFile(
      filePath,
      content,
      ts.ScriptTarget.Latest,
      true
    );
    
    // 查找接口定义
    const interfaceDecl = this.findInterface(sourceFile, interfaceName);
    
    return {
      name: interfaceName,
      properties: interfaceDecl.members.map(m => ({
        name: m.name?.getText(),
        type: m.type?.getText(),
        optional: m.questionToken !== undefined
      })),
      extends: interfaceDecl.heritageClauses?.map(h => h.types.map(t => t.getText()))
    };
  }
  
  // 根据类型生成 Zod Schema(运行时验证)
  generateZodSchema(typeInfo: TypeInfo): string {
    const fields = typeInfo.properties.map(prop => {
      let schema = `z.${this.mapTypeToZod(prop.type)}()`;
      
      if (prop.optional) {
        schema += '.optional()';
      }
      
      return `  ${prop.name}: ${schema}`;
    });
    
    return `const ${typeInfo.name}Schema = z.object({\n${fields.join(',\n')}\n});`;
  }
}

为什么重要

前端项目越来越多使用类型优先开发(Type-First Development)。OpenCode 能够理解类型定义,从而:

  • 生成与现有类型兼容的代码
  • 推断 API 响应结构
  • 创建运行时验证(Zod/Yup)与编译时类型保持一致

4.3 与测试框架的集成

// 自动分析测试覆盖率和生成测试用例
class TestIntegration {
  async generateTestsForFile(filePath: string): Promise<string> {
    // 1. 读取源代码
    const source = await read(filePath);
    
    // 2. 分析导出内容
    const exports = this.analyzeExports(source);
    
    // 3. 查找现有测试文件
    const testFile = await this.findTestFile(filePath);
    const existingTests = testFile ? await read(testFile) : '';
    
    // 4. 确定测试策略
    const strategy = this.determineTestStrategy(filePath, exports);
    
    // 5. 生成测试代码
    const tests = exports.map(exp => this.generateTestCase(exp, strategy));
    
    return this.formatTestFile(tests, strategy);
  }
  
  private determineTestStrategy(filePath: string, exports: Export[]): TestStrategy {
    // React 组件
    if (filePath.includes('.tsx') && exports.some(e => e.isComponent)) {
      return {
        framework: 'vitest',
        library: 'testing-library/react',
        approach: 'behavioral'  // 测试行为而非实现
      };
    }
    
    // 工具函数
    if (exports.every(e => e.isFunction)) {
      return {
        framework: 'vitest',
        approach: 'unit',
        coverage: 'branch'  // 分支覆盖
      };
    }
    
    // API 客户端
    if (filePath.includes('/api/')) {
      return {
        framework: 'vitest',
        library: 'msw',  // Mock Service Worker
        approach: 'integration'
      };
    }
  }
}

五、性能优化与极限场景

5.1 大项目的处理策略

对于超大型项目(如企业级 Monorepo):

class LargeProjectOptimizer {
  // 延迟加载:只加载必要的部分
  async lazyLoad(projectRoot: string, targetFile: string): Promise<ProjectContext> {
    // 1. 构建依赖图(增量更新)
    const dependencyGraph = await this.buildDependencyGraph(projectRoot);
    
    // 2. 找出目标文件的依赖闭包
    const closure = this.getDependencyClosure(dependencyGraph, targetFile);
    
    // 3. 只加载闭包内的文件
    const relevantFiles = closure.map(node => node.filePath);
    
    return {
      files: await this.loadFiles(relevantFiles),
      graph: dependencyGraph.subgraph(closure)
    };
  }
  
  // 增量更新:缓存未变更的文件
  private fileCache: Map<string, CacheEntry> = new Map();
  
  async readWithCache(filePath: string): Promise<string> {
    const stats = await fs.stat(filePath);
    const cached = this.fileCache.get(filePath);
    
    if (cached && cached.mtime === stats.mtime.getTime()) {
      return cached.content;
    }
    
    const content = await read(filePath);
    this.fileCache.set(filePath, {
      content,
      mtime: stats.mtime.getTime(),
      size: stats.size
    });
    
    return content;
  }
}

5.2 并发控制与资源管理

class ResourceManager {
  private semaphore: Semaphore;
  private activeTasks: Map<string, AbortController> = new Map();
  
  constructor(private maxConcurrency: number = 4) {
    this.semaphore = new Semaphore(maxConcurrency);
  }
  
  async executeTask<T>(
    taskId: string, 
    task: () => Promise<T>,
    priority: 'high' | 'normal' | 'low' = 'normal'
  ): Promise<T> {
    // 取消低优先级任务
    if (priority === 'high') {
      this.cancelLowPriorityTasks();
    }
    
    const controller = new AbortController();
    this.activeTasks.set(taskId, controller);
    
    try {
      // 获取信号量
      await this.semaphore.acquire();
      
      // 执行任务
      return await task();
      
    } finally {
      this.semaphore.release();
      this.activeTasks.delete(taskId);
    }
  }
  
  cancelTask(taskId: string): void {
    const controller = this.activeTasks.get(taskId);
    if (controller) {
      controller.abort();
      this.activeTasks.delete(taskId);
    }
  }
}

5.3 Token 优化的高级技巧

class TokenOptimizer {
  // 分层摘要:不同粒度保留不同细节
  createHierarchicalSummary(files: FileContent[]): HierarchicalSummary {
    return {
      // 第一层:项目结构(所有文件)
      structure: files.map(f => ({
        path: f.path,
        exports: f.exports.map(e => e.name),
        dependencies: f.imports.map(i => i.source)
      })),
      
      // 第二层:最近修改的文件(详细内容)
      recent: files
        .filter(f => f.lastModified > Date.now() - 24 * 60 * 60 * 1000)
        .map(f => ({
          path: f.path,
          content: f.content
        })),
      
      // 第三层:相关文件(基于依赖图)
      related: this.getRelatedFiles(files, this.currentTask)
    };
  }
  
  // 代码压缩:移除对 LLM 理解无关的内容
  compressCode(code: string): string {
    return code
      // 保留 JSDoc 注释(类型信息)
      .replace(/\/\*\*[\s\S]*?\*\//g, keep => keep)
      // 移除实现注释
      .replace(/\/\/.*$/gm, '')
      // 压缩空行
      .replace(/\n{3,}/g, '\n\n')
      // 保留 console.log 等调试用代码的位置标记
      .replace(/console\.(log|warn|error)\(.*\);?/g, '// [debug]');
  }
}

六、安全模型与威胁防护

6.1 多层防御架构

┌─────────────────────────────────────────────────────────┐
│                    安全防御层                            │
├─────────────────────────────────────────────────────────┤
│                                                         │
│  第 1 层:输入过滤                                       │
│  ├── 恶意代码模式识别(正则 + AST 分析)                  │
│  └── 敏感信息检测(密钥、密码、Token)                    │
│                                                         │
│  第 2 层:命令沙箱                                       │
│  ├── 白名单命令(git, npm, node)                        │
│  ├── 路径遍历防护                                        │
│  └── 资源限制(CPU、内存、时间)                          │
│                                                         │
│  第 3 层:代码审计                                       │
│  ├── 静态分析(eslint, semgrep)                         │
│  ├── 依赖检查(npm audit)                               │
│  └── 运行时防护(evalFunction 构造器拦截)              │
│                                                         │
│  第 4 层:操作日志                                       │
│  ├── 所有文件变更记录                                    │
│  ├── 命令执行历史                                        │
│  └── 支持完整回滚                                        │
│                                                         │
└─────────────────────────────────────────────────────────┘

6.2 恶意代码检测

class SecurityScanner {
  private dangerousPatterns: Pattern[] = [
    // 动态代码执行
    {
      name: 'eval_usage',
      pattern: /\beval\s*\(/,
      severity: 'high',
      description: 'Dynamic code execution via eval'
    },
    {
      name: 'function_constructor',
      pattern: /new\s+Function\s*\(/,
      severity: 'high',
      description: 'Dynamic code execution via Function constructor'
    },
    // 文件系统操作
    {
      name: 'fs_unrestricted',
      pattern: /fs\.(writeFile|unlink|rmdir)\s*\([^)]*\+\s*[^)]*\)/,
      severity: 'critical',
      description: 'Potential path traversal in file operations'
    },
    // 网络请求
    {
      name: 'unrestricted_fetch',
      pattern: /fetch\s*\(\s*[^'"`]/,
      severity: 'medium',
      description: 'Fetch with dynamic URL'
    },
    // 敏感 API
    {
      name: 'clipboard_access',
      pattern: /navigator\.clipboard/,
      severity: 'medium',
      description: 'Clipboard access'
    },
    {
      name: 'service_worker',
      pattern: /navigator\.serviceWorker\.register/,
      severity: 'low',
      description: 'Service Worker registration'
    }
  ];
  
  async scan(code: string, context: SecurityContext): Promise<ScanResult> {
    const findings: Finding[] = [];
    
    // 1. 正则匹配(快速过滤)
    for (const pattern of this.dangerousPatterns) {
      if (pattern.pattern.test(code)) {
        findings.push({
          rule: pattern.name,
          severity: pattern.severity,
          message: pattern.description,
          line: this.findLineNumber(code, pattern.pattern)
        });
      }
    }
    
    // 2. AST 深度分析(精确判断)
    const astFindings = await this.analyzeAST(code, context);
    findings.push(...astFindings);
    
    // 3. 依赖分析
    const deps = this.extractDependencies(code);
    const knownVulnerabilities = await this.checkVulnerabilities(deps);
    findings.push(...knownVulnerabilities);
    
    return {
      findings,
      isSafe: !findings.some(f => f.severity === 'critical'),
      riskScore: this.calculateRiskScore(findings)
    };
  }
  
  private async analyzeAST(code: string, context: SecurityContext): Promise<Finding[]> {
    const ast = parse(code, {
      ecmaVersion: 'latest',
      sourceType: 'module'
    });
    
    const findings: Finding[] = [];
    
    // 遍历 AST 查找危险模式
    walk(ast, {
      CallExpression(node) {
        // 检查是否是危险的函数调用
        if (isDangerousCall(node, context)) {
          findings.push({
            rule: 'dangerous_call',
            severity: 'high',
            message: `Dangerous function call: ${node.callee.name}`,
            line: node.loc?.start.line
          });
        }
      },
      ImportDeclaration(node) {
        // 检查是否引入危险模块
        if (isDangerousModule(node.source.value)) {
          findings.push({
            rule: 'dangerous_import',
            severity: 'high',
            message: `Suspicious module import: ${node.source.value}`,
            line: node.loc?.start.line
          });
        }
      }
    });
    
    return findings;
  }
}

七、扩展性设计:自定义工具与 Skill 系统

7.1 工具注册机制

// 自定义工具示例:AST 转换
interface CustomTool {
  name: string;
  description: string;
  parameters: JSONSchema;
  execute: (params: any, context: ToolContext) => Promise<ToolResult>;
}

const astTransformTool: CustomTool = {
  name: 'ASTTransform',
  description: 'Transform code using AST operations',
  parameters: {
    type: 'object',
    properties: {
      filePath: { type: 'string' },
      transformations: {
        type: 'array',
        items: {
          type: 'object',
          properties: {
            type: { 
              enum: ['rename', 'remove', 'add', 'replace'],
              type: 'string'
            },
            target: { type: 'string' },
            replacement: { type: 'string' }
          }
        }
      }
    },
    required: ['filePath', 'transformations']
  },
  
  async execute(params, context) {
    const { filePath, transformations } = params;
    
    // 读取并解析
    const code = await context.read(filePath);
    const ast = parse(code, { ecmaVersion: 'latest' });
    
    // 应用转换
    for (const transform of transformations) {
      switch (transform.type) {
        case 'rename':
          this.renameIdentifier(ast, transform.target, transform.replacement);
          break;
        case 'remove':
          this.removeNode(ast, transform.target);
          break;
        // ...
      }
    }
    
    // 生成代码
    const output = generate(ast);
    
    // 写入文件
    await context.write(filePath, output);
    
    return {
      success: true,
      data: { transformed: transformations.length }
    };
  }
};

// 注册工具
ToolRegistry.register(astTransformTool);

7.2 Skill 系统架构

Skill 是可复用的领域知识包:

// React Performance Optimization Skill
const reactPerformanceSkill = {
  name: 'react-performance',
  version: '1.0.0',
  
  // 知识库:常见性能问题及解决方案
  patterns: [
    {
      name: 'unnecessary_re_render',
      detect: (code: string) => {
        // 检测是否缺少 memo/useMemo
        return code.includes('const') && 
               !code.includes('useMemo') &&
               !code.includes('React.memo');
      },
      fix: (component: ComponentInfo) => {
        return `
          // 添加 React.memo 防止不必要的重渲染
          export default memo(${component.name});
          
          // 或使用 useMemo 缓存计算结果
          const computedValue = useMemo(() => {
            return expensiveComputation(props.data);
          }, [props.data]);
        `;
      }
    },
    {
      name: 'inline_function',
      detect: (code: string) => {
        // 检测内联函数导致的重渲染
        return /onClick=\{\(\).*=>/.test(code);
      },
      fix: () => {
        return `
          // 将内联函数提取到 useCallback
          const handleClick = useCallback(() => {
            // ...
          }, [deps]);
          
          <button onClick={handleClick}>Click</button>
        `;
      }
    }
  ],
  
  // 工具增强
  tools: [
    {
      name: 'analyzePerformance',
      description: 'Analyze React component performance',
      execute: async (componentPath: string) => {
        // 使用 React DevTools Profiler API
        // 分析渲染次数和耗时
      }
    }
  ],
  
  // 代码模板
  templates: {
    'optimized-component': `
      import { memo, useMemo, useCallback } from 'react';
      
      interface Props {
        /* ... */
      }
      
      const {{componentName}} = memo(function {{componentName}}(props: Props) {
        const computed = useMemo(() => {
          return /* expensive computation */;
        }, [/* deps */]);
        
        const handleEvent = useCallback(() => {
          /* handler */
        }, [/* deps */]);
        
        return (
          /* JSX */
        );
      });
      
      export default {{componentName}};
    `
  }
};

// 加载 Skill
await SkillManager.load(reactPerformanceSkill);

八、最佳实践与反模式

8.1 高效使用 Checklist

需求澄清阶段

  • 提供明确的输入/输出示例
  • 说明边界条件和错误处理要求
  • 指定技术栈和版本约束
  • 提及已有的相关代码或模式

探索阶段

  • 使用 Glob 了解项目结构
  • 读取 package.json 确认依赖
  • 搜索现有实现避免重复
  • 检查测试文件了解预期行为

实现阶段

  • 优先修改现有代码而非重写
  • 保持与项目编码风格一致
  • 添加必要的类型定义
  • 考虑错误处理和边界情况

验证阶段

  • 运行 linter 检查代码风格
  • 执行测试套件
  • 手动验证关键路径
  • 检查性能影响( bundle 大小、运行时性能)

8.2 常见反模式

反模式 1:过度抽象

// ❌ 为了使用设计模式而使用
class AbstractComponentFactory {
  createFactory(type: string) {
    return new ComponentFactory(type);
  }
}

class ComponentFactory {
  constructor(private type: string) {}
  
  create() {
    switch(this.type) {
      case 'button': return <Button />;
      case 'input': return <Input />;
    }
  }
}

// ✅ 简单直接
const components = {
  button: Button,
  input: Input
};

const Component = components[type];

反模式 2:忽视类型安全

// ❌ any 滥用
function processData(data: any) {
  return data.map(item => item.value);
}

// ✅ 明确类型
interface DataItem {
  id: string;
  value: number;
}

function processData(data: DataItem[]): number[] {
  return data.map(item => item.value);
}

反模式 3:过早优化

// ❌ 不必要的 memoization
const SimpleComponent = memo(function SimpleComponent({ text }) {
  return <span>{text}</span>;
});

// ✅ 先测量,后优化
// 只有当组件确实存在性能问题时才使用 memo

反模式 4:忽视可访问性

// ❌ 不可访问的自定义组件
<div onClick={handleClick}>Click me</div>

// ✅ 语义化 + 键盘支持
<button onClick={handleClick}>Click me</button>
// 或
<div 
  role="button" 
  tabIndex={0}
  onClick={handleClick}
  onKeyDown={(e) => e.key === 'Enter' && handleClick()}
>
  Click me
</div>

8.3 团队协作规范

代码审查 Prompt 模板

请审查这段代码,关注:
1. 类型安全:是否有 any 或类型断言?
2. 错误处理:是否处理了异步操作的错误?
3. 性能:是否有不必要的重渲染或计算?
4. 可访问性:是否遵循 ARIA 规范?
5. 测试:是否易于测试?边界情况是否覆盖?

[粘贴代码]

重构任务 Prompt 模板

请重构 src/components/LegacyComponent.tsx:

当前问题:
- [ ] 组件超过 300 行
- [ ] 使用了 class 组件
- [ ] 混合了业务逻辑和 UI

目标:
- 拆分为多个小组件
- 转换为函数组件 + Hooks
- 业务逻辑抽离到自定义 Hook
- 保持现有功能不变(所有测试通过)

技术约束:
- 使用 React 18
- 使用 TypeScript 严格模式
- 使用现有的 hooks/useAuth 处理认证

结语

OpenCode 代表了AI 原生开发工具的新范式。它不是简单的代码生成器,而是:

  1. 架构设计伙伴:帮助思考系统结构、模块划分
  2. 代码审查助手:发现潜在问题、提供改进建议
  3. 工程化加速器:自动化重复工作、强制执行最佳实践
  4. 知识库:集成领域专家经验、提供可复用的 Skill

对于高级前端工程师而言,掌握 OpenCode 意味着:

  • 从重复性编码工作中解放出来,专注架构设计
  • 借助 AI 的能力处理更大规模、更复杂的系统
  • 将团队的最佳实践固化为可复用的自动化流程

但请记住

AI 是杠杆,它会放大你的能力——无论是好的还是坏的。 优秀的工程师用 AI 写出更好的代码, 平庸的工程师用 AI 更快地写出糟糕的代码。

理解工具的原理、掌握正确的使用方法、保持批判性思维,才能真正发挥 OpenCode 的价值。


延伸阅读

❌