agent-messenger 2.9.0 → 2.10.1

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.
Files changed (118) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/dist/package.json +1 -1
  3. package/dist/src/platforms/teams/client.d.ts +9 -1
  4. package/dist/src/platforms/teams/client.d.ts.map +1 -1
  5. package/dist/src/platforms/teams/client.js +69 -18
  6. package/dist/src/platforms/teams/client.js.map +1 -1
  7. package/dist/src/platforms/teams/commands/auth.d.ts.map +1 -1
  8. package/dist/src/platforms/teams/commands/auth.js +7 -2
  9. package/dist/src/platforms/teams/commands/auth.js.map +1 -1
  10. package/dist/src/platforms/teams/commands/channel.d.ts.map +1 -1
  11. package/dist/src/platforms/teams/commands/channel.js +18 -3
  12. package/dist/src/platforms/teams/commands/channel.js.map +1 -1
  13. package/dist/src/platforms/teams/commands/file.d.ts.map +1 -1
  14. package/dist/src/platforms/teams/commands/file.js +18 -3
  15. package/dist/src/platforms/teams/commands/file.js.map +1 -1
  16. package/dist/src/platforms/teams/commands/message.d.ts.map +1 -1
  17. package/dist/src/platforms/teams/commands/message.js +24 -4
  18. package/dist/src/platforms/teams/commands/message.js.map +1 -1
  19. package/dist/src/platforms/teams/commands/reaction.d.ts.map +1 -1
  20. package/dist/src/platforms/teams/commands/reaction.js +12 -2
  21. package/dist/src/platforms/teams/commands/reaction.js.map +1 -1
  22. package/dist/src/platforms/teams/commands/snapshot.d.ts.map +1 -1
  23. package/dist/src/platforms/teams/commands/snapshot.js +6 -1
  24. package/dist/src/platforms/teams/commands/snapshot.js.map +1 -1
  25. package/dist/src/platforms/teams/commands/team.d.ts.map +1 -1
  26. package/dist/src/platforms/teams/commands/team.js +6 -1
  27. package/dist/src/platforms/teams/commands/team.js.map +1 -1
  28. package/dist/src/platforms/teams/commands/user.d.ts.map +1 -1
  29. package/dist/src/platforms/teams/commands/user.js +18 -3
  30. package/dist/src/platforms/teams/commands/user.js.map +1 -1
  31. package/dist/src/platforms/teams/commands/whoami.d.ts.map +1 -1
  32. package/dist/src/platforms/teams/commands/whoami.js +6 -1
  33. package/dist/src/platforms/teams/commands/whoami.js.map +1 -1
  34. package/dist/src/platforms/teams/credential-manager.d.ts +3 -1
  35. package/dist/src/platforms/teams/credential-manager.d.ts.map +1 -1
  36. package/dist/src/platforms/teams/credential-manager.js +6 -1
  37. package/dist/src/platforms/teams/credential-manager.js.map +1 -1
  38. package/dist/src/platforms/teams/ensure-auth.d.ts.map +1 -1
  39. package/dist/src/platforms/teams/ensure-auth.js +7 -2
  40. package/dist/src/platforms/teams/ensure-auth.js.map +1 -1
  41. package/dist/src/platforms/teams/token-extractor.d.ts +3 -1
  42. package/dist/src/platforms/teams/token-extractor.d.ts.map +1 -1
  43. package/dist/src/platforms/teams/token-extractor.js +73 -10
  44. package/dist/src/platforms/teams/token-extractor.js.map +1 -1
  45. package/dist/src/platforms/teams/types.d.ts +17 -0
  46. package/dist/src/platforms/teams/types.d.ts.map +1 -1
  47. package/dist/src/platforms/teams/types.js +2 -0
  48. package/dist/src/platforms/teams/types.js.map +1 -1
  49. package/dist/src/platforms/webex/client.d.ts +3 -0
  50. package/dist/src/platforms/webex/client.d.ts.map +1 -1
  51. package/dist/src/platforms/webex/client.js +58 -13
  52. package/dist/src/platforms/webex/client.js.map +1 -1
  53. package/dist/src/platforms/webex/commands/auth.d.ts.map +1 -1
  54. package/dist/src/platforms/webex/commands/auth.js +61 -10
  55. package/dist/src/platforms/webex/commands/auth.js.map +1 -1
  56. package/dist/src/platforms/webex/credential-manager.d.ts.map +1 -1
  57. package/dist/src/platforms/webex/credential-manager.js +18 -6
  58. package/dist/src/platforms/webex/credential-manager.js.map +1 -1
  59. package/dist/src/platforms/webex/encryption.d.ts.map +1 -1
  60. package/dist/src/platforms/webex/encryption.js +3 -1
  61. package/dist/src/platforms/webex/encryption.js.map +1 -1
  62. package/dist/src/platforms/webex/ensure-auth.d.ts.map +1 -1
  63. package/dist/src/platforms/webex/ensure-auth.js +10 -2
  64. package/dist/src/platforms/webex/ensure-auth.js.map +1 -1
  65. package/dist/src/platforms/webex/token-extractor.d.ts +1 -0
  66. package/dist/src/platforms/webex/token-extractor.d.ts.map +1 -1
  67. package/dist/src/platforms/webex/token-extractor.js +21 -4
  68. package/dist/src/platforms/webex/token-extractor.js.map +1 -1
  69. package/e2e/webex.e2e.test.ts +57 -0
  70. package/package.json +1 -1
  71. package/skills/agent-channeltalk/SKILL.md +1 -1
  72. package/skills/agent-channeltalkbot/SKILL.md +1 -1
  73. package/skills/agent-discord/SKILL.md +1 -1
  74. package/skills/agent-discordbot/SKILL.md +1 -1
  75. package/skills/agent-instagram/SKILL.md +1 -1
  76. package/skills/agent-kakaotalk/SKILL.md +1 -1
  77. package/skills/agent-line/SKILL.md +1 -1
  78. package/skills/agent-slack/SKILL.md +1 -1
  79. package/skills/agent-slackbot/SKILL.md +1 -1
  80. package/skills/agent-teams/SKILL.md +1 -1
  81. package/skills/agent-telegram/SKILL.md +1 -1
  82. package/skills/agent-webex/SKILL.md +1 -1
  83. package/skills/agent-wechatbot/SKILL.md +1 -1
  84. package/skills/agent-whatsapp/SKILL.md +1 -1
  85. package/skills/agent-whatsappbot/SKILL.md +1 -1
  86. package/src/platforms/teams/client.test.ts +34 -30
  87. package/src/platforms/teams/client.ts +92 -20
  88. package/src/platforms/teams/commands/auth.test.ts +6 -2
  89. package/src/platforms/teams/commands/auth.ts +7 -2
  90. package/src/platforms/teams/commands/channel.test.ts +6 -6
  91. package/src/platforms/teams/commands/channel.ts +18 -3
  92. package/src/platforms/teams/commands/file.ts +18 -3
  93. package/src/platforms/teams/commands/message.ts +24 -4
  94. package/src/platforms/teams/commands/reaction.ts +12 -2
  95. package/src/platforms/teams/commands/snapshot.ts +6 -1
  96. package/src/platforms/teams/commands/team.test.ts +2 -2
  97. package/src/platforms/teams/commands/team.ts +6 -1
  98. package/src/platforms/teams/commands/user.ts +18 -3
  99. package/src/platforms/teams/commands/whoami.ts +6 -1
  100. package/src/platforms/teams/credential-manager.test.ts +25 -0
  101. package/src/platforms/teams/credential-manager.ts +13 -3
  102. package/src/platforms/teams/ensure-auth.test.ts +6 -1
  103. package/src/platforms/teams/ensure-auth.ts +7 -2
  104. package/src/platforms/teams/token-extractor.test.ts +112 -98
  105. package/src/platforms/teams/token-extractor.ts +83 -12
  106. package/src/platforms/teams/types.test.ts +17 -0
  107. package/src/platforms/teams/types.ts +6 -0
  108. package/src/platforms/webex/client.test.ts +157 -13
  109. package/src/platforms/webex/client.ts +64 -15
  110. package/src/platforms/webex/commands/auth.test.ts +122 -1
  111. package/src/platforms/webex/commands/auth.ts +72 -17
  112. package/src/platforms/webex/credential-manager.test.ts +63 -0
  113. package/src/platforms/webex/credential-manager.ts +22 -8
  114. package/src/platforms/webex/encryption.test.ts +54 -0
  115. package/src/platforms/webex/encryption.ts +3 -1
  116. package/src/platforms/webex/ensure-auth.ts +10 -2
  117. package/src/platforms/webex/token-extractor.test.ts +32 -3
  118. package/src/platforms/webex/token-extractor.ts +26 -5
@@ -3,7 +3,9 @@ import * as childProcess from 'node:child_process'
3
3
 
4
4
  import { WebexClient } from '../client'
5
5
  import { WebexCredentialManager } from '../credential-manager'
6
- import { loginAction, logoutAction, statusAction } from './auth'
6
+ import { WebexTokenExtractor } from '../token-extractor'
7
+ import { WebexError } from '../types'
8
+ import { extractAction, loginAction, logoutAction, statusAction } from './auth'
7
9
 
8
10
  describe('auth commands', () => {
9
11
  let consoleSpy: ReturnType<typeof spyOn>
@@ -208,6 +210,125 @@ describe('auth commands', () => {
208
210
  })
209
211
  })
210
212
 
213
+ describe('extractAction', () => {
214
+ test('passes deviceUrl and tokenType to client.login', async () => {
215
+ protoSpy(WebexTokenExtractor.prototype, 'extract').mockResolvedValue({
216
+ accessToken: 'extracted-token-at-least-twenty-chars',
217
+ refreshToken: 'refresh-token',
218
+ expiresAt: Date.now() + 3600000,
219
+ deviceUrl: 'https://wdm-r.wbx2.com/wdm/api/v1/devices/test-device-id',
220
+ userId: 'user-1',
221
+ })
222
+ const loginSpy = protoSpy(WebexClient.prototype, 'login').mockResolvedValue(new WebexClient())
223
+ protoSpy(WebexClient.prototype, 'testAuth').mockResolvedValue(mockPerson)
224
+ protoSpy(WebexCredentialManager.prototype, 'saveConfig').mockResolvedValue(undefined)
225
+
226
+ await extractAction({ pretty: false })
227
+
228
+ expect(loginSpy).toHaveBeenCalledWith({
229
+ token: 'extracted-token-at-least-twenty-chars',
230
+ deviceUrl: 'https://wdm-r.wbx2.com/wdm/api/v1/devices/test-device-id',
231
+ tokenType: 'extracted',
232
+ })
233
+ })
234
+
235
+ test('attempts refresh when token is expired', async () => {
236
+ protoSpy(WebexTokenExtractor.prototype, 'extract').mockResolvedValue({
237
+ accessToken: 'expired-token-at-least-twenty-chars-',
238
+ refreshToken: 'valid-refresh-token',
239
+ expiresAt: Date.now() - 7200000,
240
+ })
241
+ const refreshSpy = protoSpy(WebexCredentialManager.prototype, 'refreshToken').mockResolvedValue({
242
+ accessToken: 'refreshed-token-at-least-twenty-ch',
243
+ refreshToken: 'new-refresh',
244
+ expiresAt: Date.now() + 3600000,
245
+ })
246
+ const loginSpy = protoSpy(WebexClient.prototype, 'login').mockResolvedValue(new WebexClient())
247
+ protoSpy(WebexClient.prototype, 'testAuth').mockResolvedValue(mockPerson)
248
+ protoSpy(WebexCredentialManager.prototype, 'saveConfig').mockResolvedValue(undefined)
249
+
250
+ await extractAction({ pretty: false })
251
+
252
+ expect(refreshSpy).toHaveBeenCalled()
253
+ expect(loginSpy).toHaveBeenCalledWith(expect.objectContaining({ token: 'refreshed-token-at-least-twenty-ch' }))
254
+ const lastCall = consoleSpy.mock.calls[consoleSpy.mock.calls.length - 1][0] as string
255
+ const output = JSON.parse(lastCall)
256
+ expect(output.authenticated).toBe(true)
257
+ expect(output.refreshed).toBe(true)
258
+ })
259
+
260
+ test('reports expired token with actionable hint when refresh fails', async () => {
261
+ protoSpy(WebexTokenExtractor.prototype, 'extract').mockResolvedValue({
262
+ accessToken: 'expired-token-at-least-twenty-chars-',
263
+ refreshToken: 'bad-refresh-token',
264
+ expiresAt: Date.now() - 7200000,
265
+ })
266
+ protoSpy(WebexCredentialManager.prototype, 'refreshToken').mockResolvedValue(null)
267
+ protoSpy(WebexClient.prototype, 'login').mockResolvedValue(new WebexClient())
268
+ protoSpy(WebexClient.prototype, 'testAuth').mockRejectedValue(new WebexError('Unauthorized', 'http_401'))
269
+ const exitSpy = protoSpy(process, 'exit').mockImplementation(() => undefined as never)
270
+
271
+ await extractAction({ pretty: false })
272
+
273
+ const lastCall = consoleSpy.mock.calls[consoleSpy.mock.calls.length - 1][0] as string
274
+ const output = JSON.parse(lastCall)
275
+ expect(output.error).toContain('expired')
276
+ expect(output.hint).toContain('web.webex.com')
277
+ expect(output.hint).toContain('not webex.com')
278
+ expect(exitSpy).toHaveBeenCalledWith(1)
279
+ })
280
+
281
+ test('rethrows non-auth errors even when token is expired', async () => {
282
+ protoSpy(WebexTokenExtractor.prototype, 'extract').mockResolvedValue({
283
+ accessToken: 'expired-token-at-least-twenty-chars-',
284
+ refreshToken: 'bad-refresh-token',
285
+ expiresAt: Date.now() - 7200000,
286
+ })
287
+ protoSpy(WebexCredentialManager.prototype, 'refreshToken').mockResolvedValue(null)
288
+ protoSpy(WebexClient.prototype, 'login').mockResolvedValue(new WebexClient())
289
+ protoSpy(WebexClient.prototype, 'testAuth').mockRejectedValue(new Error('Network error'))
290
+ protoSpy(process, 'exit').mockImplementation(() => undefined as never)
291
+
292
+ await extractAction({ pretty: false })
293
+
294
+ const lastCall = consoleErrorSpy.mock.calls[consoleErrorSpy.mock.calls.length - 1]?.[0] as string | undefined
295
+ if (lastCall) {
296
+ const output = JSON.parse(lastCall)
297
+ expect(output.error).toContain('Network error')
298
+ }
299
+ })
300
+
301
+ test('rethrows non-expiry auth errors', async () => {
302
+ protoSpy(WebexTokenExtractor.prototype, 'extract').mockResolvedValue({
303
+ accessToken: 'valid-token-at-least-twenty-chars-xx',
304
+ expiresAt: Date.now() + 3600000,
305
+ })
306
+ protoSpy(WebexClient.prototype, 'login').mockResolvedValue(new WebexClient())
307
+ protoSpy(WebexClient.prototype, 'testAuth').mockRejectedValue(new Error('Network error'))
308
+ protoSpy(process, 'exit').mockImplementation(() => undefined as never)
309
+
310
+ await extractAction({ pretty: false })
311
+
312
+ const lastCall = consoleErrorSpy.mock.calls[consoleErrorSpy.mock.calls.length - 1]?.[0] as string | undefined
313
+ if (lastCall) {
314
+ const output = JSON.parse(lastCall)
315
+ expect(output.error).toContain('Network error')
316
+ }
317
+ })
318
+
319
+ test('outputs no token found when extract returns null', async () => {
320
+ protoSpy(WebexTokenExtractor.prototype, 'extract').mockResolvedValue(null)
321
+ const exitSpy = protoSpy(process, 'exit').mockImplementation(() => undefined as never)
322
+
323
+ await extractAction({ pretty: false })
324
+
325
+ const lastCall = consoleSpy.mock.calls[consoleSpy.mock.calls.length - 1][0] as string
326
+ const output = JSON.parse(lastCall)
327
+ expect(output.error).toContain('No Webex token found')
328
+ expect(exitSpy).toHaveBeenCalledWith(1)
329
+ })
330
+ })
331
+
211
332
  describe('logoutAction', () => {
212
333
  test('clears credentials when authenticated', async () => {
213
334
  protoSpy(WebexCredentialManager.prototype, 'loadConfig').mockResolvedValue({
@@ -8,6 +8,7 @@ import { getWebexAppCredentials } from '../app-config'
8
8
  import { WebexClient } from '../client'
9
9
  import { WebexCredentialManager } from '../credential-manager'
10
10
  import { WebexTokenExtractor } from '../token-extractor'
11
+ import { WebexError } from '../types'
11
12
 
12
13
  interface ResolvedCredentials {
13
14
  clientId: string
@@ -142,7 +143,8 @@ export async function statusAction(options: { pretty?: boolean }): Promise<void>
142
143
 
143
144
  export async function extractAction(options: { pretty?: boolean; debug?: boolean }): Promise<void> {
144
145
  try {
145
- const extractor = new WebexTokenExtractor(undefined, options.debug ? (msg) => debug(`[debug] ${msg}`) : undefined)
146
+ const debugLog = options.debug ? (msg: string) => debug(`[debug] ${msg}`) : undefined
147
+ const extractor = new WebexTokenExtractor(undefined, debugLog)
146
148
 
147
149
  if (options.debug) {
148
150
  debug('[debug] Searching browser profiles for Webex tokens...')
@@ -155,7 +157,7 @@ export async function extractAction(options: { pretty?: boolean; debug?: boolean
155
157
  formatOutput(
156
158
  {
157
159
  error:
158
- 'No Webex token found in any browser. Make sure you are logged in to web.webex.com in Chrome, Edge, Arc, or Brave.',
160
+ 'No Webex token found in any browser. Make sure you are logged in at https://web.webex.com (not webex.com) in Chrome, Edge, Arc, or Brave.',
159
161
  hint: 'Run "auth login" for OAuth Device Grant flow, or --debug for more info.',
160
162
  },
161
163
  options.pretty,
@@ -165,30 +167,83 @@ export async function extractAction(options: { pretty?: boolean; debug?: boolean
165
167
  return
166
168
  }
167
169
 
168
- const client = await new WebexClient().login({ token: extracted.accessToken })
169
- const person = await client.testAuth()
170
+ const isExpired = extracted.expiresAt != null && extracted.expiresAt > 0 && extracted.expiresAt < Date.now()
171
+ if (isExpired && options.debug) {
172
+ const agoMs = Date.now() - extracted.expiresAt!
173
+ const agoHours = Math.round(agoMs / 3_600_000)
174
+ debugLog?.(`Token expired ${agoHours > 0 ? `${agoHours}h ago` : 'recently'}.`)
175
+ }
176
+
177
+ let activeToken = extracted.accessToken
178
+ let refreshedConfig: { accessToken: string; refreshToken: string; expiresAt: number } | null = null
179
+
180
+ if (isExpired && extracted.refreshToken) {
181
+ debugLog?.('Attempting token refresh...')
182
+ const credManager = new WebexCredentialManager()
183
+ const { clientId, clientSecret } = getWebexAppCredentials()
184
+ refreshedConfig = await credManager.refreshToken(extracted.refreshToken, clientId, clientSecret)
185
+ if (refreshedConfig) {
186
+ debugLog?.('Token refreshed successfully.')
187
+ activeToken = refreshedConfig.accessToken
188
+ } else {
189
+ debugLog?.('Token refresh failed. Will attempt validation with expired token.')
190
+ }
191
+ }
192
+
193
+ const client = await new WebexClient().login({
194
+ token: activeToken,
195
+ deviceUrl: extracted.deviceUrl,
196
+ tokenType: 'extracted',
197
+ })
198
+
199
+ let person: { id: string; displayName: string; emails: string[] } | null = null
200
+ try {
201
+ const result = await client.testAuth()
202
+ if (result.id) {
203
+ person = { id: result.id, displayName: result.displayName, emails: result.emails }
204
+ }
205
+ } catch (authError) {
206
+ const isAuthFailure =
207
+ authError instanceof WebexError && (authError.code === 'http_401' || authError.code === 'http_403')
208
+ if (isExpired && isAuthFailure) {
209
+ console.log(
210
+ formatOutput(
211
+ {
212
+ error: 'Extracted browser token is expired and could not be refreshed.',
213
+ hint: 'Log in at https://web.webex.com (not webex.com) in your browser, then run "auth extract" again. Or use "auth login" for OAuth Device Grant flow.',
214
+ },
215
+ options.pretty,
216
+ ),
217
+ )
218
+ process.exit(1)
219
+ return
220
+ }
221
+ throw authError
222
+ }
170
223
 
171
224
  const credManager = new WebexCredentialManager()
172
225
  await credManager.saveConfig({
173
- accessToken: extracted.accessToken,
174
- refreshToken: extracted.refreshToken ?? '',
175
- expiresAt: extracted.expiresAt ?? 0,
226
+ accessToken: activeToken,
227
+ refreshToken: refreshedConfig?.refreshToken ?? extracted.refreshToken ?? '',
228
+ expiresAt: refreshedConfig?.expiresAt ?? extracted.expiresAt ?? 0,
176
229
  tokenType: 'extracted',
177
230
  deviceUrl: extracted.deviceUrl,
178
231
  userId: extracted.userId,
179
232
  encryptionKeys: extracted.encryptionKeys ? Object.fromEntries(extracted.encryptionKeys) : undefined,
180
233
  })
181
234
 
182
- console.log(
183
- formatOutput(
184
- {
185
- user: { id: person.id, displayName: person.displayName, emails: person.emails },
186
- authenticated: true,
187
- tokenType: 'extracted',
188
- },
189
- options.pretty,
190
- ),
191
- )
235
+ const output: Record<string, unknown> = {
236
+ authenticated: true,
237
+ tokenType: 'extracted',
238
+ }
239
+ if (refreshedConfig) {
240
+ output['refreshed'] = true
241
+ }
242
+ if (person) {
243
+ output['user'] = person
244
+ }
245
+
246
+ console.log(formatOutput(output, options.pretty))
192
247
  } catch (error) {
193
248
  handleError(error as Error)
194
249
  }
@@ -291,6 +291,69 @@ describe('WebexCredentialManager', () => {
291
291
  expect(loaded?.clientSecret).toBe('my-client-secret')
292
292
  })
293
293
 
294
+ test('getToken tries refresh for expired extracted tokens', async () => {
295
+ const originalFetch = globalThis.fetch
296
+ globalThis.fetch = mock(() =>
297
+ Promise.resolve(
298
+ new Response(
299
+ JSON.stringify({
300
+ access_token: 'refreshed-extracted-token',
301
+ refresh_token: 'new-refresh',
302
+ expires_in: 3600,
303
+ }),
304
+ { status: 200 },
305
+ ),
306
+ ),
307
+ ) as typeof fetch
308
+
309
+ await credManager.saveConfig({
310
+ accessToken: 'expired-extracted-token',
311
+ refreshToken: 'extracted-refresh',
312
+ expiresAt: Date.now() - 1000,
313
+ tokenType: 'extracted',
314
+ })
315
+
316
+ const token = await credManager.getToken()
317
+ expect(token).toBe('refreshed-extracted-token')
318
+
319
+ const config = await credManager.loadConfig()
320
+ expect(config?.tokenType).toBe('extracted')
321
+ expect(config?.accessToken).toBe('refreshed-extracted-token')
322
+
323
+ globalThis.fetch = originalFetch
324
+ })
325
+
326
+ test('getToken returns expired extracted token when refresh fails', async () => {
327
+ const originalFetch = globalThis.fetch
328
+ globalThis.fetch = mock(() =>
329
+ Promise.resolve(new Response('{"error":"invalid_grant"}', { status: 400 })),
330
+ ) as typeof fetch
331
+
332
+ await credManager.saveConfig({
333
+ accessToken: 'expired-extracted-token',
334
+ refreshToken: 'bad-refresh',
335
+ expiresAt: Date.now() - 1000,
336
+ tokenType: 'extracted',
337
+ })
338
+
339
+ const token = await credManager.getToken()
340
+ expect(token).toBe('expired-extracted-token')
341
+
342
+ globalThis.fetch = originalFetch
343
+ })
344
+
345
+ test('getToken returns non-expired extracted token without refresh', async () => {
346
+ await credManager.saveConfig({
347
+ accessToken: 'valid-extracted-token',
348
+ refreshToken: 'refresh',
349
+ expiresAt: Date.now() + 3600000,
350
+ tokenType: 'extracted',
351
+ })
352
+
353
+ const token = await credManager.getToken()
354
+ expect(token).toBe('valid-extracted-token')
355
+ })
356
+
294
357
  test('loadConfig backward compat — old config without clientId/clientSecret', async () => {
295
358
  // Write raw JSON without clientId/clientSecret fields
296
359
  const credPath = join(tempDir, 'webex-credentials.json')
@@ -45,16 +45,33 @@ export class WebexCredentialManager {
45
45
  const config = await this.loadConfig()
46
46
  if (!config) return null
47
47
 
48
- if (config.tokenType === 'manual' || config.tokenType === 'extracted') {
48
+ if (config.tokenType === 'manual') {
49
49
  return config.accessToken
50
50
  }
51
51
 
52
- if (config.expiresAt < Date.now() + 5 * 60 * 1000) {
52
+ const isExpired = config.expiresAt > 0 && config.expiresAt < Date.now() + 5 * 60 * 1000
53
+
54
+ if (config.tokenType === 'extracted') {
55
+ if (isExpired && config.refreshToken) {
56
+ const builtinCreds = getWebexAppCredentials()
57
+ const refreshed = await this.refreshToken(config.refreshToken, builtinCreds.clientId, builtinCreds.clientSecret)
58
+ if (refreshed) {
59
+ await this.saveConfig({ ...config, ...refreshed, tokenType: 'extracted' })
60
+ return refreshed.accessToken
61
+ }
62
+ }
63
+ return config.accessToken
64
+ }
65
+
66
+ if (isExpired) {
53
67
  const builtinCreds = getWebexAppCredentials()
54
68
  const resolvedClientId = clientId ?? config.clientId ?? builtinCreds.clientId
55
69
  const resolvedClientSecret = clientSecret ?? config.clientSecret ?? builtinCreds.clientSecret
56
70
  const refreshed = await this.refreshToken(config.refreshToken, resolvedClientId, resolvedClientSecret)
57
- if (refreshed) return refreshed.accessToken
71
+ if (refreshed) {
72
+ await this.saveConfig({ ...config, ...refreshed })
73
+ return refreshed.accessToken
74
+ }
58
75
  return null
59
76
  }
60
77
 
@@ -82,14 +99,11 @@ export class WebexCredentialManager {
82
99
  expires_in: number
83
100
  }
84
101
 
85
- const config: WebexConfig = {
102
+ return {
86
103
  accessToken: data.access_token,
87
104
  refreshToken: data.refresh_token,
88
105
  expiresAt: Date.now() + data.expires_in * 1000,
89
- }
90
-
91
- await this.saveConfig(config)
92
- return config
106
+ } satisfies Pick<WebexConfig, 'accessToken' | 'refreshToken' | 'expiresAt'>
93
107
  } catch {
94
108
  return null
95
109
  }
@@ -0,0 +1,54 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+
3
+ import * as jose from 'node-jose'
4
+
5
+ import { WebexEncryptionService } from './encryption'
6
+
7
+ const decodeJweHeader = (jwe: string): Record<string, unknown> => {
8
+ const [header = ''] = jwe.split('.')
9
+ const padded = header + '='.repeat((4 - (header.length % 4)) % 4)
10
+ const json = Buffer.from(padded, 'base64url').toString('utf8')
11
+ return JSON.parse(json) as Record<string, unknown>
12
+ }
13
+
14
+ const createKeyring = async (keyUri: string) => {
15
+ const keystore = jose.JWK.createKeyStore()
16
+ const key = await keystore.generate('oct', 256, { alg: 'A256GCM' })
17
+ const jwk = key.toJSON(true)
18
+ const rawKeys = new Map<string, string>()
19
+ rawKeys.set(keyUri, JSON.stringify({ jwk }))
20
+ return new WebexEncryptionService(rawKeys)
21
+ }
22
+
23
+ describe('WebexEncryptionService', () => {
24
+ const keyUri = 'kms://kms-aore.wbx2.com/keys/7819829b-5e0d-4139-9cad-1b6fe7aee533'
25
+
26
+ test('encryptText emits JWE with alg, enc, and kid JOSE headers', async () => {
27
+ const service = await createKeyring(keyUri)
28
+
29
+ const jwe = await service.encryptText(keyUri, 'hello world')
30
+
31
+ expect(jwe).not.toBeNull()
32
+ const header = decodeJweHeader(jwe as string)
33
+ expect(header.alg).toBe('dir')
34
+ expect(header.enc).toBe('A256GCM')
35
+ expect(header.kid).toBe(keyUri)
36
+ })
37
+
38
+ test('encryptText returns null when key is unknown', async () => {
39
+ const service = await createKeyring(keyUri)
40
+
41
+ const jwe = await service.encryptText('kms://other/keys/missing', 'hello')
42
+
43
+ expect(jwe).toBeNull()
44
+ })
45
+
46
+ test('decryptText round-trips plaintext encrypted by encryptText', async () => {
47
+ const service = await createKeyring(keyUri)
48
+
49
+ const jwe = await service.encryptText(keyUri, 'round trip')
50
+ const plaintext = await service.decryptText(keyUri, jwe as string)
51
+
52
+ expect(plaintext).toBe('round trip')
53
+ })
54
+ })
@@ -30,9 +30,11 @@ export class WebexEncryptionService {
30
30
  if (!key) return null
31
31
 
32
32
  try {
33
+ // Webex desktop/web clients auto-tombstone edit activities whose JWE is missing
34
+ // `kid` — they can't resolve the KMS key and treat the activity as malformed.
33
35
  return await jose.JWE.createEncrypt(
34
36
  { format: 'compact', contentAlg: 'A256GCM' },
35
- { key, header: { alg: 'dir' }, reference: null },
37
+ { key, header: { alg: 'dir', kid: keyUri }, reference: null },
36
38
  ).final(plaintext, 'utf8')
37
39
  } catch {
38
40
  return null
@@ -11,7 +11,11 @@ export async function ensureWebexAuth(): Promise<void> {
11
11
  const token = await credManager.getToken(config.clientId, config.clientSecret)
12
12
  if (token) {
13
13
  const client = new WebexClient()
14
- await client.login({ token })
14
+ await client.login({
15
+ token,
16
+ deviceUrl: config.deviceUrl,
17
+ tokenType: config.tokenType,
18
+ })
15
19
  await client.testAuth()
16
20
  return
17
21
  }
@@ -22,7 +26,11 @@ export async function ensureWebexAuth(): Promise<void> {
22
26
  if (!extracted) return
23
27
 
24
28
  const client = new WebexClient()
25
- await client.login({ token: extracted.accessToken })
29
+ await client.login({
30
+ token: extracted.accessToken,
31
+ deviceUrl: extracted.deviceUrl,
32
+ tokenType: 'extracted',
33
+ })
26
34
  await client.testAuth()
27
35
 
28
36
  await credManager.saveConfig({
@@ -216,12 +216,41 @@ describe('WebexTokenExtractor', () => {
216
216
  expect(result).not.toBeNull()
217
217
  })
218
218
 
219
- test('returns first valid token and stops scanning', async () => {
219
+ test('prefers token with latest expiry across profiles', async () => {
220
220
  const dir1 = createLevelDBDir(tempDir, 'Default')
221
221
  const dir2 = createLevelDBDir(tempDir, 'Profile 1')
222
222
 
223
- const token1 = makeWebexStorageJson({ accessToken: 'first-valid-token-longer-than-twenty-chars' })
224
- const token2 = makeWebexStorageJson({ accessToken: 'second-valid-token-longer-than-twenty-chars' })
223
+ const expiredToken = makeWebexStorageJson({
224
+ accessToken: 'expired-token-longer-than-twenty-chars-xx',
225
+ expires: Date.now() - 3600000,
226
+ })
227
+ const freshToken = makeWebexStorageJson({
228
+ accessToken: 'fresh-token-longer-than-twenty-chars-xxx',
229
+ expires: Date.now() + 3600000,
230
+ })
231
+
232
+ writeFileSync(join(dir1, '000003.log'), expiredToken)
233
+ writeFileSync(join(dir2, '000003.log'), freshToken)
234
+
235
+ const extractor = new WebexTokenExtractor('darwin', undefined, tempDir)
236
+ const result = await extractor.extract()
237
+
238
+ expect(result!.accessToken).toBe('fresh-token-longer-than-twenty-chars-xxx')
239
+ })
240
+
241
+ test('returns first token when all have same expiry', async () => {
242
+ const dir1 = createLevelDBDir(tempDir, 'Default')
243
+ const dir2 = createLevelDBDir(tempDir, 'Profile 1')
244
+
245
+ const expires = Date.now() + 3600000
246
+ const token1 = makeWebexStorageJson({
247
+ accessToken: 'first-valid-token-longer-than-twenty-chars',
248
+ expires,
249
+ })
250
+ const token2 = makeWebexStorageJson({
251
+ accessToken: 'second-valid-token-longer-than-twenty-chars',
252
+ expires,
253
+ })
225
254
 
226
255
  writeFileSync(join(dir1, '000003.log'), token1)
227
256
  writeFileSync(join(dir2, '000003.log'), token2)
@@ -160,25 +160,46 @@ export class WebexTokenExtractor {
160
160
  return null
161
161
  }
162
162
 
163
+ let best: { token: ExtractedWebexToken; source: string } | null = null
164
+
163
165
  for (const leveldbDir of profileDirs) {
164
166
  this.debug(`Scanning: ${leveldbDir}`)
165
167
 
166
168
  const result = (await this.scanViaClassicLevelCopy(leveldbDir)) ?? this.scanRawFiles(leveldbDir)
167
169
 
168
170
  if (result?.token) {
169
- this.debug(`Found token in: ${leveldbDir}`)
170
-
171
171
  const token = result.token
172
172
  if (result.encryptionKeys.size > 0) {
173
173
  token.encryptionKeys = result.encryptionKeys
174
174
  }
175
175
 
176
- return token
176
+ this.debug(
177
+ `Found token in: ${leveldbDir} (expires: ${token.expiresAt ? new Date(token.expiresAt).toISOString() : 'unknown'}, length: ${token.accessToken.length})`,
178
+ )
179
+
180
+ if (!best || this.isTokenFresher(token, best.token)) {
181
+ best = { token, source: leveldbDir }
182
+ }
177
183
  }
178
184
  }
179
185
 
180
- this.debug('No Webex tokens found in any browser profile')
181
- return null
186
+ if (!best) {
187
+ this.debug('No Webex tokens found in any browser profile')
188
+ return null
189
+ }
190
+
191
+ this.debug(`Selected token from: ${best.source}`)
192
+ return best.token
193
+ }
194
+
195
+ private isTokenFresher(candidate: ExtractedWebexToken, current: ExtractedWebexToken): boolean {
196
+ const candidateExpiry = candidate.expiresAt ?? 0
197
+ const currentExpiry = current.expiresAt ?? 0
198
+ if (candidateExpiry > 0 && currentExpiry > 0) {
199
+ return candidateExpiry > currentExpiry
200
+ }
201
+ if (candidateExpiry > 0 && currentExpiry === 0) return true
202
+ return false
182
203
  }
183
204
 
184
205
  private async scanViaClassicLevelCopy(dbPath: string): Promise<ScanResult | null> {