feishu-user-plugin 1.3.12 → 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.
- package/.claude-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/.mcpb/manifest.json +1 -1
- package/CHANGELOG.md +125 -0
- package/README.en.md +3 -1
- package/README.md +4 -2
- package/package.json +1 -1
- package/scripts/check-broken-links.js +117 -0
- package/scripts/generate-release-artifacts.js +7 -1
- package/scripts/probe-feishu-docx.js +6 -6
- package/scripts/test-uat-race-child.js +8 -5
- package/scripts/test-uat-race.js +9 -2
- package/scripts/test-wiki-attach-fallback.js +7 -0
- package/scripts/verify-app-name.js +1 -1
- package/skills/feishu-user-plugin/SKILL.md +2 -2
- package/src/auth/cookie.js +67 -14
- package/src/auth/credentials-monitor.js +5 -0
- package/src/auth/env-backfill.js +37 -0
- package/src/auth/identity-state.js +23 -2
- package/src/auth/uat.js +86 -7
- package/src/cli.js +4 -3
- package/src/clients/official/base.js +39 -9
- package/src/error-codes.js +2 -1
- package/src/oauth.js +38 -11
- package/src/server.js +38 -4
- package/src/setup.js +3 -1
- package/src/test-all.js +12 -0
- package/src/test-comprehensive.js +7 -2
- package/src/test-cookie-heartbeat.js +132 -0
- package/src/test-identity-state.js +5 -0
- package/src/test-lark-desktop.js +1 -0
- package/src/test-lru-cache.js +1 -1
- package/src/test-uat-lifecycle.js +354 -0
- package/src/oauth-auto.js +0 -175
|
@@ -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
|
-
|
|
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}` };
|
|
@@ -150,7 +165,12 @@ async function withIdentityFallback({ client, uatFn, botFn, label }) {
|
|
|
150
165
|
uatErr = e;
|
|
151
166
|
}
|
|
152
167
|
if (uatResp && uatResp.code === 0) {
|
|
153
|
-
|
|
168
|
+
// Preserve the legacy _viaUser marker that 15+ _asUserOrApp callers read
|
|
169
|
+
// via `res._viaUser`. Without this flag, calendar/docs/bitable/wiki/okr/
|
|
170
|
+
// tasks/drive write tools labelled UAT-owned resources as viaUser:false,
|
|
171
|
+
// making users believe a bot created them. Caught by Codex review on
|
|
172
|
+
// PR #103 (P1 — set _viaUser on successful UAT results).
|
|
173
|
+
const data = { ...uatResp, _viaUser: true };
|
|
154
174
|
return { data, via: 'uat', identity };
|
|
155
175
|
}
|
|
156
176
|
const cls = _classifyUatFailure(uatResp, uatErr);
|
|
@@ -201,4 +221,5 @@ module.exports = {
|
|
|
201
221
|
invalidateIdentity,
|
|
202
222
|
_refineIdentity, // exported for D's CredentialsMonitor hook (private API)
|
|
203
223
|
_readInMemoryState, // exported for testing edge cases (private API)
|
|
224
|
+
_classifyUatFailure, // v1.3.14 — exported for testing redact + uatRevoked wiring
|
|
204
225
|
};
|
package/src/auth/uat.js
CHANGED
|
@@ -28,13 +28,36 @@ const path = require('path');
|
|
|
28
28
|
const os = require('os');
|
|
29
29
|
const { fetchWithTimeout } = require('../utils');
|
|
30
30
|
|
|
31
|
+
// One-warning-per-malformed-token tracker. Without this, a persistently bad
|
|
32
|
+
// JWT would flood stderr every tool call (getValidUAT calls decode whenever
|
|
33
|
+
// _uatExpires falsy, which it stays at 0 if decode returns 0). 1024-entry cap
|
|
34
|
+
// is conservative — real tokens are rotated rarely; cap prevents OOM in the
|
|
35
|
+
// unlikely event of a malformed-token spray.
|
|
36
|
+
const _warnedMalformedTokens = new Set();
|
|
37
|
+
function _markWarnedMalformedToken(token) {
|
|
38
|
+
if (typeof token !== 'string' || token.length === 0) return false;
|
|
39
|
+
const key = require('crypto').createHash('sha256').update(token, 'utf8').digest('hex').slice(0, 16);
|
|
40
|
+
if (_warnedMalformedTokens.has(key)) return false;
|
|
41
|
+
if (_warnedMalformedTokens.size >= 1024) _warnedMalformedTokens.clear();
|
|
42
|
+
_warnedMalformedTokens.add(key);
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
|
|
31
46
|
function decodeTokenExpiry(token) {
|
|
32
47
|
try {
|
|
33
48
|
const payload = token?.split('.')?.[1];
|
|
34
49
|
if (!payload) return 0;
|
|
35
50
|
const data = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
|
|
36
51
|
return typeof data.exp === 'number' ? data.exp : 0;
|
|
37
|
-
} catch (
|
|
52
|
+
} catch (e) {
|
|
53
|
+
// Log breadcrumb so silently-malformed UATs are observable, but only once
|
|
54
|
+
// per distinct bad token (hashed). We still return 0 (caller treats 0 as
|
|
55
|
+
// "never decoded — let refresh path decide") instead of throwing, because
|
|
56
|
+
// a bad JWT shouldn't crash tool dispatch — the next 99991663/99991668
|
|
57
|
+
// response will trigger refresh anyway.
|
|
58
|
+
if (_markWarnedMalformedToken(token)) {
|
|
59
|
+
console.error(`[feishu-user-plugin] decodeTokenExpiry: malformed JWT payload (${e.message}); will rely on Feishu rejection for refresh trigger`);
|
|
60
|
+
}
|
|
38
61
|
return 0;
|
|
39
62
|
}
|
|
40
63
|
}
|
|
@@ -73,7 +96,12 @@ function adoptPersistedUATIfNewer(client) {
|
|
|
73
96
|
}
|
|
74
97
|
|
|
75
98
|
function uatLockPath() {
|
|
76
|
-
|
|
99
|
+
// v1.3.14: moved from ~/.claude/feishu-uat-refresh.lock to canonical
|
|
100
|
+
// ~/.feishu-user-plugin/ so Codex-only / non-Claude-Code users (whose
|
|
101
|
+
// ~/.claude/ may not exist) still get cross-process mutual exclusion.
|
|
102
|
+
// Mixed-version concern is N/A — running two different versions of this
|
|
103
|
+
// plugin in parallel is not a supported configuration.
|
|
104
|
+
return path.join(os.homedir(), '.feishu-user-plugin', 'uat-refresh.lock');
|
|
77
105
|
}
|
|
78
106
|
|
|
79
107
|
async function acquireRefreshLock(lockPath, { staleMs = 30000, pollMs = 200, timeoutMs = 20000 } = {}) {
|
|
@@ -105,13 +133,32 @@ function releaseRefreshLock(lockPath) {
|
|
|
105
133
|
}
|
|
106
134
|
|
|
107
135
|
async function refreshUAT(client) {
|
|
136
|
+
// v1.3.14 — Pre-lock cheap path: maybe a peer process already refreshed and
|
|
137
|
+
// persisted a valid token. Adopt it before paying for the file lock. This
|
|
138
|
+
// dramatically reduces lock contention in deployments with 10+ concurrent
|
|
139
|
+
// MCP server processes (one per Claude Code session).
|
|
140
|
+
let now = Math.floor(Date.now() / 1000);
|
|
141
|
+
if (adoptPersistedUATIfNewer(client) && client._uatExpires > now + 300) {
|
|
142
|
+
return client._uat;
|
|
143
|
+
}
|
|
144
|
+
|
|
108
145
|
const lockPath = uatLockPath();
|
|
109
146
|
const acquired = await acquireRefreshLock(lockPath);
|
|
110
147
|
if (!acquired) {
|
|
111
|
-
|
|
148
|
+
// Lock timed out (>20s of contention). Before falling through to an
|
|
149
|
+
// un-coordinated refresh — which can burn the refresh_token chain on the
|
|
150
|
+
// Feishu side — give disk one more chance: a peer may have written a
|
|
151
|
+
// fresh token between our pre-check and now.
|
|
152
|
+
now = Math.floor(Date.now() / 1000);
|
|
153
|
+
if (adoptPersistedUATIfNewer(client) && client._uatExpires > now + 300) {
|
|
154
|
+
return client._uat;
|
|
155
|
+
}
|
|
156
|
+
console.error('[feishu-user-plugin] UAT refresh lock timed out; proceeding without mutual exclusion (this may burn refresh_token chain — investigate if it happens often)');
|
|
112
157
|
}
|
|
113
158
|
try {
|
|
114
|
-
|
|
159
|
+
// Inside the lock: re-check disk one more time. Between acquireRefreshLock
|
|
160
|
+
// returning and this point, another holder may have released after writing.
|
|
161
|
+
now = Math.floor(Date.now() / 1000);
|
|
115
162
|
if (adoptPersistedUATIfNewer(client) && client._uatExpires > now + 300) {
|
|
116
163
|
return client._uat;
|
|
117
164
|
}
|
|
@@ -128,7 +175,28 @@ async function refreshUAT(client) {
|
|
|
128
175
|
});
|
|
129
176
|
const data = await res.json();
|
|
130
177
|
const tokenData = data.access_token ? data : data.data;
|
|
131
|
-
if (!tokenData?.access_token)
|
|
178
|
+
if (!tokenData?.access_token) {
|
|
179
|
+
// v1.3.14 — never dump the raw response body. Some Feishu error paths
|
|
180
|
+
// echo back portions of the request including refresh_token bytes, and
|
|
181
|
+
// this message bubbles up to Error.message → MCP content[0].text →
|
|
182
|
+
// LLM transcript. Surface only the structured error code/msg.
|
|
183
|
+
const errCode = data?.error ?? data?.code ?? 'unknown';
|
|
184
|
+
const errMsg = data?.error_description ?? data?.msg ?? '(no error message from Feishu)';
|
|
185
|
+
// Distinguish refresh_token rejection (must re-oauth) from transient
|
|
186
|
+
// server-side errors so the identity state machine can flip to
|
|
187
|
+
// UAT_REVOKED, and withIdentityFallback can give the LLM clear guidance.
|
|
188
|
+
const isInvalidGrant = errCode === 'invalid_grant' || errCode === 20064;
|
|
189
|
+
if (isInvalidGrant) {
|
|
190
|
+
try {
|
|
191
|
+
const { _refineIdentity, IdentityState } = require('./identity-state');
|
|
192
|
+
_refineIdentity(client, IdentityState.UAT_REVOKED);
|
|
193
|
+
} catch (_) { /* identity-state may not be loaded in CLI subcommands; non-fatal */ }
|
|
194
|
+
const err = new Error('UAT refresh_token rejected by Feishu (invalid_grant). The 7-day refresh chain is broken. Run: npx feishu-user-plugin oauth to re-authorize.');
|
|
195
|
+
err.uatRevoked = true;
|
|
196
|
+
throw err;
|
|
197
|
+
}
|
|
198
|
+
throw new Error(`UAT refresh failed (code=${errCode}: ${errMsg}). If persistent, run: npx feishu-user-plugin oauth.`);
|
|
199
|
+
}
|
|
132
200
|
client._uat = tokenData.access_token;
|
|
133
201
|
client._uatRefresh = tokenData.refresh_token || client._uatRefresh;
|
|
134
202
|
const expiresIn = typeof tokenData.expires_in === 'number' && tokenData.expires_in > 0 ? tokenData.expires_in : 7200;
|
|
@@ -164,9 +232,15 @@ async function withUAT(client, fn) {
|
|
|
164
232
|
} catch (err) {
|
|
165
233
|
const cls = classifyError(err);
|
|
166
234
|
if (cls.action === 'retry') {
|
|
167
|
-
|
|
235
|
+
// v1.3.14 — fall through into the auth-code check below instead of
|
|
236
|
+
// returning the retry result raw. A token rotated between our first
|
|
237
|
+
// attempt and the retry (peer process refreshed) can manifest as a
|
|
238
|
+
// 99991663/99991668 in the retry response, and we want refreshUAT to
|
|
239
|
+
// run before bubbling that up as a hard failure.
|
|
240
|
+
data = await fn(uat);
|
|
241
|
+
} else {
|
|
242
|
+
throw err;
|
|
168
243
|
}
|
|
169
|
-
throw err;
|
|
170
244
|
}
|
|
171
245
|
|
|
172
246
|
if (data.code === 99991668 || data.code === 99991663 || data.code === 99991677) {
|
|
@@ -242,4 +316,9 @@ module.exports = {
|
|
|
242
316
|
asUserOrApp,
|
|
243
317
|
persistUAT,
|
|
244
318
|
adoptPersistedUATIfNewer,
|
|
319
|
+
// v1.3.14 — exposed for testing (lifecycle + race tests). Not part of the
|
|
320
|
+
// stable API; do not use outside src/test-* or scripts/test-* harnesses.
|
|
321
|
+
uatLockPath,
|
|
322
|
+
acquireRefreshLock,
|
|
323
|
+
releaseRefreshLock,
|
|
245
324
|
};
|
package/src/cli.js
CHANGED
|
@@ -248,9 +248,10 @@ async function keepalive() {
|
|
|
248
248
|
const needSwitch = all && prevActive !== profileName;
|
|
249
249
|
try {
|
|
250
250
|
if (needSwitch) cred.setActiveProfile(profileName);
|
|
251
|
-
//
|
|
252
|
-
process.env
|
|
253
|
-
process.env
|
|
251
|
+
// v1.3.14 — direct field assignment is the source of truth; do NOT
|
|
252
|
+
// also set process.env (previous comment claimed LarkOfficialClient
|
|
253
|
+
// would read process.env, but loadUAT() is dead code and process.env
|
|
254
|
+
// pollution leaked between iterations of the --all loop).
|
|
254
255
|
const official = new LarkOfficialClient(env.LARK_APP_ID, env.LARK_APP_SECRET);
|
|
255
256
|
official._uat = env.LARK_USER_ACCESS_TOKEN;
|
|
256
257
|
official._uatRefresh = env.LARK_USER_REFRESH_TOKEN;
|
|
@@ -21,16 +21,21 @@ class LarkOfficialClient {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
// --- UAT (User Access Token) Management ---
|
|
24
|
+
//
|
|
25
|
+
// v1.3.14 — preferred entry is src/server.js::loadUATFromEnv(client, env),
|
|
26
|
+
// which reads from a specific env block (credentials.json profile or harness
|
|
27
|
+
// env) rather than from process.env. `loadUAT()` below is kept for backward
|
|
28
|
+
// compat with `src/test-all.js` and any external callers, but new code in
|
|
29
|
+
// this repo should NOT use it — it can't see credentials.json profiles and
|
|
30
|
+
// doesn't participate in the profile-switch hot-reload path.
|
|
24
31
|
|
|
32
|
+
/** @deprecated v1.3.14 — use server.loadUATFromEnv(client, env) instead. */
|
|
25
33
|
loadUAT() {
|
|
26
34
|
const token = process.env.LARK_USER_ACCESS_TOKEN;
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
this._uatRefresh = refresh || null;
|
|
32
|
-
this._uatExpires = expires || this._decodeTokenExpiry(token);
|
|
33
|
-
}
|
|
35
|
+
if (!token) return;
|
|
36
|
+
this._uat = token;
|
|
37
|
+
this._uatRefresh = process.env.LARK_USER_REFRESH_TOKEN || null;
|
|
38
|
+
this._uatExpires = parseInt(process.env.LARK_UAT_EXPIRES || '0') || this._decodeTokenExpiry(token);
|
|
34
39
|
}
|
|
35
40
|
|
|
36
41
|
get hasUAT() {
|
|
@@ -199,11 +204,36 @@ class LarkOfficialClient {
|
|
|
199
204
|
// dispatching N redundant API calls per read_messages on hot chats.
|
|
200
205
|
// has(id)==true / get(id)==null lets _computeDisplayLabel fall back to
|
|
201
206
|
// "(open_id)" exactly the same way as before.
|
|
207
|
+
//
|
|
208
|
+
// PR #103 Copilot followup: getUserById / getAppName return null on
|
|
209
|
+
// non-zero Feishu codes (e.g. 99991672, scope missing) WITHOUT rejecting,
|
|
210
|
+
// so the per-batch Promise.allSettled rejection log misses these. Log
|
|
211
|
+
// the ids that ended without a name as a separate stderr line so failure
|
|
212
|
+
// shape is observable regardless of whether the underlying lookup
|
|
213
|
+
// returned null or rejected.
|
|
214
|
+
const unresolvedUserIds = [];
|
|
202
215
|
for (const id of unknownUserIds) {
|
|
203
|
-
if (!this._userNameCache.has(id)
|
|
216
|
+
if (!this._userNameCache.has(id) || this._userNameCache.get(id) === null) {
|
|
217
|
+
this._userNameCache.set(id, null);
|
|
218
|
+
unresolvedUserIds.push(id);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (unresolvedUserIds.length) {
|
|
222
|
+
const sample = unresolvedUserIds.slice(0, 5).join(', ');
|
|
223
|
+
const tail = unresolvedUserIds.length > 5 ? ` (+${unresolvedUserIds.length - 5} more)` : '';
|
|
224
|
+
console.error(`[feishu-user-plugin] sender name unresolved (cached null) for ${unresolvedUserIds.length} id(s): ${sample}${tail}`);
|
|
204
225
|
}
|
|
226
|
+
const unresolvedAppIds = [];
|
|
205
227
|
for (const id of unknownAppIds) {
|
|
206
|
-
if (!this._appNameCache.has(id)
|
|
228
|
+
if (!this._appNameCache.has(id) || this._appNameCache.get(id) === null) {
|
|
229
|
+
this._appNameCache.set(id, null);
|
|
230
|
+
unresolvedAppIds.push(id);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
if (unresolvedAppIds.length) {
|
|
234
|
+
const sample = unresolvedAppIds.slice(0, 5).join(', ');
|
|
235
|
+
const tail = unresolvedAppIds.length > 5 ? ` (+${unresolvedAppIds.length - 5} more)` : '';
|
|
236
|
+
console.error(`[feishu-user-plugin] app name unresolved (cached null) for ${unresolvedAppIds.length} id(s): ${sample}${tail}`);
|
|
207
237
|
}
|
|
208
238
|
|
|
209
239
|
// Step 4: populate senderName, isExternal, displayLabel
|
package/src/error-codes.js
CHANGED
|
@@ -27,7 +27,8 @@ const FAILURE_MAP = {
|
|
|
27
27
|
19001: { action: 'uat', reason: 'bot_chat_not_found' },
|
|
28
28
|
|
|
29
29
|
// UAT revoked — refresh_token explicitly invalid_grant (user revoked OAuth
|
|
30
|
-
// or
|
|
30
|
+
// or the 7-day refresh_token window elapsed without any successful refresh
|
|
31
|
+
// to roll it forward). The live trigger for this code lives in
|
|
31
32
|
// identity-state.js::_classifyUatFailure (UAT REST throws / returns 20064);
|
|
32
33
|
// this entry exists for *symmetry* — should a bot-side surface ever return
|
|
33
34
|
// 20064 (it shouldn't, bot uses app_access_token not refresh_token), the
|
package/src/oauth.js
CHANGED
|
@@ -16,6 +16,7 @@ const http = require('http');
|
|
|
16
16
|
const { execSync } = require('child_process');
|
|
17
17
|
const credentialsModule = require('./auth/credentials');
|
|
18
18
|
const legacyConfig = require('./config');
|
|
19
|
+
const { fetchWithTimeout } = require('./utils');
|
|
19
20
|
|
|
20
21
|
// v1.3.9: profile-aware. Accepts `--profile <name>` (defaults to credentials.json::active);
|
|
21
22
|
// reads APP_ID/SECRET from that profile, persists UAT back to that profile.
|
|
@@ -105,10 +106,11 @@ if (!APP_ID || !APP_SECRET) {
|
|
|
105
106
|
async function getAppInfo() {
|
|
106
107
|
try {
|
|
107
108
|
// Get app_access_token to query app details
|
|
108
|
-
const tokenRes = await
|
|
109
|
+
const tokenRes = await fetchWithTimeout('https://open.feishu.cn/open-apis/auth/v3/app_access_token/internal', {
|
|
109
110
|
method: 'POST',
|
|
110
111
|
headers: { 'content-type': 'application/json' },
|
|
111
112
|
body: JSON.stringify({ app_id: APP_ID, app_secret: APP_SECRET }),
|
|
113
|
+
timeoutMs: 10000,
|
|
112
114
|
});
|
|
113
115
|
const tokenData = await tokenRes.json();
|
|
114
116
|
if (!tokenData.app_access_token) {
|
|
@@ -118,8 +120,9 @@ async function getAppInfo() {
|
|
|
118
120
|
|
|
119
121
|
// Get app info — try the direct app query first, fall back to underauditlist
|
|
120
122
|
let appName = null;
|
|
121
|
-
const directRes = await
|
|
123
|
+
const directRes = await fetchWithTimeout(`https://open.feishu.cn/open-apis/application/v6/applications/${APP_ID}?lang=zh_cn`, {
|
|
122
124
|
headers: { 'Authorization': `Bearer ${tokenData.app_access_token}` },
|
|
125
|
+
timeoutMs: 10000,
|
|
123
126
|
});
|
|
124
127
|
const directData = await directRes.json();
|
|
125
128
|
appName = directData?.data?.app?.app_name;
|
|
@@ -134,8 +137,9 @@ async function getAppInfo() {
|
|
|
134
137
|
} else if (directData?.code && directData.code !== 0) {
|
|
135
138
|
console.error(`[oauth] App name resolve failed: code=${directData.code} msg=${directData.msg}`);
|
|
136
139
|
}
|
|
137
|
-
const listRes = await
|
|
140
|
+
const listRes = await fetchWithTimeout('https://open.feishu.cn/open-apis/application/v6/applications/underauditlist?lang=zh_cn&page_size=1', {
|
|
138
141
|
headers: { 'Authorization': `Bearer ${tokenData.app_access_token}` },
|
|
142
|
+
timeoutMs: 10000,
|
|
139
143
|
});
|
|
140
144
|
const listData = await listRes.json();
|
|
141
145
|
appName = listData?.data?.items?.[0]?.app_name;
|
|
@@ -156,17 +160,27 @@ async function exchangeCode(code) {
|
|
|
156
160
|
code,
|
|
157
161
|
redirect_uri: REDIRECT_URI,
|
|
158
162
|
};
|
|
159
|
-
|
|
160
|
-
|
|
163
|
+
// v1.3.14 — redact `code` too: the authorization code is short-lived but
|
|
164
|
+
// it IS an exchangeable credential while it's still valid (~60s pre-exchange).
|
|
165
|
+
// Logging it to stdout means transcripts retain a usable credential window.
|
|
166
|
+
console.log('Token exchange request:', JSON.stringify({ ...body, client_secret: '***', code: '***' }));
|
|
167
|
+
const tokenRes = await fetchWithTimeout('https://open.feishu.cn/open-apis/authen/v2/oauth/token', {
|
|
161
168
|
method: 'POST',
|
|
162
169
|
headers: { 'content-type': 'application/json' },
|
|
163
170
|
body: JSON.stringify(body),
|
|
171
|
+
timeoutMs: 15000,
|
|
164
172
|
});
|
|
165
173
|
const raw = await tokenRes.text();
|
|
166
|
-
|
|
174
|
+
// v1.3.13 security followup: don't log the full raw body — it contains the
|
|
175
|
+
// bare access_token + refresh_token. Log only the http status and a hint of
|
|
176
|
+
// success/failure; the parsed token never leaves this function except via
|
|
177
|
+
// saveToken (which writes the file with 0600 perms).
|
|
178
|
+
console.log(`Token exchange HTTP ${tokenRes.status} (body ${raw.length} bytes)`);
|
|
167
179
|
let tokenData;
|
|
168
180
|
try { tokenData = JSON.parse(raw); } catch (e) {
|
|
169
|
-
|
|
181
|
+
// Parse error path: redact body in the thrown message so an upstream
|
|
182
|
+
// log line doesn't accidentally surface tokens.
|
|
183
|
+
throw new Error(`Response not JSON (HTTP ${tokenRes.status}, ${raw.length} bytes): ${raw.slice(0, 100).replace(/[A-Za-z0-9._-]{40,}/g, '<redacted>')}`);
|
|
170
184
|
}
|
|
171
185
|
if (tokenData.error) {
|
|
172
186
|
throw new Error(`${tokenData.error}: ${tokenData.error_description}`);
|
|
@@ -199,8 +213,17 @@ function saveToken(tokenData) {
|
|
|
199
213
|
if (ok) console.log(`Tokens written to ${profileLabel}`);
|
|
200
214
|
}
|
|
201
215
|
if (!ok) {
|
|
202
|
-
|
|
203
|
-
|
|
216
|
+
// v1.3.13 security followup: never dump full token bytes to stderr.
|
|
217
|
+
// Caller can find them by re-running OAuth or reading the credentials
|
|
218
|
+
// file. Show only the field shape so user knows what fields exist.
|
|
219
|
+
console.error('WARNING: Tokens could not be saved automatically. Re-run `npx feishu-user-plugin oauth` after fixing the config path, or check that `~/.feishu-user-plugin/credentials.json` is writable.');
|
|
220
|
+
console.error('Fields that would have been written (values redacted):');
|
|
221
|
+
for (const [k, v] of Object.entries(updates)) {
|
|
222
|
+
const preview = typeof v === 'string' && v.length > 0
|
|
223
|
+
? `${v.slice(0, 6)}…(${v.length} chars)`
|
|
224
|
+
: '<empty>';
|
|
225
|
+
console.error(` ${k}=${preview}`);
|
|
226
|
+
}
|
|
204
227
|
}
|
|
205
228
|
}
|
|
206
229
|
|
|
@@ -221,11 +244,15 @@ const server = http.createServer(async (req, res) => {
|
|
|
221
244
|
|
|
222
245
|
const hasRefresh = !!tokenData.refresh_token;
|
|
223
246
|
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
|
247
|
+
// v1.3.14 — do not display any token bytes in the browser callback.
|
|
248
|
+
// Browser history / screenshots / OS screen recordings would otherwise
|
|
249
|
+
// retain the first 20 chars of the access token. Replaced with a length
|
|
250
|
+
// attestation so the user still sees the flow succeeded.
|
|
224
251
|
res.end(`<h2>✅ 授权成功!</h2>
|
|
225
|
-
<p>access_token:
|
|
252
|
+
<p>access_token: ✅ 已获取(${tokenData.access_token.length} chars)</p>
|
|
226
253
|
<p>scope: ${tokenData.scope}</p>
|
|
227
254
|
<p>expires_in: ${tokenData.expires_in}s</p>
|
|
228
|
-
<p>refresh_token: ${hasRefresh ? '✅ 已获取(
|
|
255
|
+
<p>refresh_token: ${hasRefresh ? '✅ 已获取(7天有效,支持自动续期;每次 refresh 滚动续 7 天)' : '❌ 未返回(token 将在 2 小时后过期,需重新授权)'}</p>
|
|
229
256
|
<p>已保存到 MCP 配置文件,可以关闭此页面。</p>`);
|
|
230
257
|
|
|
231
258
|
console.log('\n=== OAuth 授权成功 ===');
|
package/src/server.js
CHANGED
|
@@ -83,8 +83,15 @@ let nonOwnerPollTimer = null;
|
|
|
83
83
|
let _ownerStartCallbacks = [];
|
|
84
84
|
|
|
85
85
|
// Lark Desktop reactor state (v1.3.11 §A) — owned by the heartbeat callback.
|
|
86
|
+
// v1.3.14 — `_lastSwitchAt = 0` was previously misinterpreted as "no recent
|
|
87
|
+
// switch" by the lark-desktop reactor's debounce, so a cold-start owner that
|
|
88
|
+
// hadn't observed any switch would still fire-on-first-tick if the snapshot
|
|
89
|
+
// looked stale. We rebaseline `_lastSwitchAt` to the owner-claim wallclock on
|
|
90
|
+
// first reactor invocation so the cold start window has the same debounce
|
|
91
|
+
// budget as a long-running owner. See `_runLarkDesktopReactor` below.
|
|
86
92
|
let _lastHashMtimes = {};
|
|
87
93
|
let _lastSwitchAt = 0;
|
|
94
|
+
let _reactorFirstTickDone = false;
|
|
88
95
|
const _seenUnboundHashes = new Set();
|
|
89
96
|
|
|
90
97
|
function _onBecomeOwner(cb) { _ownerStartCallbacks.push(cb); }
|
|
@@ -157,11 +164,14 @@ function getOfficialClient() {
|
|
|
157
164
|
return officialClient;
|
|
158
165
|
}
|
|
159
166
|
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
//
|
|
167
|
+
// UAT loader sourced from a specific env block (credentials.json profile or
|
|
168
|
+
// harness env). Used both at startup and as the hot-reload entry point from
|
|
169
|
+
// credMonitor.onUatChange. When `env` has no UAT (user nuked the token via
|
|
170
|
+
// `oauth` --revoke or manual edit), clears the in-memory copy instead of
|
|
164
171
|
// silently leaving the stale token in place.
|
|
172
|
+
//
|
|
173
|
+
// v1.3.14 — replaced the dead `LarkOfficialClient.loadUAT()` helper that
|
|
174
|
+
// read from process.env. All UAT loading now goes through this function.
|
|
165
175
|
function loadUATFromEnv(client, env) {
|
|
166
176
|
const token = env?.LARK_USER_ACCESS_TOKEN || null;
|
|
167
177
|
const refresh = env?.LARK_USER_REFRESH_TOKEN || null;
|
|
@@ -274,6 +284,14 @@ function _credMtime() {
|
|
|
274
284
|
// the WS client with the new profile's events list).
|
|
275
285
|
function _runLarkDesktopReactor() {
|
|
276
286
|
const ld = require('./auth/lark-desktop');
|
|
287
|
+
// v1.3.14 — cold-start debounce: prevent the first reactor tick from
|
|
288
|
+
// treating a long-pre-existing snapshot as a "recent switch". By stamping
|
|
289
|
+
// _lastSwitchAt at first tick we give the debounce the same baseline as a
|
|
290
|
+
// long-running owner that just hot-took-over.
|
|
291
|
+
if (!_reactorFirstTickDone) {
|
|
292
|
+
_reactorFirstTickDone = true;
|
|
293
|
+
if (_lastSwitchAt === 0) _lastSwitchAt = Date.now();
|
|
294
|
+
}
|
|
277
295
|
const out = ld.detectSwitch({
|
|
278
296
|
prevSnapshot: _lastHashMtimes,
|
|
279
297
|
lastSwitchAt: _lastSwitchAt,
|
|
@@ -332,6 +350,17 @@ credMonitor.onUatChange((env) => {
|
|
|
332
350
|
console.error('[feishu-user-plugin] UAT reloaded from credentials.json (no restart needed)');
|
|
333
351
|
});
|
|
334
352
|
|
|
353
|
+
credMonitor.onCookieChange(() => {
|
|
354
|
+
// Cookie rotation: null the LarkUserClient singleton so the next
|
|
355
|
+
// getUserClient() call rebuilds it with the fresh cookie from env.
|
|
356
|
+
// Without this, cookie-based tools (send_to_user / search_contacts /
|
|
357
|
+
// get_login_status / send_as_user / batch_send) keep using the stale
|
|
358
|
+
// cookie until restart. PR #103 Codex P2 followup.
|
|
359
|
+
if (!userClient) return;
|
|
360
|
+
userClient = null;
|
|
361
|
+
console.error('[feishu-user-plugin] cookie rotation detected — userClient nulled, rebuilds on next tool call');
|
|
362
|
+
});
|
|
363
|
+
|
|
335
364
|
credMonitor.onCacheInvalidate(() => {
|
|
336
365
|
if (officialClient) identityState.invalidateIdentity(officialClient);
|
|
337
366
|
});
|
|
@@ -568,6 +597,11 @@ async function main() {
|
|
|
568
597
|
}
|
|
569
598
|
}
|
|
570
599
|
|
|
600
|
+
// Baseline credMonitor at startup so any credential changes between server
|
|
601
|
+
// boot and the first tool call fire hooks instead of being silently absorbed
|
|
602
|
+
// by the first sync()'s baselining branch. PR #103 Codex P2 followup.
|
|
603
|
+
credMonitor.sync();
|
|
604
|
+
|
|
571
605
|
// --- Real-time events (v1.3.9 — owner-arbitrated) ---
|
|
572
606
|
if (hasApp) {
|
|
573
607
|
_claimAndStart().catch((e) => {
|
package/src/setup.js
CHANGED
|
@@ -102,10 +102,12 @@ async function main() {
|
|
|
102
102
|
// Validate app credentials
|
|
103
103
|
console.log('\nValidating app credentials...');
|
|
104
104
|
try {
|
|
105
|
-
const
|
|
105
|
+
const { fetchWithTimeout } = require('./utils');
|
|
106
|
+
const res = await fetchWithTimeout('https://open.feishu.cn/open-apis/auth/v3/app_access_token/internal', {
|
|
106
107
|
method: 'POST',
|
|
107
108
|
headers: { 'content-type': 'application/json' },
|
|
108
109
|
body: JSON.stringify({ app_id: appId, app_secret: appSecret }),
|
|
110
|
+
timeoutMs: 10000,
|
|
109
111
|
});
|
|
110
112
|
const data = await res.json();
|
|
111
113
|
if (data.app_access_token) {
|
package/src/test-all.js
CHANGED
|
@@ -4,6 +4,12 @@
|
|
|
4
4
|
* Sends test messages to "飞书plugin测试群".
|
|
5
5
|
*/
|
|
6
6
|
require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
|
|
7
|
+
|
|
8
|
+
// v1.3.14 — backfill process.env from canonical credentials store so
|
|
9
|
+
// `npm test` works for users who moved creds to ~/.feishu-user-plugin/.
|
|
10
|
+
// See src/auth/env-backfill.js for full rationale.
|
|
11
|
+
require('./auth/env-backfill').backfillFromCanonical();
|
|
12
|
+
|
|
7
13
|
const { LarkUserClient } = require('./clients/user');
|
|
8
14
|
const { LarkOfficialClient } = require('./clients/official');
|
|
9
15
|
|
|
@@ -333,6 +339,11 @@ main().catch(console.error).finally(() => {
|
|
|
333
339
|
console.error('with-uat-retry: FAIL', e);
|
|
334
340
|
process.exitCode = 1;
|
|
335
341
|
});
|
|
342
|
+
require('./test-uat-lifecycle').run().catch(e => {
|
|
343
|
+
console.error('uat-lifecycle: FAIL', e);
|
|
344
|
+
process.exitCode = 1;
|
|
345
|
+
});
|
|
346
|
+
require('./test-cookie-heartbeat').run();
|
|
336
347
|
require('./test-populate-sender-names').run().catch(e => {
|
|
337
348
|
console.error('populate-sender-names: FAIL', e);
|
|
338
349
|
process.exitCode = 1;
|
|
@@ -363,5 +374,6 @@ main().catch(console.error).finally(() => {
|
|
|
363
374
|
process.exitCode = 1;
|
|
364
375
|
});
|
|
365
376
|
require('./test-cli-tool').run();
|
|
377
|
+
require('./test-lark-desktop').run();
|
|
366
378
|
require('./test-display-label'); // standalone — runs on require, exits non-zero on fail
|
|
367
379
|
});
|
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
3
|
* Comprehensive test: exercises every tool category in feishu-user-plugin.
|
|
4
|
-
* Reads credentials from .env,
|
|
4
|
+
* Reads credentials from .env, then backfills from canonical store. Tests
|
|
5
|
+
* each layer independently.
|
|
5
6
|
*/
|
|
6
7
|
const path = require('path');
|
|
7
8
|
require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
|
|
9
|
+
// v1.3.14 — let users on canonical store run this without exporting env vars
|
|
10
|
+
// or maintaining a stale .env. .env still wins via dotenv when present.
|
|
11
|
+
require('./auth/env-backfill').backfillFromCanonical();
|
|
8
12
|
|
|
9
13
|
const { LarkUserClient } = require('./clients/user');
|
|
10
14
|
const { LarkOfficialClient } = require('./clients/official');
|
|
@@ -274,7 +278,8 @@ async function testUAT() {
|
|
|
274
278
|
}
|
|
275
279
|
|
|
276
280
|
async function main() {
|
|
277
|
-
|
|
281
|
+
const pkgVersion = require('../package.json').version;
|
|
282
|
+
console.log(`=== feishu-user-plugin v${pkgVersion} — Comprehensive Test ===\n`);
|
|
278
283
|
|
|
279
284
|
await testUserIdentity();
|
|
280
285
|
console.log('');
|