agent-messenger 1.9.2 → 1.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 (58) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/dist/package.json +1 -1
  3. package/dist/src/platforms/discord/commands/server.d.ts.map +1 -1
  4. package/dist/src/platforms/discord/commands/server.js +5 -2
  5. package/dist/src/platforms/discord/commands/server.js.map +1 -1
  6. package/dist/src/platforms/discord/token-extractor.d.ts +1 -0
  7. package/dist/src/platforms/discord/token-extractor.d.ts.map +1 -1
  8. package/dist/src/platforms/discord/token-extractor.js +7 -0
  9. package/dist/src/platforms/discord/token-extractor.js.map +1 -1
  10. package/dist/src/platforms/discordbot/client.d.ts.map +1 -1
  11. package/dist/src/platforms/discordbot/client.js +1 -1
  12. package/dist/src/platforms/discordbot/client.js.map +1 -1
  13. package/dist/src/platforms/slack/commands/auth.d.ts +1 -0
  14. package/dist/src/platforms/slack/commands/auth.d.ts.map +1 -1
  15. package/dist/src/platforms/slack/commands/auth.js +28 -6
  16. package/dist/src/platforms/slack/commands/auth.js.map +1 -1
  17. package/dist/src/platforms/slack/commands/file.d.ts.map +1 -1
  18. package/dist/src/platforms/slack/commands/file.js +1 -1
  19. package/dist/src/platforms/slack/commands/file.js.map +1 -1
  20. package/dist/src/platforms/slack/commands/reaction.js +4 -4
  21. package/dist/src/platforms/slack/commands/reaction.js.map +1 -1
  22. package/dist/src/platforms/slack/commands/workspace.d.ts.map +1 -1
  23. package/dist/src/platforms/slack/commands/workspace.js +2 -2
  24. package/dist/src/platforms/slack/commands/workspace.js.map +1 -1
  25. package/dist/src/platforms/slack/ensure-auth.d.ts +7 -0
  26. package/dist/src/platforms/slack/ensure-auth.d.ts.map +1 -1
  27. package/dist/src/platforms/slack/ensure-auth.js +29 -3
  28. package/dist/src/platforms/slack/ensure-auth.js.map +1 -1
  29. package/dist/src/platforms/slack/token-extractor.d.ts +10 -2
  30. package/dist/src/platforms/slack/token-extractor.d.ts.map +1 -1
  31. package/dist/src/platforms/slack/token-extractor.js +147 -23
  32. package/dist/src/platforms/slack/token-extractor.js.map +1 -1
  33. package/dist/src/platforms/teams/client.js +2 -2
  34. package/dist/src/platforms/teams/client.js.map +1 -1
  35. package/dist/src/platforms/teams/commands/team.d.ts.map +1 -1
  36. package/dist/src/platforms/teams/commands/team.js +4 -4
  37. package/dist/src/platforms/teams/commands/team.js.map +1 -1
  38. package/package.json +1 -1
  39. package/skills/agent-discord/SKILL.md +1 -1
  40. package/skills/agent-discordbot/SKILL.md +1 -1
  41. package/skills/agent-slack/SKILL.md +1 -1
  42. package/skills/agent-slackbot/SKILL.md +1 -1
  43. package/skills/agent-teams/SKILL.md +1 -1
  44. package/src/platforms/discord/commands/server.ts +15 -2
  45. package/src/platforms/discord/token-extractor.test.ts +25 -0
  46. package/src/platforms/discord/token-extractor.ts +7 -0
  47. package/src/platforms/discordbot/client.ts +4 -1
  48. package/src/platforms/slack/commands/auth.test.ts +23 -0
  49. package/src/platforms/slack/commands/auth.ts +34 -6
  50. package/src/platforms/slack/commands/file.ts +6 -1
  51. package/src/platforms/slack/commands/reaction.ts +4 -4
  52. package/src/platforms/slack/commands/workspace.ts +12 -2
  53. package/src/platforms/slack/ensure-auth.test.ts +54 -1
  54. package/src/platforms/slack/ensure-auth.ts +32 -2
  55. package/src/platforms/slack/token-extractor.test.ts +207 -0
  56. package/src/platforms/slack/token-extractor.ts +161 -28
  57. package/src/platforms/teams/client.ts +2 -2
  58. package/src/platforms/teams/commands/team.ts +14 -4
@@ -31,6 +31,213 @@ function createCookiesDb(
31
31
  db.close()
32
32
  }
33
33
 
34
+ describe('TokenExtractor token deduplication', () => {
35
+ test('keeps first token per team and upgrades unknown team name', async () => {
36
+ // given — two .log entries for the same team: first has unknown name, second has a name
37
+ const slackDir = mkdtempSync(join(tmpdir(), 'slack-dedup-'))
38
+ tempDirs.push(slackDir)
39
+
40
+ const hex64a = 'a'.repeat(64)
41
+ const hex64b = 'b'.repeat(64)
42
+ const tokenA = `xoxc-1111111111-2222222222-3333333333-${hex64a}`
43
+ const tokenB = `xoxc-4444444444-5555555555-6666666666-${hex64b}`
44
+
45
+ const leveldbDir = join(slackDir, 'Local Storage', 'leveldb')
46
+ mkdirSync(leveldbDir, { recursive: true })
47
+ // First entry: tokenA with team ID but no name
48
+ // Second entry: tokenB with same team ID and a name
49
+ writeFileSync(join(leveldbDir, '000001.log'), `"${tokenA}"T12345678xxx"${tokenB}"T12345678"name":"workspace-name"`)
50
+
51
+ // when
52
+ const extractor = new TokenExtractor('darwin', slackDir)
53
+ const result = await extractor.extract()
54
+
55
+ // then — first token wins, but team name is upgraded
56
+ expect(result.length).toBe(1)
57
+ expect(result[0].token).toBe(tokenA)
58
+ expect(result[0].workspace_name).toBe('workspace-name')
59
+ })
60
+
61
+ test('prefers .log tokens over .ldb tokens for same team', async () => {
62
+ // given — same team ID in both .log (fresh) and .ldb (stale)
63
+ const slackDir = mkdtempSync(join(tmpdir(), 'slack-dedup-order-'))
64
+ tempDirs.push(slackDir)
65
+
66
+ const hex64fresh = 'f'.repeat(64)
67
+ const hex64stale = 's'.repeat(64)
68
+ const freshToken = `xoxc-1111111111-2222222222-3333333333-${hex64fresh}`
69
+ const staleToken = `xoxc-9999999999-8888888888-7777777777-${hex64stale}`
70
+
71
+ const leveldbDir = join(slackDir, 'Local Storage', 'leveldb')
72
+ mkdirSync(leveldbDir, { recursive: true })
73
+ writeFileSync(join(leveldbDir, '000001.log'), `"${freshToken}"T12345678"name":"fresh-workspace"`)
74
+ writeFileSync(join(leveldbDir, '000002.ldb'), `"${staleToken}"T12345678"team_name":"stale-workspace"`)
75
+
76
+ // when
77
+ const extractor = new TokenExtractor('darwin', slackDir)
78
+ const result = await extractor.extract()
79
+
80
+ // then — .log token wins
81
+ expect(result.length).toBe(1)
82
+ expect(result[0].token).toBe(freshToken)
83
+ })
84
+ })
85
+
86
+ describe('TokenExtractor LevelDB fragmentation markers', () => {
87
+ function buildFragmentedLdbContent(tokenParts: string[], marker: number[]): Buffer {
88
+ // given — build binary content simulating LevelDB fragmentation:
89
+ // token segments joined by 4-byte marker instead of hyphens
90
+ const segments: Buffer[] = []
91
+ for (let i = 0; i < tokenParts.length; i++) {
92
+ segments.push(Buffer.from(tokenParts[i]))
93
+ if (i < tokenParts.length - 1) {
94
+ segments.push(Buffer.from(marker))
95
+ }
96
+ }
97
+ // Surround with team ID context and a terminator
98
+ const prefix = Buffer.from('"team_name":"test-workspace"T12345678')
99
+ const suffix = Buffer.from('"')
100
+ return Buffer.concat([prefix, segments[0] ? Buffer.concat(segments) : Buffer.alloc(0), suffix])
101
+ }
102
+
103
+ test('extracts token with old fragmentation marker [19 0d f0 NN]', async () => {
104
+ // given
105
+ const slackDir = mkdtempSync(join(tmpdir(), 'slack-marker-old-'))
106
+ tempDirs.push(slackDir)
107
+
108
+ const hex64 = 'a'.repeat(64)
109
+ const tokenParts = ['xoxc-1111111111', '2222222222', '3333333333', hex64]
110
+ const content = buildFragmentedLdbContent(tokenParts, [0x19, 0x0d, 0xf0, 0x5e])
111
+
112
+ const leveldbDir = join(slackDir, 'Local Storage', 'leveldb')
113
+ mkdirSync(leveldbDir, { recursive: true })
114
+ writeFileSync(join(leveldbDir, '000001.ldb'), content)
115
+
116
+ // when
117
+ const extractor = new TokenExtractor('darwin', slackDir)
118
+ const result = await extractor.extract()
119
+
120
+ // then
121
+ expect(result.length).toBe(1)
122
+ expect(result[0].token).toBe(`xoxc-1111111111-2222222222-3333333333-${hex64}`)
123
+ })
124
+
125
+ test('extracts token with new fragmentation marker [15 0b f0 43]', async () => {
126
+ // given — marker whose 4th byte (0x43 = "C") is a valid hex char
127
+ const slackDir = mkdtempSync(join(tmpdir(), 'slack-marker-new-'))
128
+ tempDirs.push(slackDir)
129
+
130
+ const hex64 = 'b'.repeat(64)
131
+ const tokenParts = ['xoxc-4063338523', '4063338531', '8150876260673', hex64]
132
+ const content = buildFragmentedLdbContent(tokenParts, [0x15, 0x0b, 0xf0, 0x43])
133
+
134
+ const leveldbDir = join(slackDir, 'Local Storage', 'leveldb')
135
+ mkdirSync(leveldbDir, { recursive: true })
136
+ writeFileSync(join(leveldbDir, '000001.ldb'), content)
137
+
138
+ // when
139
+ const extractor = new TokenExtractor('darwin', slackDir)
140
+ const result = await extractor.extract()
141
+
142
+ // then — 0x43 ("C") must NOT leak into the token
143
+ expect(result.length).toBe(1)
144
+ expect(result[0].token).toBe(`xoxc-4063338523-4063338531-8150876260673-${hex64}`)
145
+ expect(result[0].token).not.toContain('C')
146
+ })
147
+
148
+ test('extracts token with new fragmentation marker [15 0b f0 58]', async () => {
149
+ // given — marker whose 4th byte (0x58 = "X") is not a valid hex char
150
+ const slackDir = mkdtempSync(join(tmpdir(), 'slack-marker-58-'))
151
+ tempDirs.push(slackDir)
152
+
153
+ const hex64 = 'c'.repeat(64)
154
+ const tokenParts = ['xoxc-5555555555', '6666666666', '7777777777', hex64]
155
+ const content = buildFragmentedLdbContent(tokenParts, [0x15, 0x0b, 0xf0, 0x58])
156
+
157
+ const leveldbDir = join(slackDir, 'Local Storage', 'leveldb')
158
+ mkdirSync(leveldbDir, { recursive: true })
159
+ writeFileSync(join(leveldbDir, '000001.ldb'), content)
160
+
161
+ // when
162
+ const extractor = new TokenExtractor('darwin', slackDir)
163
+ const result = await extractor.extract()
164
+
165
+ // then
166
+ expect(result.length).toBe(1)
167
+ expect(result[0].token).toBe(`xoxc-5555555555-6666666666-7777777777-${hex64}`)
168
+ })
169
+ })
170
+
171
+ describe('TokenExtractor Linux cookie decryption', () => {
172
+ test('decrypts v10 cookie using peanuts password on Linux', async () => {
173
+ // given — LevelDB with valid token + v10-encrypted cookie using Linux key
174
+ const slackDir = mkdtempSync(join(tmpdir(), 'slack-linux-'))
175
+ tempDirs.push(slackDir)
176
+
177
+ const cookiePlaintext = 'xoxd-linuxTestCookie%2Bvalue'
178
+ const key = require('node:crypto').pbkdf2Sync('peanuts', 'saltysalt', 1, 16, 'sha1')
179
+ const iv = Buffer.alloc(16, ' ')
180
+ const cipher = createCipheriv('aes-128-cbc', key, iv)
181
+ const ciphertext = Buffer.concat([cipher.update(cookiePlaintext, 'utf8'), cipher.final()])
182
+ const encryptedCookie = Buffer.concat([Buffer.from('v10'), ciphertext])
183
+
184
+ createCookiesDb(join(slackDir, 'Cookies'), [
185
+ {
186
+ name: 'd',
187
+ value: '',
188
+ encrypted_value: new Uint8Array(encryptedCookie),
189
+ host_key: '.slack.com',
190
+ last_access_utc: 1,
191
+ },
192
+ ])
193
+
194
+ const token = `xoxc-1111111111-2222222222-3333333333-${'a'.repeat(64)}`
195
+ const leveldbDir = join(slackDir, 'Local Storage', 'leveldb')
196
+ mkdirSync(leveldbDir, { recursive: true })
197
+ writeFileSync(join(leveldbDir, '000001.log'), `"${token}"T12345678"name":"test-workspace"`)
198
+
199
+ // when
200
+ const extractor = new TokenExtractor('linux', slackDir)
201
+ const result = await extractor.extract()
202
+
203
+ // then
204
+ expect(result.length).toBe(1)
205
+ expect(result[0].cookie).toBe(cookiePlaintext)
206
+ expect(result[0].token).toBe(token)
207
+ })
208
+ })
209
+
210
+ describe('TokenExtractor debug logging', () => {
211
+ test('calls debugLog callback during extraction', async () => {
212
+ // given
213
+ const slackDir = mkdtempSync(join(tmpdir(), 'slack-debug-'))
214
+ tempDirs.push(slackDir)
215
+ mkdirSync(join(slackDir, 'storage'), { recursive: true })
216
+
217
+ const messages: string[] = []
218
+ const debugLog = (msg: string) => messages.push(msg)
219
+
220
+ // when
221
+ const extractor = new TokenExtractor('darwin', slackDir, undefined, debugLog)
222
+ await extractor.extract()
223
+
224
+ // then — should have emitted debug messages
225
+ expect(messages.length).toBeGreaterThan(0)
226
+ })
227
+
228
+ test('does not throw when debugLog is not provided', async () => {
229
+ // given
230
+ const slackDir = mkdtempSync(join(tmpdir(), 'slack-no-debug-'))
231
+ tempDirs.push(slackDir)
232
+ mkdirSync(join(slackDir, 'storage'), { recursive: true })
233
+
234
+ // when — then — should not throw
235
+ const extractor = new TokenExtractor('darwin', slackDir)
236
+ const result = await extractor.extract()
237
+ expect(result).toEqual([])
238
+ })
239
+ })
240
+
34
241
  describe('TokenExtractor Windows DPAPI', () => {
35
242
  test('decryptDPAPI returns null on non-win32 platform', () => {
36
243
  const extractor = new TokenExtractor('darwin', '/tmp/slack-test')
@@ -1,6 +1,6 @@
1
1
  import { execSync } from 'node:child_process'
2
2
  import { createDecipheriv, pbkdf2Sync } from 'node:crypto'
3
- import { copyFileSync, existsSync, readdirSync, readFileSync, rmSync } from 'node:fs'
3
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs'
4
4
  import { createRequire } from 'node:module'
5
5
  import { homedir, tmpdir } from 'node:os'
6
6
  import { join } from 'node:path'
@@ -26,8 +26,14 @@ export class TokenExtractor {
26
26
  private platform: NodeJS.Platform
27
27
  private slackDir: string
28
28
  private keyCache: DerivedKeyCache
29
-
30
- constructor(platform?: NodeJS.Platform, slackDir?: string, keyCache?: DerivedKeyCache) {
29
+ private debugLog: ((message: string) => void) | null
30
+
31
+ constructor(
32
+ platform?: NodeJS.Platform,
33
+ slackDir?: string,
34
+ keyCache?: DerivedKeyCache,
35
+ debugLog?: (message: string) => void,
36
+ ) {
31
37
  this.platform = platform ?? process.platform
32
38
 
33
39
  if (!['darwin', 'linux', 'win32'].includes(this.platform)) {
@@ -36,6 +42,11 @@ export class TokenExtractor {
36
42
 
37
43
  this.slackDir = slackDir ?? this.getSlackDir()
38
44
  this.keyCache = keyCache ?? new DerivedKeyCache()
45
+ this.debugLog = debugLog ?? null
46
+ }
47
+
48
+ private debug(message: string): void {
49
+ this.debugLog?.(message)
39
50
  }
40
51
 
41
52
  getSlackDir(): string {
@@ -72,6 +83,26 @@ export class TokenExtractor {
72
83
  }
73
84
  }
74
85
 
86
+ async extractCookie(): Promise<string> {
87
+ if (!existsSync(this.slackDir)) {
88
+ return ''
89
+ }
90
+
91
+ await this.getDerivedKeyAsync()
92
+
93
+ const cookie = await this.extractCookieFromSQLite()
94
+ if (!cookie && this.usedCachedKey) {
95
+ await this.clearKeyCache()
96
+ this.cachedKey = this.getDerivedKeyFromKeychain()
97
+ if (this.cachedKey) {
98
+ await this.keyCache.set('slack', this.cachedKey)
99
+ return await this.extractCookieFromSQLite()
100
+ }
101
+ }
102
+
103
+ return cookie
104
+ }
105
+
75
106
  async extract(): Promise<ExtractedWorkspace[]> {
76
107
  if (!existsSync(this.slackDir)) {
77
108
  throw new Error(`Slack directory not found: ${this.slackDir}`)
@@ -142,8 +173,11 @@ export class TokenExtractor {
142
173
  private deduplicateTokens(tokens: TokenInfo[]): TokenInfo[] {
143
174
  const seen = new Map<string, TokenInfo>()
144
175
  for (const token of tokens) {
145
- if (!seen.has(token.teamId) || token.teamName !== 'unknown') {
176
+ const existing = seen.get(token.teamId)
177
+ if (!existing) {
146
178
  seen.set(token.teamId, token)
179
+ } else if (existing.teamName === 'unknown' && token.teamName !== 'unknown') {
180
+ seen.set(token.teamId, { ...token, token: existing.token })
147
181
  }
148
182
  }
149
183
  return Array.from(seen.values())
@@ -180,25 +214,57 @@ export class TokenExtractor {
180
214
  }
181
215
 
182
216
  private async extractFromLevelDB(dbPath: string): Promise<TokenInfo[]> {
183
- const tokens: TokenInfo[] = []
217
+ // ClassicLevel on a copy avoids LevelDB prefix-compression artifacts
218
+ // that corrupt tokens when reading raw .ldb files
219
+ const classicLevelTokens = await this.extractViaClassicLevelCopy(dbPath)
220
+ if (classicLevelTokens.length > 0) {
221
+ return classicLevelTokens
222
+ }
184
223
 
185
- // First try reading LDB files directly (more reliable for sandboxed apps)
186
224
  const directTokens = this.extractTokensFromLDBFiles(dbPath)
187
225
  if (directTokens.length > 0) {
188
226
  return directTokens
189
227
  }
190
228
 
191
- // Fallback to ClassicLevel for standard installations
229
+ return this.extractViaClassicLevel(dbPath)
230
+ }
231
+
232
+ private async extractViaClassicLevelCopy(dbPath: string): Promise<TokenInfo[]> {
233
+ const tempDir = join(tmpdir(), `slack-leveldb-${Date.now()}-${Math.random().toString(36).slice(2)}`)
234
+
235
+ try {
236
+ mkdirSync(tempDir, { recursive: true })
237
+
238
+ const files = readdirSync(dbPath)
239
+ for (const file of files) {
240
+ if (file === 'LOCK') continue // ClassicLevel creates its own
241
+ const src = join(dbPath, file)
242
+ try {
243
+ if (statSync(src).isFile()) {
244
+ copyFileSync(src, join(tempDir, file))
245
+ }
246
+ } catch {}
247
+ }
248
+
249
+ return await this.extractViaClassicLevel(tempDir)
250
+ } catch {
251
+ return []
252
+ } finally {
253
+ try {
254
+ rmSync(tempDir, { recursive: true, force: true })
255
+ } catch {}
256
+ }
257
+ }
258
+
259
+ private async extractViaClassicLevel(dbPath: string): Promise<TokenInfo[]> {
260
+ const tokens: TokenInfo[] = []
192
261
  let db: ClassicLevel<string, string> | null = null
193
262
  try {
194
263
  db = new ClassicLevel(dbPath, { valueEncoding: 'utf8' })
195
264
 
196
265
  for await (const [key, value] of db.iterator()) {
197
266
  if (typeof value === 'string' && value.includes('xoxc-')) {
198
- const extracted = this.parseTokenData(key, value)
199
- if (extracted) {
200
- tokens.push(extracted)
201
- }
267
+ tokens.push(...this.parseTokenValue(key, value))
202
268
  }
203
269
  }
204
270
  } catch {
@@ -209,7 +275,6 @@ export class TokenExtractor {
209
275
  } catch {}
210
276
  }
211
277
  }
212
-
213
278
  return tokens
214
279
  }
215
280
 
@@ -219,7 +284,13 @@ export class TokenExtractor {
219
284
  // Prioritize .log files (not compacted, have clean data)
220
285
  // Then fall back to .ldb files
221
286
  const logFiles = readdirSync(dbPath).filter((f) => f.endsWith('.log'))
222
- const ldbFiles = readdirSync(dbPath).filter((f) => f.endsWith('.ldb'))
287
+ const ldbFiles = readdirSync(dbPath)
288
+ .filter((f) => f.endsWith('.ldb'))
289
+ .sort((a, b) => {
290
+ const statA = statSync(join(dbPath, a))
291
+ const statB = statSync(join(dbPath, b))
292
+ return statB.mtimeMs - statA.mtimeMs
293
+ })
223
294
  const files = [...logFiles, ...ldbFiles]
224
295
 
225
296
  for (const file of files) {
@@ -305,8 +376,10 @@ export class TokenExtractor {
305
376
  i++
306
377
  } else {
307
378
  // Check for 4-byte fragmentation marker pattern
308
- // Pattern: 0x19 0x0d 0xf0 0xNN (where NN varies)
309
- if (i + 3 < chunk.length && chunk[i] === 0x19 && chunk[i + 1] === 0x0d && chunk[i + 2] === 0xf0) {
379
+ // LevelDB compaction inserts 4-byte markers where hyphens should be.
380
+ // Known patterns: [19 0d f0 NN], [15 0b f0 NN] the 3rd byte (0xf0) is consistent.
381
+ // Match any 4-byte sequence where byte at offset +2 is 0xf0.
382
+ if (i + 3 < chunk.length && chunk[i + 2] === 0xf0) {
310
383
  // Skip the 4 garbage bytes and insert a hyphen
311
384
  result.push(0x2d) // hyphen
312
385
  i += 4
@@ -370,33 +443,57 @@ export class TokenExtractor {
370
443
  return { token, teamId, teamName }
371
444
  }
372
445
 
373
- private parseTokenData(_key: string, value: string): TokenInfo | null {
374
- const tokenMatch = value.match(/xoxc-[a-zA-Z0-9-]+/)
375
- if (!tokenMatch) {
376
- return null
446
+ private parseTokenValue(_key: string, value: string): TokenInfo[] {
447
+ // LevelDB values may have leading control characters (e.g. 0x01).
448
+ // Built dynamically to satisfy biome's noControlCharactersInRegex.
449
+ const controlChars = new RegExp(
450
+ `[${String.fromCharCode(0)}-${String.fromCharCode(8)}${String.fromCharCode(11)}${String.fromCharCode(12)}${String.fromCharCode(14)}-${String.fromCharCode(31)}]`,
451
+ 'g',
452
+ )
453
+ const cleaned = value.replace(controlChars, '')
454
+ try {
455
+ const parsed = JSON.parse(cleaned)
456
+ if (parsed?.teams && typeof parsed.teams === 'object') {
457
+ return this.parseTeamsObject(parsed.teams)
458
+ }
459
+ } catch {}
460
+
461
+ const single = this.parseSingleToken(cleaned)
462
+ return single ? [single] : []
463
+ }
464
+
465
+ private parseTeamsObject(teams: Record<string, any>): TokenInfo[] {
466
+ const tokens: TokenInfo[] = []
467
+ for (const [teamId, team] of Object.entries(teams)) {
468
+ if (!team?.token || typeof team.token !== 'string' || !team.token.startsWith('xoxc-')) continue
469
+ tokens.push({
470
+ token: team.token,
471
+ teamId: team.id || teamId,
472
+ teamName: team.name || 'unknown',
473
+ })
377
474
  }
475
+ return tokens
476
+ }
378
477
 
379
- const token = tokenMatch[0]
478
+ private parseSingleToken(value: string): TokenInfo | null {
479
+ const tokenMatch = value.match(/xoxc-[a-zA-Z0-9-]+/)
480
+ if (!tokenMatch) return null
380
481
 
381
482
  let teamId = 'unknown'
382
483
  let teamName = 'unknown'
383
484
 
384
485
  const teamIdMatch = value.match(/"team_id"\s*:\s*"(T[A-Z0-9]+)"/)
385
- if (teamIdMatch) {
386
- teamId = teamIdMatch[1]
387
- }
486
+ if (teamIdMatch) teamId = teamIdMatch[1]
388
487
 
389
488
  const teamNameMatch = value.match(/"team_name"\s*:\s*"([^"]+)"/)
390
489
  if (teamNameMatch) {
391
490
  teamName = teamNameMatch[1]
392
491
  } else {
393
492
  const domainMatch = value.match(/"domain"\s*:\s*"([^"]+)"/)
394
- if (domainMatch) {
395
- teamName = domainMatch[1]
396
- }
493
+ if (domainMatch) teamName = domainMatch[1]
397
494
  }
398
495
 
399
- return { token, teamId, teamName }
496
+ return { token: tokenMatch[0], teamId, teamName }
400
497
  }
401
498
 
402
499
  private async extractCookieFromSQLite(): Promise<string> {
@@ -404,10 +501,13 @@ export class TokenExtractor {
404
501
  if (!existsSync(cookiesPath)) {
405
502
  const networkCookiesPath = join(this.slackDir, 'Network', 'Cookies')
406
503
  if (!existsSync(networkCookiesPath)) {
504
+ this.debug(`Cookie file not found at ${cookiesPath} or ${networkCookiesPath}`)
407
505
  return ''
408
506
  }
507
+ this.debug(`Using Network cookies path: ${networkCookiesPath}`)
409
508
  return this.readCookieFromDB(networkCookiesPath)
410
509
  }
510
+ this.debug(`Using cookies path: ${cookiesPath}`)
411
511
  return this.readCookieFromDB(cookiesPath)
412
512
  }
413
513
 
@@ -425,6 +525,7 @@ export class TokenExtractor {
425
525
  'Quit the Slack app completely and try again.',
426
526
  )
427
527
  }
528
+ this.debug(`Failed to copy cookie DB: ${(error as Error).message}`)
428
529
  return ''
429
530
  }
430
531
 
@@ -451,22 +552,29 @@ export class TokenExtractor {
451
552
  }
452
553
 
453
554
  if (!row) {
555
+ this.debug('No cookie row found in database')
454
556
  return ''
455
557
  }
456
558
 
457
559
  if (row.value?.startsWith('xoxd-')) {
560
+ this.debug('Found plaintext cookie')
458
561
  return row.value
459
562
  }
460
563
 
461
564
  if (row.encrypted_value && row.encrypted_value.length > 0) {
565
+ this.debug(`Found encrypted cookie (${row.encrypted_value.length} bytes)`)
462
566
  const decrypted = this.tryDecryptCookie(Buffer.from(row.encrypted_value))
463
567
  if (decrypted) {
568
+ this.debug('Cookie decrypted successfully')
464
569
  return decrypted
465
570
  }
571
+ this.debug('Cookie decryption failed')
466
572
  }
467
573
 
574
+ this.debug('No usable cookie value in row')
468
575
  return ''
469
- } catch {
576
+ } catch (error) {
577
+ this.debug(`Cookie DB query failed: ${(error as Error).message}`)
470
578
  return ''
471
579
  } finally {
472
580
  try {
@@ -485,6 +593,9 @@ export class TokenExtractor {
485
593
  if (this.platform === 'win32') {
486
594
  return this.decryptV10CookieWindows(encrypted)
487
595
  }
596
+ if (this.platform === 'linux') {
597
+ return this.decryptV10CookieLinux(encrypted)
598
+ }
488
599
  return this.decryptV10Cookie(encrypted)
489
600
  }
490
601
 
@@ -524,6 +635,23 @@ export class TokenExtractor {
524
635
  }
525
636
  }
526
637
 
638
+ private decryptV10CookieLinux(encrypted: Buffer): string | null {
639
+ try {
640
+ const key = pbkdf2Sync('peanuts', 'saltysalt', 1, 16, 'sha1')
641
+ const iv = Buffer.alloc(16, ' ')
642
+ const ciphertext = encrypted.subarray(3)
643
+
644
+ const decipher = createDecipheriv('aes-128-cbc', key, iv)
645
+ const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()])
646
+ const result = decrypted.toString('utf8')
647
+
648
+ const match = result.match(/xoxd-[A-Za-z0-9%]+/)
649
+ return match ? match[0] : null
650
+ } catch {
651
+ return null
652
+ }
653
+ }
654
+
527
655
  decryptV10CookieWindows(encrypted: Buffer): string | null {
528
656
  try {
529
657
  const masterKey = this.getWindowsMasterKey()
@@ -607,6 +735,7 @@ export class TokenExtractor {
607
735
 
608
736
  private async getDerivedKeyAsync(): Promise<Buffer | null> {
609
737
  if (this.platform !== 'darwin') {
738
+ this.debug(`Skipping Keychain key derivation (platform: ${this.platform})`)
610
739
  return null
611
740
  }
612
741
 
@@ -614,6 +743,7 @@ export class TokenExtractor {
614
743
  if (cached) {
615
744
  this.cachedKey = cached
616
745
  this.usedCachedKey = true
746
+ this.debug('Using cached derived key')
617
747
  return cached
618
748
  }
619
749
 
@@ -622,6 +752,9 @@ export class TokenExtractor {
622
752
  this.cachedKey = key
623
753
  await this.keyCache.set('slack', key)
624
754
  this.usedCachedKey = false
755
+ this.debug('Derived key from Keychain')
756
+ } else {
757
+ this.debug('Failed to derive key from Keychain')
625
758
  }
626
759
  return key
627
760
  }
@@ -86,7 +86,7 @@ export class TeamsClient {
86
86
 
87
87
  private async request<T>(method: string, path: string, body?: unknown, baseUrl: string = MSG_API_BASE): Promise<T> {
88
88
  if (this.isTokenExpired()) {
89
- throw new TeamsError('Token has expired', 'token_expired')
89
+ throw new TeamsError('Token has expired. Run "auth extract" to refresh.', 'token_expired')
90
90
  }
91
91
 
92
92
  const url = `${baseUrl}${path}`
@@ -151,7 +151,7 @@ export class TeamsClient {
151
151
 
152
152
  private async requestFormData<T>(path: string, formData: FormData, baseUrl: string = MSG_API_BASE): Promise<T> {
153
153
  if (this.isTokenExpired()) {
154
- throw new TeamsError('Token has expired', 'token_expired')
154
+ throw new TeamsError('Token has expired. Run "auth extract" to refresh.', 'token_expired')
155
155
  }
156
156
 
157
157
  const url = `${baseUrl}${path}`
@@ -53,7 +53,12 @@ export async function switchAction(teamId: string, options: { pretty?: boolean }
53
53
  const account = await credManager.getCurrentAccount()
54
54
 
55
55
  if (!account?.teams?.[teamId]) {
56
- console.log(formatOutput({ error: `Team not found: ${teamId}` }, options.pretty))
56
+ console.log(
57
+ formatOutput(
58
+ { error: `Team not found: ${teamId}`, hint: 'Run "team list" to see available teams.' },
59
+ options.pretty,
60
+ ),
61
+ )
57
62
  process.exit(1)
58
63
  }
59
64
 
@@ -87,18 +92,23 @@ export async function removeAction(teamId: string, options: { pretty?: boolean }
87
92
  const config = await credManager.loadConfig()
88
93
 
89
94
  if (!config) {
90
- console.log(formatOutput({ error: 'No configuration found.' }, options.pretty))
95
+ console.log(formatOutput({ error: 'No configuration found. Run "auth extract" first.' }, options.pretty))
91
96
  process.exit(1)
92
97
  }
93
98
 
94
99
  const account = await credManager.getCurrentAccount()
95
100
  if (!account) {
96
- console.log(formatOutput({ error: 'No active account.' }, options.pretty))
101
+ console.log(formatOutput({ error: 'No active account. Run "auth extract" first.' }, options.pretty))
97
102
  process.exit(1)
98
103
  }
99
104
 
100
105
  if (!account.teams[teamId]) {
101
- console.log(formatOutput({ error: `Team not found: ${teamId}` }, options.pretty))
106
+ console.log(
107
+ formatOutput(
108
+ { error: `Team not found: ${teamId}`, hint: 'Run "team list" to see available teams.' },
109
+ options.pretty,
110
+ ),
111
+ )
102
112
  process.exit(1)
103
113
  }
104
114