agent-messenger 2.9.0 → 2.10.0

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 (117) 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 +67 -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.ts +77 -12
  105. package/src/platforms/teams/types.test.ts +17 -0
  106. package/src/platforms/teams/types.ts +6 -0
  107. package/src/platforms/webex/client.test.ts +157 -13
  108. package/src/platforms/webex/client.ts +64 -15
  109. package/src/platforms/webex/commands/auth.test.ts +122 -1
  110. package/src/platforms/webex/commands/auth.ts +72 -17
  111. package/src/platforms/webex/credential-manager.test.ts +63 -0
  112. package/src/platforms/webex/credential-manager.ts +22 -8
  113. package/src/platforms/webex/encryption.test.ts +54 -0
  114. package/src/platforms/webex/encryption.ts +3 -1
  115. package/src/platforms/webex/ensure-auth.ts +10 -2
  116. package/src/platforms/webex/token-extractor.test.ts +32 -3
  117. package/src/platforms/webex/token-extractor.ts +26 -5
@@ -21,12 +21,14 @@ export class WebexClient {
21
21
  private globalRateLimitUntil: number = 0
22
22
  private encryption: WebexEncryptionService | null = null
23
23
 
24
- async login(credentials?: { token: string }): Promise<this> {
24
+ async login(credentials?: { token: string; deviceUrl?: string; tokenType?: string }): Promise<this> {
25
25
  if (credentials) {
26
26
  if (!credentials.token) {
27
27
  throw new WebexError('Token is required', 'missing_token')
28
28
  }
29
29
  this.token = credentials.token
30
+ if (credentials.deviceUrl !== undefined) this.deviceUrl = credentials.deviceUrl
31
+ if (credentials.tokenType !== undefined) this.tokenType = credentials.tokenType
30
32
  return this
31
33
  }
32
34
 
@@ -161,9 +163,28 @@ export class WebexClient {
161
163
  }
162
164
 
163
165
  async testAuth(): Promise<WebexPerson> {
166
+ if (this.useInternalAPI) {
167
+ try {
168
+ return await this.request<WebexPerson>('GET', '/people/me')
169
+ } catch (err) {
170
+ const isAuthError = err instanceof WebexError && (err.code === 'http_401' || err.code === 'http_403')
171
+ if (!isAuthError) throw err
172
+ await this.testAuthInternal()
173
+ return { id: '', emails: [], displayName: '', orgId: '', type: 'person', created: '' } as WebexPerson
174
+ }
175
+ }
164
176
  return this.request<WebexPerson>('GET', '/people/me')
165
177
  }
166
178
 
179
+ private async testAuthInternal(): Promise<void> {
180
+ if (!this.deviceUrl) {
181
+ throw new WebexError('No device URL available for internal API validation', 'no_device_url')
182
+ }
183
+ await this.internalRequest<InternalConversation>(
184
+ '/conversations?participantsLimit=0&activitiesLimit=0&conversationsLimit=1',
185
+ )
186
+ }
187
+
167
188
  async listSpaces(options?: { type?: string; max?: number }): Promise<WebexSpace[]> {
168
189
  const params = new URLSearchParams()
169
190
  if (options?.type) params.set('type', options.type)
@@ -248,10 +269,15 @@ export class WebexClient {
248
269
  private async buildEncryptedObject(
249
270
  convUuid: string,
250
271
  text: string,
251
- options?: { markdown?: boolean },
272
+ options?: { markdown?: boolean; forEdit?: boolean },
252
273
  ): Promise<{ object: Record<string, string>; encryptionKeyUrl?: string }> {
253
274
  const displayName = options?.markdown ? stripMarkdown(text) : text
254
- const content = options?.markdown ? markdownToHtml(text) : text
275
+ let content: string | undefined
276
+ if (options?.markdown) {
277
+ content = markdownToHtml(text)
278
+ } else if (options?.forEdit) {
279
+ content = text
280
+ }
255
281
 
256
282
  if (this.encryption) {
257
283
  const conv = await this.internalRequest<InternalConversation>(
@@ -260,21 +286,25 @@ export class WebexClient {
260
286
  const keyUri = conv.defaultActivityEncryptionKeyUrl
261
287
  if (keyUri) {
262
288
  const encryptedDisplayName = await this.encryption.encryptText(keyUri, displayName)
263
- const encryptedContent = await this.encryption.encryptText(keyUri, content)
264
- if (encryptedDisplayName && encryptedContent) {
265
- return {
266
- object: {
267
- objectType: 'comment',
268
- displayName: encryptedDisplayName,
269
- content: encryptedContent,
270
- },
271
- encryptionKeyUrl: keyUri,
289
+ const encryptedContent = content ? await this.encryption.encryptText(keyUri, content) : undefined
290
+ if (encryptedDisplayName) {
291
+ const object: Record<string, string> = {
292
+ objectType: 'comment',
293
+ displayName: encryptedDisplayName,
294
+ }
295
+ if (encryptedContent) {
296
+ object.content = encryptedContent
272
297
  }
298
+ return { object, encryptionKeyUrl: keyUri }
273
299
  }
274
300
  }
275
301
  }
276
302
 
277
- return { object: { objectType: 'comment', displayName, content } }
303
+ const object: Record<string, string> = { objectType: 'comment', displayName }
304
+ if (content) {
305
+ object.content = content
306
+ }
307
+ return { object }
278
308
  }
279
309
 
280
310
  private async sendMessageInternal(
@@ -349,6 +379,11 @@ export class WebexClient {
349
379
  if (this.useInternalAPI) {
350
380
  const activity = await this.internalRequest<InternalActivity>(`/activities/${messageId}`)
351
381
  const convId = activity.target?.id ?? ''
382
+ // Internal API responses don't carry the cluster shard (e.g. `us-west-2_r`) the
383
+ // public roomId encoding requires. The `unknown` placeholder is a sentinel — it
384
+ // round-trips through other internal API calls because they decode only the
385
+ // conversation UUID suffix. Callers that need a public-API-safe roomId should
386
+ // obtain it from `listSpaces()` or pass it through from a prior `sendMessage`.
352
387
  const roomId = convId ? Buffer.from(`ciscospark://urn:TEAM:unknown/ROOM/${convId}`).toString('base64') : ''
353
388
  return this.activityToMessage(activity, roomId)
354
389
  }
@@ -381,14 +416,17 @@ export class WebexClient {
381
416
  ): Promise<WebexMessage> {
382
417
  if (this.useInternalAPI) {
383
418
  const convUuid = this.decodeConvUuid(roomId)
384
- const { object, encryptionKeyUrl } = await this.buildEncryptedObject(convUuid, text, options)
419
+ const { object, encryptionKeyUrl } = await this.buildEncryptedObject(convUuid, text, {
420
+ ...options,
421
+ forEdit: true,
422
+ })
385
423
 
386
424
  const activity: Record<string, unknown> = {
387
425
  verb: 'post',
388
426
  object,
389
427
  target: { id: convUuid, objectType: 'conversation' },
390
428
  parent: { id: messageId, type: 'edit' },
391
- clientTempId: `tmp-${Date.now()}`,
429
+ clientTempId: `tmp-${Date.now()}-edit`,
392
430
  }
393
431
 
394
432
  if (encryptionKeyUrl) {
@@ -399,6 +437,16 @@ export class WebexClient {
399
437
  method: 'POST',
400
438
  body: JSON.stringify(activity),
401
439
  })
440
+
441
+ // Tolerate responses that omit `parent` (server may return minimal shape) —
442
+ // only fail on an explicit mismatch between the echoed parent and the edited id.
443
+ if (result.parent && result.parent.id !== messageId) {
444
+ throw new WebexError(
445
+ `Edit rejected: server linked the new activity ${result.id} to ${result.parent.id} instead of ${messageId}.`,
446
+ 'edit_failed',
447
+ )
448
+ }
449
+
402
450
  return this.activityToMessage(result, roomId)
403
451
  }
404
452
  const body = options?.markdown ? { roomId, markdown: text } : { roomId, text }
@@ -443,6 +491,7 @@ interface InternalActivity {
443
491
  encryptionKeyUrl?: string
444
492
  }
445
493
  target?: { id: string; encryptionKeyUrl?: string }
494
+ parent?: { id: string; type: string }
446
495
  published: string
447
496
  encryptionKeyUrl?: string
448
497
  }
@@ -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({