feishu-user-plugin 1.3.13 → 1.3.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "feishu-user-plugin",
3
- "version": "1.3.13",
3
+ "version": "1.3.14",
4
4
  "description": "All-in-one Feishu MCP server + CLI tool for Claude Code — send messages as yourself, read chats (auto-expanded merge_forward), manage docs / bitable / wiki (full CRUD) / drive / OKR (with progress writes) / calendar (read+write) / Tasks v2 / multi-profile auto-switch / real-time WS events. 85 tools + 9 prompts, 3 auth layers.",
5
5
  "author": {
6
6
  "name": "EthanQC"
@@ -2,7 +2,7 @@
2
2
  "name": "feishu-user-plugin",
3
3
  "displayName": "Feishu MCP for Claude Code & Codex",
4
4
  "description": "All-in-one Feishu MCP server + CLI tool for Claude Code / Codex / Cursor / scripts — 85 tools across 3 auth layers (cookie / app / OAuth). Send as you, read groups, manage docs / bitable / wiki / drive / calendar / tasks / OKR.",
5
- "version": "1.3.13",
5
+ "version": "1.3.14",
6
6
  "author": {
7
7
  "name": "EthanQC"
8
8
  },
@@ -2,7 +2,7 @@
2
2
  "manifest_version": "0.3",
3
3
  "name": "feishu-user-plugin",
4
4
  "display_name": "Feishu MCP for Claude Code & Codex",
5
- "version": "1.3.13",
5
+ "version": "1.3.14",
6
6
  "description": "All-in-one Feishu MCP server + CLI tool for Claude Code / Codex / Cursor / scripts — 85 tools across 3 auth layers (cookie / app / OAuth). Send as you, read groups, manage docs / bitable / wiki / drive / calendar / tasks / OKR.",
7
7
  "long_description": "feishu-user-plugin is a local stdio MCP server (and shell CLI tool) that bridges Feishu / Lark and any MCP client (Claude Code, Codex, Cursor, Windsurf, OpenClaw, Claude Desktop). It exposes 85 tools across three auth layers: cookie + protobuf for sending messages as the real user (a capability not available through the official bot API), Feishu Open Platform app credentials for groups / docs / bitable / wiki / drive / calendar / tasks / OKR, and user OAuth (UAT) for P2P chat reading and user-owned resource creation.",
8
8
  "author": {
package/CHANGELOG.md CHANGED
@@ -4,6 +4,80 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [1.3.14] - 2026-05-21
8
+
9
+ **TL;DR**:纯收紧的 bug fix / security release。无 schema 变化、无新工具、无 breaking API。升级后重启 Claude Code / Codex 自动拉 v1.3.14;如果之前还没跑过 `migrate --confirm`,**强烈建议**跑一次(canonical store 是 v1.3.7+ 推荐路径,v1.3.14 把 UAT refresh 锁也搬过来完成最后一块)。
10
+
11
+ OAuth / UAT 子系统深度优化:跨进程互斥锁路径迁移到 canonical home、cookie heartbeat 改为 ws-owner 单跑(30+ 并发 session 不再每 4h × N 倍 API call 进飞书 session-keepalive 端点)、refresh 错误处理与 identity 状态机打通(invalid_grant 显式 `err.uatRevoked` → `_classifyUatFailure` 短路成 UAT_REVOKED → `withIdentityFallback` 给 LLM 清晰的"请重跑 oauth"指引)、安全敏感日志清理(含 OAuth 回调浏览器页面 token bytes 不再显示)、Lark Desktop reactor 冷启动 debounce 修复、decodeTokenExpiry 失败 breadcrumb flood-gate、dead code 移除、TROUBLESHOOTING 四段新指引、新增 27 个 fixture-based 测试(test-uat-lifecycle 18 + test-cookie-heartbeat 9)并修复 v1.3.7 起就静默坏掉的跨进程 race 测试。85 工具 9 prompts 不变。
12
+
13
+ ### Security
14
+
15
+ - **oauth.js: redact `code` field in token-exchange log**(`src/oauth.js:166`):之前 `console.log` 把 authorization code 明文写 stdout。code 短寿命(~60s)但仍是可换 token 的有效 credential,转写 / 屏幕录制 / 终端 history 会暂存。改为 `code: '***'`。
16
+ - **oauth.js: 浏览器回调页面不再显示 access_token bytes**(`src/oauth.js:251`):之前 `<p>access_token: ${tokenData.access_token.slice(0, 20)}...</p>` 把 token 前 20 字节贴到 HTML,浏览器 history / 截图 / 屏幕录制都会留痕。改为 `<p>access_token: ✅ 已获取(${len} chars)</p>` —— 长度 attestation 足以证明流程成功,不暴露 token 任何 byte。
17
+ - **删除 `src/oauth-auto.js`**:Playwright dev-only OAuth helper,v1.3.0 起没有 production path 引用、`.npmignore` 排除发包,但仍 `console.log(raw.slice(0, 300))` 把完整 access_token + refresh_token 写 stdout,对 contributor 是误导。整文件移除,doc 引用同步更新。
18
+ - **uat.js refreshUAT 错误消息不再 dump 整个响应 JSON**(`src/auth/uat.js:160-180`):飞书部分错误路径会在响应体里 echo 回 refresh_token 字段,之前 `JSON.stringify(data)` 会把这些 bytes 抛到 Error.message → 冒泡到 MCP `content[0].text` → LLM transcript。改为只透 `data.error_description / data.msg / data.code` 结构化字段(`errCode`/`errMsg` 解构);invalid_grant 单独走 hardcoded `'UAT refresh_token rejected by Feishu (invalid_grant). The 7-day refresh chain is broken. Run: npx feishu-user-plugin oauth to re-authorize.'`,无任何 `data` 字段 interpolation。
19
+ - **identity-state.js `_classifyUatFailure` redact 兜底 + uatRevoked 短路**(`src/auth/identity-state.js:88-105`):(1) 加 `if (uatError.uatRevoked) return UAT_REVOKED` 短路 —— refreshUAT 抛 invalid_grant 时设的 flag 现在真正驱动状态机(之前 flag 设了但 classifier 不读,是 dead metadata);(2) 在 `viaReason` 拼接前用 regex `replace(/[A-Za-z0-9._-]{40,}/g, '<redacted>')` 把任何 40+ 字符的 base64-ish 串清掉。defense-in-depth on top of refreshUAT 自己的清理。两条都有 fixture 测试覆盖(test-uat-lifecycle 第 15-16 case)。
20
+
21
+ ### Fixed
22
+
23
+ - **Cookie heartbeat 改为 ws-owner 单跑(v1.3.14 架构 root cause D 配套)**(`src/auth/cookie.js:30-50`):pre-v1.3.14 每个 MCP server 进程独立跑自己的 4 小时 cookie heartbeat timer,10+ 个并发 Claude Code / Codex / OpenClaw session 在一台机器上意味着每 4 小时 N 倍并发请求到飞书的 session-keepalive 端点。改为 owner-gated:只有持有 `ws-owner.lock` 的进程做真实 heartbeat + 写新 cookie 到 credentials.json;非 owner 进程的 timer tick 是 no-op。非 owner client 通过 v1.3.12 的 `CredentialsMonitor.onCookieChange` hook 在下次 tool call 时自动重建 userClient 拿新 cookie。Fallback:如果 ws-owner.lock 不存在(APP_ID/SECRET 没配 → WS server 未启 → 没人 claim),所有进程都跑 heartbeat(pre-v1.3.14 行为),保证 cookie-only 部署不受影响。每次 tick 重新检查 owner 身份,ws-owner 切换时自适应。
24
+ - **UAT refresh 跨进程锁路径迁移到 canonical home**(`src/auth/uat.js:98` `uatLockPath()`):从 `~/.claude/feishu-uat-refresh.lock` 搬到 `~/.feishu-user-plugin/uat-refresh.lock`。原因:Codex-only 用户没有 `~/.claude/` 目录,`mkdirSync` 会隐式创建空 dir 但 lock 文件没法跨 harness 真正互斥 → 30+ MCP server 并发 lazy refresh 时绕过文件锁 → 各自发 refresh API → 飞书侧 RT rotation 抢占 → `invalid_grant` 雪崩。新路径跟 `ws-owner.lock` 同 dir,所有 harness(Claude Code / Codex / OpenClaw / scripts)走同一把锁。`scripts/test-uat-race.js` 4 worker 验证:~1500ms 串行完成,无 overlap。
25
+ - **invalid_grant 触发 identity state machine UAT_REVOKED**(`src/auth/uat.js:170-178` + `src/auth/identity-state.js:88-95`):refreshUAT 拿到 `error: invalid_grant` 或 `code: 20064` 时**两条路径都执行**:(1) 直接调 `_refineIdentity(client, UAT_REVOKED)` 改 cache,(2) 抛 `err.uatRevoked = true`,由 `_classifyUatFailure` 短路成 UAT_REVOKED 让 `withIdentityFallback` 后续无论谁先到都正确分类。`fallbackWarning` 从含糊的 "UAT 不可用" 升级为 "UAT 已被撤销 (invalid_grant)... 运行 `npx feishu-user-plugin oauth` 后重启"。错误消息也明确 "The 7-day refresh chain is broken"。
26
+ - **三层 adoptPersistedUATIfNewer 检查避免重复 refresh**(`src/auth/uat.js:114-148`):锁外预检(短路 peer 已经写过的新 token)→ 锁失败再检(lock contention 期间 peer 完成)→ 锁内再检(acquireRefreshLock 返回到自己开始 refresh 之间的窗口)。10+ MCP server 进程同时 lazy refresh 时大幅减少向飞书侧的并发请求数,进一步降低 RT rotation 冲撞。
27
+ - **withUAT retry 后也走 auth-code 检查**(`src/auth/uat.js:194`):第一次 fn() 抛网络错时 `classifyError` 说 retry,**之前** retry 结果直接 return;**现在**让 retry 后的 response 也通过 `data.code === 99991663/99991668/99991677` 判断,触发 refresh 后再 retry。fix 一个静默走漏:peer 进程在 retry 间隙做了 RT rotation 后,本进程内存 RT 失效,retry response 携带 auth-related code 之前不会触发 refresh。
28
+ - **OAuth + setup 所有 `fetch` 调用加 `timeoutMs`**(`src/oauth.js` 4 处 + `src/setup.js:106`):之前裸 `fetch`,socket 卡死会让 `npx oauth` / `npx setup` 永久挂。Ctrl-C 重跑同一 authorization code 已被消费必失败。改为 `fetchWithTimeout` with 10-15s timeout。
29
+ - **scripts/test-uat-race-child.js 修复跨进程 race 测试**(v1.3.7 静默坏掉):原 child 调用 `client._uatLockPath() / _acquireRefreshLock()`,但 v1.3.7 phase A 把这些方法从 `LarkOfficialClient` 抽到 `src/auth/uat.js` 模块。pre-fix child 启动即 `TypeError`,4 个 worker 全部失败但 race test 报 `expected 4 successful workers, got 0` 时大家以为是环境问题。改为直接 import `uat.js::uatLockPath / acquireRefreshLock / releaseRefreshLock`。
30
+ - **credentials-monitor.js `forceInvalidate` 在 canonical 不存在时 `_initialized` 没翻转**(`src/auth/credentials-monitor.js:177`):之前 `_initialized` 只在 sync() 内部 set;forceInvalidate 时如果 canonical 不存在(用户手动删/移动文件),`_initialized` 仍是 false → 下次 sync() 文件出现会被当 baseline → 应该 fire 的 hook 被静默吞掉。修法:forceInvalidate 末尾无条件 `_initialized = true`。
31
+ - **server.js Lark Desktop reactor 冷启动 debounce**(`src/server.js:86-89` + `_runLarkDesktopReactor` first-tick init):之前 `_lastSwitchAt = 0` module-global,长时间运行的 owner 进程突然死掉后新 owner 接管,新 owner 的 `_lastSwitchAt` 是 0 → detectSwitch debounce 失效 → 冷启动第一次 tick 会把长期 pre-existing 的 Lark Desktop snapshot 当 "刚刚切换" 误触发 profile flip。修法:reactor 首次 tick 时若 `_lastSwitchAt === 0` 自动 stamp 为 `Date.now()`,给冷启动跟长跑 owner 一样的 debounce baseline。
32
+ - **decodeTokenExpiry 失败 breadcrumb flood-gate**(`src/auth/uat.js:31-46`):之前每次 `getValidUAT` 调用都会 decode `_uat`(因为 `_uatExpires = 0` 在解码失败后还是 falsy → 下次又 decode),如果 token 持续 malformed 会每个 tool 调用都向 stderr 打一行 warning。改为按 token sha256 hash 去重:每个 distinct 坏 token 只打一次,1024 entry cap 防 OOM。两个新 test case 验证 "same bad token 5 次只 log 1 次" + "3 个不同 bad token 各 log 1 次"。
33
+
34
+ ### Changed
35
+
36
+ - **`LarkOfficialClient.loadUAT()` 标 `@deprecated` + 内部简化**(`src/clients/official/base.js:25-35`):保留向后兼容 `src/test-all.js` 与外部 caller;新代码统一走 `src/server.js::loadUATFromEnv(client, env)` 从 credentials.json profile 或 harness env 读,不读 `process.env`。函数体也清掉了 v1.3.13 留下的死表达式(`parseInt(token ? ... : '0')` 表面像 guard 实际无效,code-review 期间删干净)。`server.js` 旁边的 "Mirror of LarkOfficialClient.loadUAT()" 注释更新成正向描述。
37
+ - **`cli.js keepalive` 不再污染 `process.env`**(`src/cli.js:251`):之前 `--all` 循环里写 `process.env.LARK_USER_ACCESS_TOKEN` 但不还原。注释还说"让 LarkOfficialClient.loadUAT() picks the right tokens",但实际 loadUAT 没被调用(v1.3.7 起 keepalive 直接赋实例字段)—— 既是 dead 操作又是 dead 注释。删干净。
38
+ - **`src/test-all.js` 自动从 canonical store backfill `process.env`**:v1.3.7+ 用户走 canonical store 后 `process.env.LARK_*` 多半为空,`npm test` 入口的 cookie / UAT 初始化会 sanity fail。加 `backfillFromCanonical()` 让 `npm test` 在没 export env vars 的 shell 直接跑通。
39
+
40
+ ### Added
41
+
42
+ - **新测试套件 `src/test-uat-lifecycle.js`(18 个 case)**:unit + mock-fetch,覆盖 decodeTokenExpiry(well-formed / missing payload / malformed base64 / no exp / flood-gate same-token-only-warns-once / flood-gate different-tokens-each-warn-once)、acquireRefreshLock(fresh / contention timeout / stale recovery)、releaseRefreshLock 容忍重复释放、adoptPersistedUATIfNewer(no canonical / same token / newer access / rotated refresh)、refreshUAT(invalid_grant 抛 `err.uatRevoked=true`、99991663 transient 不设 uatRevoked)、identity-state `_classifyUatFailure`(redact regex 真实 exercise long token-like string、uatRevoked flag 短路成 UAT_REVOKED)。接入 `npm test` 进 PR gate。
43
+ - **新测试套件 `src/test-cookie-heartbeat.js`(9 个 case)**:覆盖 `_isHeartbeatRunner` 在 6 种 lock 状态(ws-owner=self / ws-owner=other / lock missing / lock body malformed / pid 缺失 / pid 类型错)+ `_heartbeatTick` 在 3 种 owner 状态下的实际调用决策(non-owner 跳过、owner 成功 refresh + persist、owner network 失败但不 persist 不抛)。`_heartbeatTick` 函数从原 `setInterval` callback 抽出,让 tick path 可在 unit 测里直接 invoke 不必等 4 小时 timer。接入 `npm test`。
44
+ - **`src/auth/uat.js` 新增 export `uatLockPath / acquireRefreshLock / releaseRefreshLock`**:仅供测试 harness 用(明确标注非稳定 API),让 lifecycle test + cross-process race test 不依赖 client 实例方法。
45
+ - **`src/auth/identity-state.js` 新增 export `_classifyUatFailure`**:仅供测试用,让 redact regex + uatRevoked short-circuit 两条路径直接被 fixture 测到。
46
+ - **`src/auth/env-backfill.js`(新文件)**:从 canonical credentials store backfill `process.env.LARK_*`,给 legacy 路径(`loadUAT()` 读 `process.env`)兜底。提取自 v1.3.14 初版加在 `test-all.js` 顶部的 closure;现在被 `test-all.js` / `test-comprehensive.js` / `scripts/probe-feishu-docx.js` / `scripts/test-wiki-attach-fallback.js` 共用,统一 6 个 `LARK_*` keys backfill 范围(之前 `probe-feishu-docx.js` 只 backfill 2 key,缺 `LARK_UAT_EXPIRES`;现在统一)。`shell-export LARK_* > .env > canonical` 的优先级保留。
47
+
48
+ ### Docs
49
+
50
+ - **`docs/TROUBLESHOOTING.md` 加 4 个高频 troubleshooting 段**:(1)"频繁收到飞书授权操作通知" —— 给 JWT `auth_time` 字段诊断命令 + 区分 fresh consent vs silent refresh vs 飞书 server 侧策略变化;(2)"Token 已过期但 MCP 没自动刷新" —— uat-refresh.lock stale 检查 + canonical store 校验 + manual `keepalive`;(3)"v1.3.14 升级期间混合版本(短暂窗口)" —— 旧 v1.3.13 + 新 v1.3.14 同时跑 5 分钟内的 lock 路径不对齐风险 + 恢复步骤;(4)"migrate 后老 env 凭证还在被读" —— 重启 + 清 stale fallback 字段步骤。`UAT refresh invalid_grant` 现有段补 v1.3.14 锁路径变化说明 + canonical hot-reload 期望。
51
+ - **`README.md:147` "已删除" 条目补 reason inline**:之前只写"详见 ROADMAP",现在显式说明 md → wiki 因 wiki block schema 离散度高、Mermaid → 画板因依赖 wiki 主线一并删,不必跳出 README 就能理解。
52
+ - **`src/auth/cookie.js` 文件头注释精简**(17 行 → 7 行):保留核心机制说明(owner-gated + non-owner reload path + fallback),删掉营销腔的"10+ concurrent" 阐述(CHANGELOG 已有详述)。
53
+ - **`src/error-codes.js:31` 注释 "30-day window" → "7-day refresh-token window"**:飞书 v2 OAuth refresh_token 实际寿命 7 天滚动续期(与 `docs/AUTH-SETUP.md:29` / `docs/COMPARISON.md:52` / `src/oauth.js:255` 浏览器回调文案对齐)。这一处历史错文档跟其他 doc 一直矛盾,本版收口。
54
+ - **`README.md:147` 删除 "未实现:search_messages"**:v1.3.12 已实装,CLAUDE.md 当时改了但 README 漏改。换成 "已删除:md → wiki 双向同步、Mermaid → 画板"。
55
+ - **`docs/AUTH-SETUP.md:30` ws-owner.lock stale 时长 "30 秒" → "60 秒"**:与 `src/events/owner.js:14 STALE_MS = 60_000` 实际值对齐 + 补 v1.3.12 PID liveness check 说明(SIGKILL'd owner 即时回收,不必等 mtime)。同段加新行:`~/.feishu-user-plugin/uat-refresh.lock`(v1.3.14 起)+ 历史路径备注。
56
+ - **`src/oauth.js:255` 浏览器回调 HTML 文案 "30天有效" → "7天有效,每次 refresh 滚动续 7 天"**:澄清滚动续期语义 —— 活跃用户的 refresh_token 永远不过期,`keepalive` cron 只为关闭客户端 >7 天的场景。
57
+ - **`SECURITY.md:58` + `prompts/openclaw-setup.md:36` lock path 同步更新**:跟 v1.3.14 canonical 路径一致 + 保留历史路径备注。
58
+ - **`docs/REFACTOR-NOTES.md:29` 删除 `oauth-auto.js` 引用**:随文件删除,行内说明改成 `OAuth CLI 流程(v1.3.14 起 oauth-auto.js Playwright helper 已删除)`。
59
+ - **`prompts/openclaw-setup.md:34` 工具数 "84 个" → "85 个"**:跟主仓 README + SKILL.md 已经标的 85 对齐,pre-existing drift 顺手收口。
60
+ - **`prompts/openclaw-setup.md:36` 升级命令 "更新到 1.3.14" → "`npm i -g feishu-user-plugin@latest`"**:避免每发新版本都得回头改这条 dev doc。
61
+ - **`ROADMAP.md` 标题 "v1.3.13+ 待办" → "v1.3.14+ 待办"**。
62
+ - **`docs/RELEASING.md` Step 2 明确列出 6 处版本号 bump**:之前文档说"4 个,mcp-registry + mcpb 由 check 脚本单独校验"——这表述让 v1.3.14 release 漏了这 2 个 bump 直到 CI 警告才发现。改为显式列 6 处 + 各 check 脚本范围,避免后续 release 重蹈覆辙。
63
+
64
+ ### Release engineering
65
+
66
+ - **6 个版本号 source 全 bump**:`package.json` / `.claude-plugin/plugin.json` / `.cursor-plugin/plugin.json` / `skills/feishu-user-plugin/SKILL.md` / `mcp-registry.json`(含 `packages[0].version`) / `.mcpb/manifest.json`。`server.json` 由 `sync-server-json.js` regen。
67
+ - **10 个 CI gate 全通**:`check-version` / `check-tool-count` / `check-description-drift` / `check-mcp-registry-version` / `check-mcpb-version` / `sync-server-json` / `check-changelog` / `check-scopes` / `check-broken-links` / `check-docs-sync`(validate.yml + publish.yml + prepublishOnly 三个 workflow 跑全套)。
68
+
69
+ ### Test scenarios
70
+
71
+ - `npm test`(在没有 export `LARK_*` env vars 的 shell)→ **83 个 fixture pass / 0 fail**(含 e2e 18 PASS / 15 SKIP / 0 FAIL + test-uat-lifecycle 18 + test-cookie-heartbeat 9 + lark-desktop 13 + display-label 8 + 既有命名单元 17)
72
+ - OAuth 流程浏览器回调页面 source view → access_token 行只显示 `✅ 已获取(N chars)` 不含任何 token byte
73
+ - 触发持续 malformed UAT 场景(手动 corrupt token in canonical, 调 N 个 UAT 工具)→ stderr 只一条 `decodeTokenExpiry: malformed JWT` 警告,N-1 条被 dedupe
74
+ - `node scripts/test-uat-race.js`(4 worker 跨进程抢锁)→ **mutual exclusion PASSED**,4 worker 串行 ~1500ms,无 overlap(典型 1500-1520ms,依赖系统负载;4 × 300ms hold + 调度开销)
75
+ - `node scripts/check-scopes.js` → `OK (31 OAuth + 1 tenant-only scopes, 2 banned names guarded)`
76
+ - `npm run smoke` → `OK: 85 tools, 9 prompts, login_status shape matches`
77
+ - `npx feishu-user-plugin oauth` 之后浏览器页面文案:`refresh_token: ✅ 已获取(7天有效,支持自动续期;每次 refresh 滚动续 7 天)`
78
+ - 模拟 invalid_grant:next UAT tool call 的 `_fallbackWarning` 包含 `UAT 已被撤销 (invalid_grant)... 运行 \`npx feishu-user-plugin oauth\` 后重启`
79
+ - 升级后短暂出现 `~/.feishu-user-plugin/uat-refresh.lock`(refresh 时;30 秒 stale 自动回收)
80
+
7
81
  ## [1.3.13] - 2026-05-16
8
82
 
9
83
  紧急 patch — v1.3.12 release 后 Codex + Copilot PR #103 review 发现 1 P1 + 2 P2 + 5 polish,followup 又跑 5-agent 全仓 audit 找出 2 P1 (security) + 多个 doc/compliance 漂移。本版集中修复全部 issue + 把 fixture-based unit tests 拉进 CI gate。
package/README.en.md CHANGED
@@ -13,7 +13,9 @@ Feishu / Lark MCP server covering IM, docs, bitable, wiki, drive, calendar, task
13
13
 
14
14
  Works with Claude Code, Codex, Cursor, Windsurf, VS Code, Claude Desktop, OpenClaw, and any MCP-compatible client.
15
15
 
16
- There are two paths to user-identity messaging: **Feishu's official OAuth scope `im:message.send_as_user`** (requires creating a self-built app + admin approval), or this repo's **cookie + protobuf path** (zero app barrier — capture cookie and you're ready). This repo is no longer architecturally exclusive, but it remains the simpler option for "individual developers / no admin access / want to try quickly" scenarios.
16
+ For user-identity messaging there are two paths: **Feishu's official OAuth scope `im:message.send_as_user`** (requires creating a self-built app + admin approval), or this repo's **cookie + protobuf path** (capture cookie and you're ready). This repo is no longer architecturally exclusive, but it remains the simpler option for "individual developers / no admin access / want to try user-identity messaging quickly" scenarios.
17
+
18
+ > ⚠ **Scope of the zero-app-barrier claim**: only **text / post user-identity messaging** is strictly app-free: `send_to_user` / `send_to_group` / `send_as_user` / `send_post_as_user` / `batch_send` (text/post mode) — 5 tools. `send_image_as_user` / `send_file_as_user` send via cookie, but `image_key` / `file_key` must first be uploaded through the Official API (`upload_image` / `upload_file`); `send_card_as_user` is server-blocked on the cookie channel and always routes through bot. All other capabilities in this repo (reading group messages, document / bitable / wiki / drive / calendar / tasks / OKR / realtime events) **still require a Feishu self-built app** (`LARK_APP_ID` + `LARK_APP_SECRET`) — same as the official MCP / CLI.
17
19
 
18
20
  ## vs the official Feishu/Lark tools (released 2026)
19
21
 
package/README.md CHANGED
@@ -13,7 +13,9 @@
13
13
 
14
14
  兼容 Claude Code、Codex、Cursor、Windsurf、VS Code、Claude Desktop、OpenClaw 等 MCP 客户端。
15
15
 
16
- 用户身份发消息有两条路径:**飞书官方 OAuth scope `im:message.send_as_user`**(需要创建自建应用 + 管理员审批),或本仓的 **cookie + protobuf 路径**(零应用门槛,cookie 抓出来就跑)。本仓不再是物理性独家,但仍然是"个人开发者 / 没有管理员权限 / 想快速试"场景的简便选项。
16
+ 用户身份发消息有两条路径:**飞书官方 OAuth scope `im:message.send_as_user`**(需要创建自建应用 + 管理员审批),或本仓的 **cookie + protobuf 路径**(cookie 抓出来就跑)。本仓不再是物理性独家,但仍然是"个人开发者 / 没有管理员权限 / 想快速试用户身份发消息"场景的简便选项。
17
+
18
+ > ⚠ **注意限定范围**:cookie 路径"零应用门槛"只对**纯文本 / post 类用户身份发消息**严格成立:`send_to_user` / `send_to_group` / `send_as_user` / `send_post_as_user` / `batch_send`(text/post 模式)5 个工具。`send_image_as_user` / `send_file_as_user` 的发送本身走 cookie,但 `image_key` / `file_key` 必须先经 Official API(`upload_image` / `upload_file`)上传;`send_card_as_user` 服务端禁了 cookie 通道,始终走 bot。本仓其他能力(读群消息、操作文档 / 表格 / 知识库 / 云空间 / 日历 / 任务 / OKR / 实时事件等)也**仍然需要创建飞书自建应用**(`LARK_APP_ID` + `LARK_APP_SECRET`),跟官方 MCP / CLI 完全一样。
17
19
 
18
20
  ## 与官方对比(飞书 2026 年也发了 MCP + CLI)
19
21
 
@@ -142,7 +144,7 @@ mcp call manage_ws_status --action claim --force true
142
144
  - **协议变化**:cookie + protobuf 层依赖飞书 web 客户端的协议,飞书更新可能失效(机器人能力不受影响)
143
145
  - **卡片**:cookie 通道发卡片服务端不可用,机器人通道可发
144
146
  - **Lark 国际版**:实时事件 WS 不支持
145
- - **未实现**:`search_messages`、md → wiki 同步(详见 [ROADMAP.md](ROADMAP.md)
147
+ - **已删除**:md → wiki 双向同步(飞书 wiki block schema 离散度高,无损往返不现实)、Mermaid → 飞书画板(依赖 wiki 主线一并删)。详见 [ROADMAP.md](ROADMAP.md) 的"已删除"段
146
148
 
147
149
  ## 文档
148
150
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "feishu-user-plugin",
3
3
  "mcpName": "io.github.EthanQC/feishu-user-plugin",
4
- "version": "1.3.13",
4
+ "version": "1.3.14",
5
5
  "description": "All-in-one Feishu MCP server + CLI tool for Claude Code / Codex / Cursor / scripts — 85 tools across 3 auth layers (cookie / app / OAuth). Send as you, read groups, manage docs / bitable / wiki / drive / calendar / tasks / OKR.",
6
6
  "main": "src/index.js",
7
7
  "bin": {
@@ -0,0 +1,117 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // Scans tracked .md files for broken intra-repo file links.
4
+ //
5
+ // Checks only whether the target FILE exists on disk. Does NOT verify
6
+ // section anchors (GitHub's anchor-slug algorithm has CJK / em-dash
7
+ // edge cases that produce too many false positives to be worth the
8
+ // gate's signal). If you rename a heading and a link's #anchor stops
9
+ // working, GitHub renders the link clickable but lands at the page
10
+ // top — annoying but not broken in the structural sense.
11
+ //
12
+ // Checks:
13
+ // - [text](path/to/file.md) — relative or absolute path within the repo
14
+ // - [text](./file.md) / [text](../file.md) — relative paths
15
+ //
16
+ // Skips:
17
+ // - http:// / https:// links (external URLs not verified)
18
+ // - mailto: / # (pure page-internal anchors)
19
+ // - Image links ![...](...)
20
+ // - Targets that don't end in a file extension (e.g. "知乎专栏链接",
21
+ // "wikcnXXX", "args, ctx") — these are placeholder text inside
22
+ // markdown link syntax, not real file paths
23
+ // - Files under docs/launch/ + docs/superpowers/ (staging / historical
24
+ // plan docs — placeholder text + draft links are intentional there)
25
+ //
26
+ // Why this gate exists: PR-H series consolidated cross-link style
27
+ // (relative paths in docs/* and README, GitHub URLs in CLAUDE.md /
28
+ // AGENTS.md). Without this gate, future PRs can silently break links
29
+ // when files are renamed.
30
+
31
+ const fs = require('fs');
32
+ const path = require('path');
33
+ const { execSync } = require('child_process');
34
+
35
+ const ROOT = path.join(__dirname, '..');
36
+
37
+ // Tracked .md files
38
+ const allFiles = execSync('git ls-files "*.md"', { cwd: ROOT, encoding: 'utf8' })
39
+ .trim()
40
+ .split('\n')
41
+ .filter(Boolean);
42
+
43
+ // Skip staging / historical-plan directories — links there are often
44
+ // intentional placeholders for content yet to be written.
45
+ const SKIP_DIRS = ['docs/launch/', 'docs/superpowers/'];
46
+ const files = allFiles.filter(f => !SKIP_DIRS.some(d => f.startsWith(d)));
47
+
48
+ const failures = [];
49
+
50
+ // Pattern: [link text](target) — but not preceded by `!` (image)
51
+ const LINK_RE = /(?<!!)\[([^\]]+)\]\(([^)]+)\)/g;
52
+
53
+ // Heuristic: real file paths have a file extension (.md, .png, .json, ...) or
54
+ // they're a directory reference. Anything else is treated as placeholder text.
55
+ const FILE_EXT_RE = /\.[a-zA-Z0-9]{1,8}(#|$)/;
56
+
57
+ // Skip .html targets — Jekyll Pages cross-page links (`./en.html`) point at
58
+ // HTML produced by jekyll-build, not source files in the repo. The .html
59
+ // equivalent doesn't exist on disk but works at runtime on the rendered site.
60
+ const JEKYLL_HTML_RE = /\.html(#|$)/;
61
+
62
+ // Strip code blocks (``` ... ``` and `inline`) so we don't false-positive
63
+ // on markdown link syntax that appears inside code examples.
64
+ function stripCode(content) {
65
+ return content
66
+ .replace(/```[\s\S]*?```/g, '') // fenced
67
+ .replace(/`[^`\n]+`/g, ''); // inline
68
+ }
69
+
70
+ for (const file of files) {
71
+ const content = stripCode(fs.readFileSync(path.join(ROOT, file), 'utf8'));
72
+ const dir = path.dirname(file);
73
+
74
+ for (const m of content.matchAll(LINK_RE)) {
75
+ const linkTarget = m[2].trim();
76
+
77
+ // Skip external URLs and mailto/etc
78
+ if (/^[a-z]+:\/\//i.test(linkTarget) || linkTarget.startsWith('mailto:')) continue;
79
+ // Skip pure page-internal anchors
80
+ if (linkTarget.startsWith('#')) continue;
81
+ // Skip targets that don't look like file paths (placeholder text)
82
+ if (!FILE_EXT_RE.test(linkTarget) && !linkTarget.endsWith('/')) continue;
83
+ // Skip Jekyll-rendered .html links (cross-page in docs/index.md / en.md)
84
+ if (JEKYLL_HTML_RE.test(linkTarget)) continue;
85
+
86
+ // Strip anchor portion if present
87
+ const pathPart = linkTarget.split('#')[0];
88
+ if (!pathPart) continue;
89
+
90
+ // Resolve target: leading `/` means repo-absolute (rare but valid in
91
+ // markdown); otherwise resolve relative to the source file's directory.
92
+ // path.resolve handles `..` and normalizes; the boundary check below
93
+ // rejects targets that escape ROOT.
94
+ const absoluteResolved = pathPart.startsWith('/')
95
+ ? path.resolve(ROOT, pathPart.replace(/^\/+/, ''))
96
+ : path.resolve(ROOT, dir, pathPart);
97
+
98
+ if (absoluteResolved !== ROOT && !absoluteResolved.startsWith(ROOT + path.sep)) {
99
+ failures.push(`${file}: broken link "${linkTarget}" — target escapes repo root`);
100
+ continue;
101
+ }
102
+
103
+ if (!fs.existsSync(absoluteResolved)) {
104
+ const rel = path.relative(ROOT, absoluteResolved);
105
+ failures.push(`${file}: broken link "${linkTarget}" — file "${rel}" does not exist`);
106
+ }
107
+ }
108
+ }
109
+
110
+ if (failures.length) {
111
+ console.error(`Broken markdown file links detected (${failures.length}):`);
112
+ for (const f of failures) console.error(` ${f}`);
113
+ console.error('\nFix: update the path in the source .md, or rename / restore the referenced file.');
114
+ process.exit(1);
115
+ }
116
+
117
+ console.log(`OK: scanned ${files.length} .md files (excluding ${SKIP_DIRS.join(' / ')}), no broken intra-repo file links`);
@@ -303,7 +303,13 @@ function main() {
303
303
  const teamSkillsBlock = generateTeamSkillsChangelog(version, date, parsed, null);
304
304
  fs.writeFileSync(path.join(outDir, 'team-skills-changelog.md'), teamSkillsBlock);
305
305
 
306
- const rootRow = generateRootReadmeRow(version, pkg.description);
306
+ // Use .claude-plugin/plugin.json::description (NOT package.json), because
307
+ // team-skills' own scripts/generate-readme.py reads each plugin's
308
+ // .claude-plugin/plugin.json::description. Mismatch causes team-skills
309
+ // CI "Validate Plugins" to fail (caught 2026-05-13 root-cause analysis:
310
+ // 7 of last 30 team-skills CI runs failed for exactly this drift).
311
+ const pluginJson = JSON.parse(fs.readFileSync(path.join(ROOT, '.claude-plugin', 'plugin.json'), 'utf8'));
312
+ const rootRow = generateRootReadmeRow(version, pluginJson.description);
307
313
  fs.writeFileSync(path.join(outDir, 'team-skills-readme-row.md'), rootRow + '\n');
308
314
 
309
315
  const card = generateCard(version, date, parsed);
@@ -83,12 +83,12 @@ const BLOCK_LABELS = {
83
83
  process.exit(1);
84
84
  }
85
85
  const c = new LarkOfficialClient(env.LARK_APP_ID, env.LARK_APP_SECRET);
86
- // loadUAT() reads process.env directly — propagate if we got tokens from
87
- // the .claude.json fallback path, where process.env is not populated.
88
- for (const k of ['LARK_USER_ACCESS_TOKEN', 'LARK_USER_REFRESH_TOKEN']) {
89
- if (env[k] && !process.env[k]) process.env[k] = env[k];
90
- }
91
- if (env.LARK_USER_ACCESS_TOKEN) c.loadUAT();
86
+ // loadUAT() reads process.env directly. v1.3.14 use the shared backfill
87
+ // helper instead of the inline 2-key copy this script previously did
88
+ // (LARK_UAT_EXPIRES was missing from the manual loop, leading to silent
89
+ // token-expires-zero issues on the probe path).
90
+ require('../src/auth/env-backfill').backfillFromCanonical();
91
+ if (process.env.LARK_USER_ACCESS_TOKEN) c.loadUAT();
92
92
 
93
93
  // --- Fetch blocks ---
94
94
  // NOTE: getDocBlocks fetches up to 500 blocks (no pagination). Docs longer
@@ -1,17 +1,20 @@
1
1
  // Child worker for test-uat-race.js. Acquires the UAT refresh lock, holds
2
2
  // for a brief window (simulating the refresh + persist), then releases.
3
3
  // Writes a single line to stdout: "<id> acquired <ts_ms>; released <ts_ms>"
4
+ //
5
+ // v1.3.14 — rewritten to use module-level uat.js API. Pre-v1.3.14 this called
6
+ // `client._uatLockPath()` etc, which no longer existed after v1.3.7 extracted
7
+ // lifecycle into src/auth/uat.js — the test was silently broken for months.
4
8
 
5
- const { LarkOfficialClient } = require('../src/official');
9
+ const { uatLockPath, acquireRefreshLock, releaseRefreshLock } = require('../src/auth/uat');
6
10
 
7
11
  const id = process.argv[2] || '?';
8
12
  const holdMs = parseInt(process.argv[3] || '250');
9
13
 
10
- const client = new LarkOfficialClient('test', 'test');
11
- const lockPath = client._uatLockPath();
14
+ const lockPath = uatLockPath();
12
15
 
13
16
  (async () => {
14
- const got = await client._acquireRefreshLock(lockPath, { timeoutMs: 15000 });
17
+ const got = await acquireRefreshLock(lockPath, { timeoutMs: 15000 });
15
18
  if (!got) {
16
19
  console.log(`${id} FAILED_TO_ACQUIRE`);
17
20
  process.exit(1);
@@ -19,6 +22,6 @@ const lockPath = client._uatLockPath();
19
22
  const acquired = Date.now();
20
23
  await new Promise(r => setTimeout(r, holdMs));
21
24
  const released = Date.now();
22
- client._releaseRefreshLock(lockPath);
25
+ releaseRefreshLock(lockPath);
23
26
  console.log(`${id} acquired ${acquired}; released ${released}`);
24
27
  })().catch(e => { console.log(`${id} ERROR ${e.message}`); process.exit(1); });
@@ -12,11 +12,18 @@ const HOLD_MS = 300;
12
12
 
13
13
  const child = path.join(__dirname, 'test-uat-race-child.js');
14
14
 
15
- // Clean up any stale lock from prior runs
15
+ // Clean up any stale lock from prior runs.
16
+ // v1.3.14 — lock path moved; clean up both old and new locations in case the
17
+ // test runs against either version.
18
+ try {
19
+ const os = require('os');
20
+ fs.unlinkSync(path.join(os.homedir(), '.feishu-user-plugin', 'uat-refresh.lock'));
21
+ console.log('(cleaned up stale lock at canonical path)');
22
+ } catch (_) {}
16
23
  try {
17
24
  const os = require('os');
18
25
  fs.unlinkSync(path.join(os.homedir(), '.claude', 'feishu-uat-refresh.lock'));
19
- console.log('(cleaned up stale lock)');
26
+ console.log('(cleaned up stale lock at legacy path)');
20
27
  } catch (_) {}
21
28
 
22
29
  (async () => {
@@ -15,6 +15,13 @@ if (!creds.LARK_APP_ID || !creds.LARK_APP_SECRET || !creds.LARK_USER_ACCESS_TOKE
15
15
  process.exit(77); // POSIX skip code
16
16
  }
17
17
 
18
+ // v1.3.14 — `loadUAT()` reads from process.env, but `readCredentials()` reads
19
+ // from canonical store or harness env. Without backfill, the early-skip guard
20
+ // above would pass (creds has the UAT) but loadUAT() below would silently
21
+ // no-op (process.env doesn't), so the test would run with an empty UAT
22
+ // against the mocked-attachToWiki path.
23
+ require('../src/auth/env-backfill').backfillFromCanonical();
24
+
18
25
  (async () => {
19
26
  const { LarkOfficialClient } = require('../src/clients/official');
20
27
  const client = new LarkOfficialClient(creds.LARK_APP_ID, creds.LARK_APP_SECRET);
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: feishu-user-plugin
3
- version: "1.3.13"
4
- description: "All-in-one Feishu MCP server + CLI tool — send messages as yourself (incl. batch_send), read group/P2P chats (auto-expands merge_forward), manage docs/tables/wiki (full CRUD)/drive, OKR (with progress writes), calendar (read+write), Tasks v2, multi-profile auto-switch, real-time WS events. v1.3.13: search_messages tool (Protobuf phase 2, UAT-only), CLI tool mode (tool list / help / dispatch), IdentityState state machine + credentials hot-reload (UAT viaUser flag preserved across the 15+ write tools, no-restart UAT/cookie reload, startup-blind-window closed), displayLabel + sender semantics pack, WS owner PID liveness, gitleaks secret scan, oauth.js token-leak hardening, fixture unit tests pulled into CI gate."
3
+ version: "1.3.14"
4
+ description: "All-in-one Feishu MCP server + CLI tool — send messages as yourself (incl. batch_send), read group/P2P chats (auto-expands merge_forward), manage docs/tables/wiki (full CRUD)/drive, OKR (with progress writes), calendar (read+write), Tasks v2, multi-profile auto-switch, real-time WS events. v1.3.14: UAT refresh-lock moved to canonical home (Codex-only users now get cross-process mutex), invalid_grant flips identity to UAT_REVOKED with clear oauth re-run guidance, refresh-error redaction (no raw response body in Error.message), three-stage adoptPersistedUATIfNewer race-shield, cookie heartbeat ws-owner-gated (no more N-times API spam from 30+ concurrent sessions), OAuth/setup all fetch calls timeout-bounded, oauth-auto.js dead code with token leak removed, OAuth browser callback no longer displays token bytes, decodeTokenExpiry malformed-JWT warning deduped, Lark Desktop reactor cold-start debounce fixed, 27 new fixture-based tests (test-uat-lifecycle + test-cookie-heartbeat) plus the cross-process race test repaired, TROUBLESHOOTING.md grew 4 sections (frequent consent notification / UAT-not-refreshing / mixed-version upgrade window / migrate-then-stale-env)."
5
5
  allowed-tools: send_to_user, send_to_group, send_as_user, send_image_as_user, send_file_as_user, send_post_as_user, batch_send, send_card_as_user, search_contacts, create_p2p_chat, get_chat_info, get_user_info, get_login_status, list_profiles, switch_profile, manage_profile_hints, read_p2p_messages, list_user_chats, list_chats, read_messages, search_messages, send_message_as_bot, reply_message, forward_message, delete_message, update_message, add_reaction, delete_reaction, pin_message, create_group, update_group, list_members, manage_members, search_docs, read_doc, get_doc_blocks, create_doc, manage_doc_block, read_doc_markdown, manage_bitable_app, manage_bitable_table, manage_bitable_field, manage_bitable_view, manage_bitable_record, upload_bitable_attachment, list_wiki_spaces, search_wiki, list_wiki_nodes, get_wiki_node, create_wiki_node, update_wiki_node, move_wiki_node, copy_wiki_node, delete_wiki_node, list_files, create_folder, upload_drive_file, manage_drive_file, upload_image, upload_file, download_message_resource, download_doc_image, list_user_okrs, get_okrs, list_okr_periods, create_okr_progress_record, list_okr_progress_records, delete_okr_progress_record, list_calendars, list_calendar_events, get_calendar_event, create_calendar_event, update_calendar_event, delete_calendar_event, respond_calendar_event, get_freebusy, list_tasks, get_task, create_task, update_task, complete_task, delete_task, manage_task_members, get_new_events, manage_ws_status
6
6
  user_invocable: true
7
7
  ---
@@ -1,22 +1,69 @@
1
1
  // src/auth/cookie.js — cookie heartbeat scheduler.
2
2
  //
3
- // State lives on the LarkUserClient instance (this.cookieStr, this._heartbeatTimer).
4
- // We expose start/stop functions that take `client` and mutate the timer field.
5
- // Lifted out of clients/user.js for clarity; called only from there.
3
+ // State lives on the LarkUserClient instance. Lifted from clients/user.js.
4
+ //
5
+ // v1.3.14 owner-gated single runner: only the process holding
6
+ // ws-owner.lock does the real heartbeat; non-owners tick into no-op. Non-owner
7
+ // clients pick up the refreshed cookie via CredentialsMonitor's
8
+ // onCookieChange hook on the next tool call. Fallback: no ws-owner.lock
9
+ // (e.g., APP_ID/SECRET missing → no WS server) → every process runs heartbeat
10
+ // (pre-v1.3.14 behaviour), keeping cookie-only deployments working.
11
+
12
+ 'use strict';
13
+
14
+ const fs = require('fs');
15
+ const os = require('os');
16
+ const path = require('path');
6
17
 
7
18
  const HEARTBEAT_INTERVAL_MS = 4 * 60 * 60 * 1000; // 4 hours — sl_session has 12h max
19
+ const WS_OWNER_LOCK = path.join(os.homedir(), '.feishu-user-plugin', 'ws-owner.lock');
20
+
21
+ /**
22
+ * Returns true iff this process should run the cookie heartbeat right now.
23
+ * Logic: this process is the ws-owner, OR no ws-owner exists (fallback).
24
+ * Re-checked on every tick so the gate adapts to live ws-owner takeovers.
25
+ *
26
+ * Exported for testing — callers should not depend on its presence.
27
+ */
28
+ function _isHeartbeatRunner(_lockPath = WS_OWNER_LOCK, _pid = process.pid) {
29
+ let body;
30
+ try {
31
+ body = JSON.parse(fs.readFileSync(_lockPath, 'utf8'));
32
+ } catch (_) {
33
+ // Lock file missing or unreadable → no owner is claimed → fall back to
34
+ // pre-v1.3.14 behavior (every process runs heartbeat).
35
+ return true;
36
+ }
37
+ if (typeof body.pid !== 'number') return true; // malformed body → fallback
38
+ return body.pid === _pid;
39
+ }
40
+
41
+ /**
42
+ * One heartbeat tick. Extracted so unit tests can call it directly without
43
+ * waiting 4 hours. Returns the action taken: 'skip' (non-owner), 'refreshed'
44
+ * (owner did the API call + persist), or 'error' (owner tried but failed).
45
+ *
46
+ * Exported for testing.
47
+ */
48
+ async function _heartbeatTick(client, deps = {}) {
49
+ const isOwner = deps.isHeartbeatRunner || _isHeartbeatRunner;
50
+ if (!isOwner()) {
51
+ return 'skip';
52
+ }
53
+ try {
54
+ await client._getCsrfToken();
55
+ const persist = deps.persistToConfig || require('./credentials').persistToConfig;
56
+ persist({ LARK_COOKIE: client.cookieStr });
57
+ console.error('[feishu-user-plugin] Cookie heartbeat: session refreshed and persisted (ws-owner)');
58
+ return 'refreshed';
59
+ } catch (e) {
60
+ console.error('[feishu-user-plugin] Cookie heartbeat failed:', e.message);
61
+ return 'error';
62
+ }
63
+ }
8
64
 
9
65
  function startHeartbeat(client) {
10
- client._heartbeatTimer = setInterval(async () => {
11
- try {
12
- await client._getCsrfToken();
13
- const { persistToConfig } = require('./credentials');
14
- persistToConfig({ LARK_COOKIE: client.cookieStr });
15
- console.error('[feishu-user-plugin] Cookie heartbeat: session refreshed and persisted');
16
- } catch (e) {
17
- console.error('[feishu-user-plugin] Cookie heartbeat failed:', e.message);
18
- }
19
- }, HEARTBEAT_INTERVAL_MS);
66
+ client._heartbeatTimer = setInterval(() => _heartbeatTick(client), HEARTBEAT_INTERVAL_MS);
20
67
  if (client._heartbeatTimer.unref) client._heartbeatTimer.unref();
21
68
  }
22
69
 
@@ -27,4 +74,10 @@ function stopHeartbeat(client) {
27
74
  }
28
75
  }
29
76
 
30
- module.exports = { startHeartbeat, stopHeartbeat, HEARTBEAT_INTERVAL_MS };
77
+ module.exports = {
78
+ startHeartbeat,
79
+ stopHeartbeat,
80
+ HEARTBEAT_INTERVAL_MS,
81
+ _isHeartbeatRunner, // exported for testing
82
+ _heartbeatTick, // exported for testing
83
+ };
@@ -170,6 +170,11 @@ function createCredentialsMonitor({ path: credPath = DEFAULT_PATH } = {}) {
170
170
  lastRefreshHash = _fieldHash(env, 'LARK_USER_REFRESH_TOKEN');
171
171
  lastMtimeMs = stat.mtimeMs;
172
172
  }
173
+ // v1.3.14 — always mark initialized after forceInvalidate, even when
174
+ // canonical was null. Without this, a subsequent sync() that finds the
175
+ // file existing would treat the first read as baselining (silent) and
176
+ // swallow the hook fire that should signal "file appeared".
177
+ _initialized = true;
173
178
  }
174
179
 
175
180
  return {
@@ -0,0 +1,37 @@
1
+ // src/auth/env-backfill.js — backfill `process.env.LARK_*` from canonical
2
+ // credentials store, for legacy callers that read env directly.
3
+ //
4
+ // Motivation: v1.3.7+ canonical store is at ~/.feishu-user-plugin/credentials.json,
5
+ // but the original CLI flows + e2e tests + dev probes (e.g. test-all.js,
6
+ // test-comprehensive.js, scripts/probe-feishu-docx.js, scripts/test-wiki-attach-
7
+ // fallback.js) constructed clients with `process.env.LARK_*`. After users move
8
+ // creds to canonical, those env vars are empty in a fresh shell and the legacy
9
+ // paths fall over.
10
+ //
11
+ // This helper sets process.env values from canonical if and only if they aren't
12
+ // already set, preserving precedence: explicit shell env > canonical fallback.
13
+ // Safe to call multiple times — idempotent. Safe to call before canonical
14
+ // exists (no-op, legacy harness env still wins).
15
+
16
+ 'use strict';
17
+
18
+ const SNAP_KEYS = [
19
+ 'LARK_COOKIE',
20
+ 'LARK_APP_ID',
21
+ 'LARK_APP_SECRET',
22
+ 'LARK_USER_ACCESS_TOKEN',
23
+ 'LARK_USER_REFRESH_TOKEN',
24
+ 'LARK_UAT_EXPIRES',
25
+ ];
26
+
27
+ function backfillFromCanonical() {
28
+ try {
29
+ const { readCredentials } = require('./credentials');
30
+ const creds = readCredentials();
31
+ for (const k of SNAP_KEYS) {
32
+ if (!process.env[k] && creds[k]) process.env[k] = String(creds[k]);
33
+ }
34
+ } catch (_) { /* canonical may not exist; legacy path unaffected */ }
35
+ }
36
+
37
+ module.exports = { backfillFromCanonical, SNAP_KEYS };
@@ -84,7 +84,22 @@ function _refineIdentity(client, state) {
84
84
  // should keep the original VALID_USER state and just record the via_reason).
85
85
  function _classifyUatFailure(uatResp, uatError) {
86
86
  if (uatError) {
87
- const msg = uatError.message || String(uatError);
87
+ // v1.3.14 explicit short-circuit when refreshUAT set `err.uatRevoked`.
88
+ // Lets refresh-side rejections (invalid_grant from /authen/v2/oauth/token)
89
+ // flow into the same UAT_REVOKED state as tool-call-side 20064 responses,
90
+ // and lets `withIdentityFallback` build a clear "请重跑 oauth" warning.
91
+ if (uatError.uatRevoked) {
92
+ return {
93
+ state: IdentityState.UAT_REVOKED,
94
+ viaReason: 'as user: refresh_token rejected by Feishu (invalid_grant)',
95
+ };
96
+ }
97
+ // v1.3.14 — redact base64-ish tokens (40+ chars of [A-Za-z0-9._-]) in
98
+ // case an upstream throw site leaked refresh_token or access_token bytes
99
+ // into the error message. Defense-in-depth on top of uat.js::refreshUAT
100
+ // which already avoids dumping the raw response body.
101
+ const rawMsg = uatError.message || String(uatError);
102
+ const msg = rawMsg.replace(/[A-Za-z0-9._-]{40,}/g, '<redacted>');
88
103
  // Network/JSON parse errors don't refine identity — UAT is still presumed
89
104
  // valid, we just couldn't reach Feishu this call.
90
105
  return { state: null, viaReason: `as user: ${msg}` };
@@ -206,4 +221,5 @@ module.exports = {
206
221
  invalidateIdentity,
207
222
  _refineIdentity, // exported for D's CredentialsMonitor hook (private API)
208
223
  _readInMemoryState, // exported for testing edge cases (private API)
224
+ _classifyUatFailure, // v1.3.14 — exported for testing redact + uatRevoked wiring
209
225
  };