agent-messenger 2.24.0 → 2.24.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 (53) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.github/workflows/ci.yml +4 -0
  3. package/bun.lock +0 -71
  4. package/dist/package.json +4 -4
  5. package/dist/src/platforms/kakaotalk/token-extractor.d.ts.map +1 -1
  6. package/dist/src/platforms/kakaotalk/token-extractor.js +11 -38
  7. package/dist/src/platforms/kakaotalk/token-extractor.js.map +1 -1
  8. package/dist/src/platforms/slack/commands/auth.d.ts.map +1 -1
  9. package/dist/src/platforms/slack/commands/auth.js +32 -5
  10. package/dist/src/platforms/slack/commands/auth.js.map +1 -1
  11. package/dist/src/platforms/slack/ensure-auth.d.ts +1 -0
  12. package/dist/src/platforms/slack/ensure-auth.d.ts.map +1 -1
  13. package/dist/src/platforms/slack/ensure-auth.js +47 -0
  14. package/dist/src/platforms/slack/ensure-auth.js.map +1 -1
  15. package/dist/src/platforms/slack/token-extractor.d.ts.map +1 -1
  16. package/dist/src/platforms/slack/token-extractor.js +5 -11
  17. package/dist/src/platforms/slack/token-extractor.js.map +1 -1
  18. package/dist/src/shared/chromium/cookie-reader.d.ts.map +1 -1
  19. package/dist/src/shared/chromium/cookie-reader.js +4 -19
  20. package/dist/src/shared/chromium/cookie-reader.js.map +1 -1
  21. package/dist/src/shared/sqlite.d.ts +10 -0
  22. package/dist/src/shared/sqlite.d.ts.map +1 -0
  23. package/dist/src/shared/sqlite.js +46 -0
  24. package/dist/src/shared/sqlite.js.map +1 -0
  25. package/package.json +4 -4
  26. package/skills/agent-channeltalk/SKILL.md +1 -1
  27. package/skills/agent-channeltalkbot/SKILL.md +1 -1
  28. package/skills/agent-discord/SKILL.md +1 -1
  29. package/skills/agent-discordbot/SKILL.md +1 -1
  30. package/skills/agent-instagram/SKILL.md +1 -1
  31. package/skills/agent-kakaotalk/SKILL.md +1 -1
  32. package/skills/agent-line/SKILL.md +1 -1
  33. package/skills/agent-slack/SKILL.md +1 -1
  34. package/skills/agent-slackbot/SKILL.md +1 -1
  35. package/skills/agent-teams/SKILL.md +1 -1
  36. package/skills/agent-telegram/SKILL.md +1 -1
  37. package/skills/agent-telegrambot/SKILL.md +1 -1
  38. package/skills/agent-webex/SKILL.md +1 -1
  39. package/skills/agent-webexbot/SKILL.md +1 -1
  40. package/skills/agent-wechatbot/SKILL.md +1 -1
  41. package/skills/agent-whatsapp/SKILL.md +1 -1
  42. package/skills/agent-whatsappbot/SKILL.md +1 -1
  43. package/src/platforms/channeltalk/token-extractor.test.ts +2 -2
  44. package/src/platforms/kakaotalk/token-extractor.ts +13 -36
  45. package/src/platforms/slack/commands/auth.ts +33 -5
  46. package/src/platforms/slack/ensure-auth.test.ts +130 -19
  47. package/src/platforms/slack/ensure-auth.ts +51 -0
  48. package/src/platforms/slack/token-extractor-node-test.ts +5 -3
  49. package/src/platforms/slack/token-extractor.ts +4 -11
  50. package/src/shared/chromium/cookie-reader-node-test.ts +70 -0
  51. package/src/shared/chromium/cookie-reader-node.test.ts +10 -0
  52. package/src/shared/chromium/cookie-reader.ts +4 -21
  53. package/src/shared/sqlite.ts +61 -0
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-webexbot
3
3
  description: Interact with Cisco Webex using bot tokens - send messages, reply in threads, upload and download files, look up people, read spaces, manage memberships, stream real-time events
4
- version: 2.24.0
4
+ version: 2.24.1
5
5
  allowed-tools: Bash(agent-webexbot:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-wechatbot
3
3
  description: Interact with WeChat Official Account using API credentials - send messages, manage templates, list followers
4
- version: 2.24.0
4
+ version: 2.24.1
5
5
  allowed-tools: Bash(agent-wechatbot:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-whatsapp
3
3
  description: Interact with WhatsApp - send messages, read chats, manage conversations
4
- version: 2.24.0
4
+ version: 2.24.1
5
5
  allowed-tools: Bash(agent-whatsapp:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-whatsappbot
3
3
  description: Interact with WhatsApp using Cloud API credentials - send messages, manage templates
4
- version: 2.24.0
4
+ version: 2.24.1
5
5
  allowed-tools: Bash(agent-whatsappbot:*)
6
6
  metadata:
7
7
  openclaw:
@@ -457,8 +457,8 @@ async function createCookieDatabase(
457
457
 
458
458
  const { createRequire } = await import('node:module')
459
459
  const req = createRequire(import.meta.url)
460
- const Database = req('better-sqlite3')
461
- const db = new Database(dbPath)
460
+ const { DatabaseSync } = req('node:sqlite')
461
+ const db = new DatabaseSync(dbPath)
462
462
  db.exec('CREATE TABLE cookies (name TEXT, value TEXT, encrypted_value BLOB, host_key TEXT)')
463
463
  const statement = db.prepare('INSERT INTO cookies (name, value, encrypted_value, host_key) VALUES (?, ?, ?, ?)')
464
464
  for (const row of rows) {
@@ -1,12 +1,11 @@
1
1
  import { execSync } from 'node:child_process'
2
2
  import { copyFileSync, existsSync, readFileSync, rmSync } from 'node:fs'
3
- import { createRequire } from 'node:module'
4
3
  import { homedir, tmpdir } from 'node:os'
5
4
  import { join } from 'node:path'
6
5
 
7
- import type { ExtractedKakaoToken } from './types'
6
+ import { openReadonlyDatabase } from '@/shared/sqlite'
8
7
 
9
- const require = createRequire(import.meta.url)
8
+ import type { ExtractedKakaoToken } from './types'
10
9
 
11
10
  export class KakaoTokenExtractor {
12
11
  private platform: NodeJS.Platform
@@ -106,23 +105,12 @@ export class KakaoTokenExtractor {
106
105
  time_stamp: number
107
106
  }
108
107
 
108
+ const db = openReadonlyDatabase(dbPath)
109
109
  let rows: CacheRow[]
110
- if (typeof globalThis.Bun !== 'undefined') {
111
- const { Database } = require('bun:sqlite')
112
- const db = new Database(dbPath, { readonly: true })
113
- try {
114
- rows = db.query(sql).all() as CacheRow[]
115
- } finally {
116
- db.close()
117
- }
118
- } else {
119
- const Database = require('better-sqlite3')
120
- const db = new Database(dbPath, { readonly: true })
121
- try {
122
- rows = db.prepare(sql).all() as CacheRow[]
123
- } finally {
124
- db.close()
125
- }
110
+ try {
111
+ rows = db.prepare(sql).all() as CacheRow[]
112
+ } finally {
113
+ db.close()
126
114
  }
127
115
 
128
116
  this.debug(`Found ${rows.length} cached request(s) to kakao.com`)
@@ -166,24 +154,13 @@ export class KakaoTokenExtractor {
166
154
  `
167
155
 
168
156
  type CacheRow = { request_object: Uint8Array | Buffer | null; request_key: string }
169
- let authRows: CacheRow[]
170
157
 
171
- if (typeof globalThis.Bun !== 'undefined') {
172
- const { Database } = require('bun:sqlite')
173
- const db = new Database(dbPath, { readonly: true })
174
- try {
175
- authRows = db.query(sql).all() as CacheRow[]
176
- } finally {
177
- db.close()
178
- }
179
- } else {
180
- const Database = require('better-sqlite3')
181
- const db = new Database(dbPath, { readonly: true })
182
- try {
183
- authRows = db.prepare(sql).all() as CacheRow[]
184
- } finally {
185
- db.close()
186
- }
158
+ const db = openReadonlyDatabase(dbPath)
159
+ let authRows: CacheRow[]
160
+ try {
161
+ authRows = db.prepare(sql).all() as CacheRow[]
162
+ } finally {
163
+ db.close()
187
164
  }
188
165
 
189
166
  for (const row of authRows) {
@@ -8,7 +8,8 @@ import { debug } from '@/shared/utils/stderr'
8
8
 
9
9
  import { SlackClient, SlackError } from '../client'
10
10
  import { CredentialManager } from '../credential-manager'
11
- import { refreshCookie, tryWebTokenRefresh } from '../ensure-auth'
11
+ import { refreshCookie, refreshKnownWorkspaceDomains, tryWebTokenRefresh } from '../ensure-auth'
12
+ import type { RefreshResult } from '../ensure-auth'
12
13
  import { loginWithQr } from '../qr-http-login'
13
14
  import { type ExtractedWorkspace, TokenExtractor } from '../token-extractor'
14
15
 
@@ -80,6 +81,8 @@ async function extractAction(options: {
80
81
 
81
82
  const validWorkspaces = []
82
83
  const failureReasons: string[] = []
84
+ const resolvedTeamIds = new Set<string>()
85
+ const refreshCache = new Map<string, RefreshResult | null>()
83
86
  for (const ws of workspaces) {
84
87
  if (options.debug) {
85
88
  debug(`[debug] Testing credentials for ${ws.workspace_id}...`)
@@ -88,10 +91,14 @@ async function extractAction(options: {
88
91
  try {
89
92
  const client = await new SlackClient().login({ token: ws.token, cookie: ws.cookie })
90
93
  const authInfo = await client.testAuth()
94
+ if (!authInfo.team_id) throw new SlackError('testAuth returned empty team_id', 'invalid_auth')
91
95
  ws.workspace_id = authInfo.team_id
92
96
  ws.workspace_name = authInfo.team || ws.workspace_name
93
- validWorkspaces.push(ws)
94
- await credManager.setWorkspace(ws)
97
+ if (!resolvedTeamIds.has(ws.workspace_id)) {
98
+ resolvedTeamIds.add(ws.workspace_id)
99
+ validWorkspaces.push(ws)
100
+ await credManager.setWorkspace(ws)
101
+ }
95
102
 
96
103
  if (options.debug) {
97
104
  debug(`[debug] ✓ Valid: ${authInfo.team} (${authInfo.user})`)
@@ -112,11 +119,12 @@ async function extractAction(options: {
112
119
  : `${ws.workspace_id} (trying all known domains)`
113
120
  debug(`[debug] Attempting web token refresh for ${target}...`)
114
121
  }
115
- const refreshed = await tryWebTokenRefresh(ws, workspaceDomains)
116
- if (refreshed) {
122
+ const refreshed = await tryWebTokenRefresh(ws, workspaceDomains, { resolvedTeamIds, refreshCache })
123
+ if (refreshed && !resolvedTeamIds.has(refreshed.workspace_id)) {
117
124
  ws.token = refreshed.token
118
125
  ws.workspace_id = refreshed.workspace_id
119
126
  ws.workspace_name = refreshed.workspace_name
127
+ resolvedTeamIds.add(ws.workspace_id)
120
128
  validWorkspaces.push(ws)
121
129
  await credManager.setWorkspace(ws)
122
130
 
@@ -129,6 +137,26 @@ async function extractAction(options: {
129
137
  }
130
138
  }
131
139
 
140
+ for (const cookie of new Set(workspaces.map((ws) => ws.cookie).filter(Boolean))) {
141
+ const enumerated = await refreshKnownWorkspaceDomains(cookie, workspaceDomains, {
142
+ resolvedTeamIds,
143
+ refreshCache,
144
+ })
145
+ for (const result of enumerated) {
146
+ const ws: ExtractedWorkspace = {
147
+ workspace_id: result.workspace_id,
148
+ workspace_name: result.workspace_name,
149
+ token: result.token,
150
+ cookie,
151
+ }
152
+ validWorkspaces.push(ws)
153
+ await credManager.setWorkspace(ws)
154
+ if (options.debug) {
155
+ debug(`[debug] ✓ Recovered via domain enumeration: ${result.workspace_id}/${result.workspace_name}`)
156
+ }
157
+ }
158
+ }
159
+
132
160
  if (validWorkspaces.length === 0) {
133
161
  const errorMessage = getExtractionErrorMessage(failureReasons)
134
162
  console.log(
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, spyOn, it } from 'bun:test'
2
2
 
3
3
  import { SlackClient } from './client'
4
4
  import { CredentialManager } from './credential-manager'
5
- import { ensureSlackAuth, refreshTokenFromWeb } from './ensure-auth'
5
+ import { ensureSlackAuth, refreshKnownWorkspaceDomains, refreshTokenFromWeb, tryWebTokenRefresh } from './ensure-auth'
6
6
  import { TokenExtractor } from './token-extractor'
7
7
 
8
8
  let getWorkspaceSpy: ReturnType<typeof spyOn>
@@ -358,8 +358,8 @@ describe('ensureSlackAuth', () => {
358
358
  )
359
359
  })
360
360
 
361
- it('stops trying domains once a refresh+verify succeeds', async () => {
362
- // given — exact-match domain succeeds on first attempt; the fallback domain must not be fetched
361
+ it('enumerates all known domains to recover every authenticatable workspace', async () => {
362
+ // given — one stale extracted token, but the cookie is valid for two workspaces
363
363
  extractSpy.mockResolvedValue([
364
364
  { workspace_id: 'T_TARGET', workspace_name: 'target', token: 'xoxc-stale', cookie: 'xoxd-valid' },
365
365
  ])
@@ -368,21 +368,27 @@ describe('ensureSlackAuth', () => {
368
368
  T_OTHER: 'other-domain',
369
369
  })
370
370
 
371
- fetchSpy.mockResolvedValue(new Response('<html>"api_token":"xoxc-fresh"</html>', { status: 200 }))
371
+ fetchSpy.mockImplementation((url: string) => {
372
+ if (url.startsWith('https://target-domain.slack.com/')) {
373
+ return Promise.resolve(new Response('<html>"api_token":"xoxc-fresh-target"</html>', { status: 200 }))
374
+ }
375
+ if (url.startsWith('https://other-domain.slack.com/')) {
376
+ return Promise.resolve(new Response('<html>"api_token":"xoxc-fresh-other"</html>', { status: 200 }))
377
+ }
378
+ return Promise.resolve(new Response('', { status: 500 }))
379
+ })
372
380
 
373
381
  authResponseByToken({
374
- 'xoxc-fresh': { team_id: 'T_TARGET', team: 'Target' },
382
+ 'xoxc-fresh-target': { team_id: 'T_TARGET', team: 'Target' },
383
+ 'xoxc-fresh-other': { team_id: 'T_OTHER', team: 'Other' },
375
384
  })
376
385
 
377
386
  // when
378
387
  await ensureSlackAuth()
379
388
 
380
- // then — the exact-match domain is tried, fallback domain is not
381
- expect(fetchSpy).toHaveBeenCalledWith(
382
- 'https://target-domain.slack.com/ssb/redirect',
383
- expect.objectContaining({ headers: { Cookie: 'd=xoxd-valid' } }),
384
- )
385
- expect(fetchSpy).not.toHaveBeenCalledWith('https://other-domain.slack.com/ssb/redirect', expect.anything())
389
+ // then — both workspaces are recovered from the single cookie, each saved once
390
+ const savedIds = setWorkspaceSpy.mock.calls.map((c) => (c[0] as { workspace_id: string }).workspace_id)
391
+ expect(savedIds.sort()).toEqual(['T_OTHER', 'T_TARGET'])
386
392
  })
387
393
 
388
394
  it('returns failure when no known domain validates the cookie', async () => {
@@ -496,26 +502,131 @@ describe('ensureSlackAuth', () => {
496
502
  expect(refreshCalls.length).toBe(2)
497
503
  })
498
504
 
499
- it('caps domain attempts at MAX_DOMAIN_ATTEMPTS', async () => {
500
- // given — 20 candidate domains; only the first 16 should be attempted
501
- extractSpy.mockResolvedValue([
502
- { workspace_id: 'unknown', workspace_name: 'unknown', token: 'xoxc-stale', cookie: 'xoxd-valid' },
503
- ])
505
+ it('caps single-candidate fallback at MAX_DOMAIN_ATTEMPTS', async () => {
506
+ // given — 20 candidate domains; the per-token fallback only probes the first 16
504
507
  const manyDomains: Record<string, string> = {}
505
508
  for (let i = 0; i < 20; i++) manyDomains[`T_${i}`] = `dom${i}`
506
- getWorkspaceDomainsSpy.mockReturnValue(manyDomains)
507
-
508
509
  fetchSpy.mockResolvedValue(new Response('', { status: 500 }))
509
510
  testAuthSpy.mockRejectedValue(new Error('invalid_auth'))
510
511
 
511
512
  // when
512
- await ensureSlackAuth()
513
+ await tryWebTokenRefresh(
514
+ { workspace_id: 'unknown', workspace_name: 'unknown', token: 'xoxc-stale', cookie: 'xoxd-valid' },
515
+ manyDomains,
516
+ )
513
517
 
514
518
  // then — exactly 16 refresh attempts
515
519
  const refreshCalls = fetchSpy.mock.calls.filter((c) => String(c[0]).includes('/ssb/redirect'))
516
520
  expect(refreshCalls.length).toBe(16)
517
521
  })
518
522
 
523
+ it('enumerates ALL known domains without the MAX_DOMAIN_ATTEMPTS cap', async () => {
524
+ // given — 20 candidate domains; enumeration must probe every one
525
+ const manyDomains: Record<string, string> = {}
526
+ for (let i = 0; i < 20; i++) manyDomains[`T_${i}`] = `dom${i}`
527
+ fetchSpy.mockResolvedValue(new Response('', { status: 500 }))
528
+ testAuthSpy.mockRejectedValue(new Error('invalid_auth'))
529
+
530
+ // when
531
+ await refreshKnownWorkspaceDomains('xoxd-valid', manyDomains)
532
+
533
+ // then — all 20 domains attempted
534
+ const refreshCalls = fetchSpy.mock.calls.filter((c) => String(c[0]).includes('/ssb/redirect'))
535
+ expect(refreshCalls.length).toBe(20)
536
+ })
537
+
538
+ it('dedupes by verified team_id even when no context is supplied', async () => {
539
+ // given — two domains whose cookie verifies to the same team_id, called without a context set
540
+ getWorkspaceDomainsSpy.mockReturnValue({})
541
+ fetchSpy.mockImplementation((url: string) => {
542
+ if (url.startsWith('https://primary.slack.com/')) {
543
+ return Promise.resolve(new Response('<html>"api_token":"xoxc-fresh-primary"</html>', { status: 200 }))
544
+ }
545
+ if (url.startsWith('https://alias.slack.com/')) {
546
+ return Promise.resolve(new Response('<html>"api_token":"xoxc-fresh-alias"</html>', { status: 200 }))
547
+ }
548
+ return Promise.resolve(new Response('', { status: 500 }))
549
+ })
550
+ authResponseByToken({
551
+ 'xoxc-fresh-primary': { team_id: 'T_SAME', team: 'Same' },
552
+ 'xoxc-fresh-alias': { team_id: 'T_SAME', team: 'Same' },
553
+ })
554
+
555
+ // when
556
+ const results = await refreshKnownWorkspaceDomains('xoxd-valid', { T_A: 'primary', T_B: 'alias' })
557
+
558
+ // then — the duplicate team is returned only once
559
+ expect(results.map((r) => r.workspace_id)).toEqual(['T_SAME'])
560
+ })
561
+
562
+ it('recovers all workspaces from one cookie when only one token is extracted', async () => {
563
+ // given — extractor yields one token deduped into two entries (one with a team id, one unknown),
564
+ // both sharing a single cookie that is valid for five workspaces
565
+ extractSpy.mockResolvedValue([
566
+ { workspace_id: 'T1', workspace_name: 'unknown', token: 'xoxc-shared', cookie: 'xoxd-valid' },
567
+ { workspace_id: 'unknown', workspace_name: 'unknown', token: 'xoxc-shared', cookie: 'xoxd-valid' },
568
+ ])
569
+ getWorkspaceDomainsSpy.mockReturnValue({
570
+ T1: 'one',
571
+ T2: 'two',
572
+ T3: 'three',
573
+ T4: 'four',
574
+ T5: 'five',
575
+ })
576
+
577
+ const domainToToken: Record<string, string> = {
578
+ one: 'xoxc-fresh-1',
579
+ two: 'xoxc-fresh-2',
580
+ three: 'xoxc-fresh-3',
581
+ four: 'xoxc-fresh-4',
582
+ five: 'xoxc-fresh-5',
583
+ }
584
+ fetchSpy.mockImplementation((url: string) => {
585
+ const domain = String(url).match(/https:\/\/([^.]+)\.slack\.com/)?.[1] ?? ''
586
+ const token = domainToToken[domain]
587
+ return Promise.resolve(
588
+ token
589
+ ? new Response(`<html>"api_token":"${token}"</html>`, { status: 200 })
590
+ : new Response('', { status: 500 }),
591
+ )
592
+ })
593
+ authResponseByToken({
594
+ 'xoxc-shared': new Error('invalid_auth'),
595
+ 'xoxc-fresh-1': { team_id: 'T1', team: 'one' },
596
+ 'xoxc-fresh-2': { team_id: 'T2', team: 'two' },
597
+ 'xoxc-fresh-3': { team_id: 'T3', team: 'three' },
598
+ 'xoxc-fresh-4': { team_id: 'T4', team: 'four' },
599
+ 'xoxc-fresh-5': { team_id: 'T5', team: 'five' },
600
+ })
601
+
602
+ // when
603
+ await ensureSlackAuth()
604
+
605
+ // then — all five teams recovered, each saved exactly once
606
+ const savedIds = setWorkspaceSpy.mock.calls.map((c) => (c[0] as { workspace_id: string }).workspace_id)
607
+ expect(savedIds.sort()).toEqual(['T1', 'T2', 'T3', 'T4', 'T5'])
608
+ })
609
+
610
+ it('keeps a valid extracted token without overwriting it via enumeration', async () => {
611
+ // given — a single extracted token that authenticates directly
612
+ extractSpy.mockResolvedValue([
613
+ { workspace_id: 'T1', workspace_name: 'ws1', token: 'xoxc-good', cookie: 'xoxd-valid' },
614
+ ])
615
+ getWorkspaceDomainsSpy.mockReturnValue({ T1: 'one' })
616
+ authResponseByToken({
617
+ 'xoxc-good': { team_id: 'T1', team: 'ws1' },
618
+ 'xoxc-fresh-1': { team_id: 'T1', team: 'ws1' },
619
+ })
620
+ fetchSpy.mockResolvedValue(new Response('<html>"api_token":"xoxc-fresh-1"</html>', { status: 200 }))
621
+
622
+ // when
623
+ await ensureSlackAuth()
624
+
625
+ // then — saved once with the original valid token, not the web-refreshed one
626
+ expect(setWorkspaceSpy).toHaveBeenCalledTimes(1)
627
+ expect(setWorkspaceSpy).toHaveBeenCalledWith(expect.objectContaining({ workspace_id: 'T1', token: 'xoxc-good' }))
628
+ })
629
+
519
630
  it('skips web refresh when workspace has no cookie', async () => {
520
631
  // given — cookie is empty
521
632
  extractSpy.mockResolvedValue([
@@ -50,6 +50,23 @@ export async function ensureSlackAuth(): Promise<void> {
50
50
  }
51
51
  }
52
52
 
53
+ for (const cookie of new Set(workspaces.map((ws) => ws.cookie).filter(Boolean))) {
54
+ const enumerated = await refreshKnownWorkspaceDomains(cookie, workspaceDomains, {
55
+ resolvedTeamIds,
56
+ refreshCache,
57
+ })
58
+ for (const result of enumerated) {
59
+ const ws: ExtractedWorkspace = {
60
+ workspace_id: result.workspace_id,
61
+ workspace_name: result.workspace_name,
62
+ token: result.token,
63
+ cookie,
64
+ }
65
+ await credManager.setWorkspace(ws)
66
+ validWorkspaces.push(ws)
67
+ }
68
+ }
69
+
53
70
  const config = await credManager.load()
54
71
  if (!config.current_workspace && validWorkspaces.length > 0) {
55
72
  await credManager.setCurrentWorkspace(validWorkspaces[0].workspace_id)
@@ -78,6 +95,40 @@ export type RefreshContext = {
78
95
  refreshCache?: Map<string, RefreshResult | null>
79
96
  }
80
97
 
98
+ // A single valid Slack `d` cookie can authenticate every workspace the user is signed into.
99
+ // Given the workspace->domain map from root-state.json, enumerate ALL known domains via web
100
+ // refresh so that workspaces without their own extracted xoxc token are still recovered. The
101
+ // number of recoverable workspaces must not be bounded by the number of extracted token blobs.
102
+ export async function refreshKnownWorkspaceDomains(
103
+ cookie: string,
104
+ workspaceDomains: Record<string, string>,
105
+ context: RefreshContext = {},
106
+ ): Promise<RefreshResult[]> {
107
+ if (!cookie) return []
108
+
109
+ const resolvedTeamIds = context.resolvedTeamIds ?? new Set<string>()
110
+ const results: RefreshResult[] = []
111
+ for (const domain of Object.values(workspaceDomains)) {
112
+ if (!SLACK_DOMAIN_REGEX.test(domain)) continue
113
+
114
+ const cacheKey = `${cookie}\u0000${domain}`
115
+ let result: RefreshResult | null
116
+ const cached = context.refreshCache?.get(cacheKey)
117
+ if (cached !== undefined) {
118
+ result = cached
119
+ } else {
120
+ result = await refreshAndVerify(cookie, domain, domain)
121
+ context.refreshCache?.set(cacheKey, result)
122
+ }
123
+
124
+ if (!result) continue
125
+ if (resolvedTeamIds.has(result.workspace_id)) continue
126
+ resolvedTeamIds.add(result.workspace_id)
127
+ results.push(result)
128
+ }
129
+ return results
130
+ }
131
+
81
132
  export async function tryWebTokenRefresh(
82
133
  ws: ExtractedWorkspace,
83
134
  workspaceDomains: Record<string, string>,
@@ -1,11 +1,13 @@
1
1
  import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'
2
+ import { createRequire } from 'node:module'
2
3
  import { tmpdir } from 'node:os'
3
4
  import { join } from 'node:path'
4
5
 
5
- import Database from 'better-sqlite3'
6
-
7
6
  import { TokenExtractor } from './token-extractor'
8
7
 
8
+ const require = createRequire(import.meta.url)
9
+ const { DatabaseSync } = require('node:sqlite')
10
+
9
11
  const tempDir = mkdtempSync(join(tmpdir(), 'token-extractor-test-'))
10
12
  const slackDir = join(tempDir, 'Slack')
11
13
  mkdirSync(slackDir)
@@ -13,7 +15,7 @@ mkdirSync(slackDir)
13
15
  const dbPath = join(slackDir, 'Cookies')
14
16
 
15
17
  try {
16
- const db = new Database(dbPath)
18
+ const db = new DatabaseSync(dbPath)
17
19
  db.exec(`
18
20
  CREATE TABLE cookies (
19
21
  name TEXT,
@@ -1,7 +1,6 @@
1
1
  import { execSync } from 'node:child_process'
2
2
  import { createDecipheriv, pbkdf2Sync } from 'node:crypto'
3
3
  import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs'
4
- import { createRequire } from 'node:module'
5
4
  import { homedir, tmpdir } from 'node:os'
6
5
  import { join } from 'node:path'
7
6
 
@@ -16,11 +15,10 @@ import {
16
15
  getBrowserBasePath,
17
16
  getAgentBrowserProfileDirs,
18
17
  } from '@/shared/chromium'
18
+ import { openReadonlyDatabase } from '@/shared/sqlite'
19
19
  import { DerivedKeyCache } from '@/shared/utils/derived-key-cache'
20
20
  import { lookupLinuxKeyringPassword } from '@/shared/utils/linux-keyring'
21
21
 
22
- const require = createRequire(import.meta.url)
23
-
24
22
  export interface ExtractedWorkspace {
25
23
  workspace_id: string
26
24
  workspace_name: string
@@ -872,16 +870,11 @@ export class TokenExtractor {
872
870
  encrypted_value?: Uint8Array | Buffer
873
871
  } | null
874
872
 
873
+ const db = openReadonlyDatabase(tempDbPath)
875
874
  let row: CookieRow
876
- if (typeof globalThis.Bun !== 'undefined') {
877
- const { Database } = require('bun:sqlite')
878
- const db = new Database(tempDbPath, { readonly: true })
879
- row = db.query(sql).get() as CookieRow
880
- db.close()
881
- } else {
882
- const Database = require('better-sqlite3')
883
- const db = new Database(tempDbPath, { readonly: true })
875
+ try {
884
876
  row = db.prepare(sql).get() as CookieRow
877
+ } finally {
885
878
  db.close()
886
879
  }
887
880
 
@@ -0,0 +1,70 @@
1
+ import { mkdtempSync, rmSync } from 'node:fs'
2
+ import { createRequire } from 'node:module'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+
6
+ import { ChromiumCookieReader } from '@/shared/chromium'
7
+
8
+ const require = createRequire(import.meta.url)
9
+ const { DatabaseSync } = require('node:sqlite')
10
+
11
+ function fail(message: string): never {
12
+ console.error(message)
13
+ process.exit(1)
14
+ }
15
+
16
+ const tempDir = mkdtempSync(join(tmpdir(), 'cookie-reader-node-test-'))
17
+ const dbPath = join(tempDir, 'Cookies')
18
+
19
+ try {
20
+ const db = new DatabaseSync(dbPath)
21
+ db.exec('CREATE TABLE cookies (name TEXT, value TEXT, encrypted_value BLOB, host_key TEXT, last_access_utc INTEGER)')
22
+ const insert = db.prepare(
23
+ 'INSERT INTO cookies (name, value, encrypted_value, host_key, last_access_utc) VALUES (?, ?, ?, ?, ?)',
24
+ )
25
+ // Chromium *_utc columns are microseconds since 1601; real values exceed
26
+ // Number.MAX_SAFE_INTEGER, which node:sqlite throws on when SELECTed.
27
+ insert.run('d', 'xoxd-newer', null, '.slack.com', 13380000000000200n)
28
+ insert.run('d', 'xoxd-older', null, '.slack.com', 13380000000000100n)
29
+ insert.run('other', 'ignored', null, '.example.com', 13380000000000300n)
30
+ insert.run('enc', '', Buffer.from('v10-secret-bytes'), '.slack.com', 13380000000000100n)
31
+ db.close()
32
+
33
+ if (typeof (globalThis as { Bun?: unknown }).Bun !== 'undefined') {
34
+ fail('Expected Node.js runtime (node:sqlite), but Bun was detected')
35
+ }
36
+
37
+ const reader = new ChromiumCookieReader()
38
+
39
+ const first = await reader.queryFirst<{ value: string }>(
40
+ dbPath,
41
+ "SELECT value FROM cookies WHERE name = 'd' AND host_key LIKE ? ORDER BY last_access_utc DESC LIMIT 1",
42
+ ['%slack.com%'],
43
+ )
44
+ if (first?.value !== 'xoxd-newer') fail(`queryFirst expected xoxd-newer, got ${String(first?.value)}`)
45
+
46
+ const all = await reader.queryAll<{ value: string }>(
47
+ dbPath,
48
+ "SELECT value FROM cookies WHERE name = 'd' ORDER BY last_access_utc DESC",
49
+ )
50
+ if (all.length !== 2) fail(`queryAll expected 2 rows, got ${all.length}`)
51
+ if (all[0]?.value !== 'xoxd-newer') fail('queryAll expected newest-first ordering')
52
+
53
+ const none = await reader.queryFirst(dbPath, "SELECT value FROM cookies WHERE host_key = 'no.match'")
54
+ if (none !== null) fail(`no-row queryFirst expected null, got ${String(none)}`)
55
+
56
+ // node:sqlite returns BLOBs as Uint8Array (not Buffer); Buffer.from must
57
+ // preserve the bytes so cookie decryption keeps working
58
+ const encrypted = await reader.queryFirst<{ encrypted_value?: Uint8Array }>(
59
+ dbPath,
60
+ "SELECT encrypted_value FROM cookies WHERE name = 'enc' LIMIT 1",
61
+ )
62
+ if (!(encrypted?.encrypted_value instanceof Uint8Array)) fail('encrypted_value expected Uint8Array')
63
+ if (Buffer.from(encrypted.encrypted_value).toString('utf8') !== 'v10-secret-bytes') {
64
+ fail('Buffer.from(BLOB) did not round-trip the original bytes')
65
+ }
66
+
67
+ console.log('ok')
68
+ } finally {
69
+ rmSync(tempDir, { recursive: true })
70
+ }
@@ -0,0 +1,10 @@
1
+ import { expect, it } from 'bun:test'
2
+ import { execSync } from 'node:child_process'
3
+
4
+ it('ChromiumCookieReader works in Node.js (node:sqlite)', () => {
5
+ const result = execSync('bun tsx src/shared/chromium/cookie-reader-node-test.ts', {
6
+ cwd: process.cwd(),
7
+ encoding: 'utf-8',
8
+ })
9
+ expect(result.trim()).toBe('ok')
10
+ })
@@ -1,9 +1,8 @@
1
1
  import { copyFileSync, existsSync, rmSync } from 'node:fs'
2
- import { createRequire } from 'node:module'
3
2
  import { tmpdir } from 'node:os'
4
3
  import { join } from 'node:path'
5
4
 
6
- const require = createRequire(import.meta.url)
5
+ import { openReadonlyDatabase } from '@/shared/sqlite'
7
6
 
8
7
  /**
9
8
  * Reads Chromium SQLite cookie databases with Bun/Node dual-runtime support.
@@ -70,24 +69,7 @@ export class ChromiumCookieReader {
70
69
  params: unknown[] | undefined,
71
70
  mode: 'all' | 'first',
72
71
  ): T[] | T | null {
73
- if (typeof globalThis.Bun !== 'undefined') {
74
- const { Database } = require('bun:sqlite')
75
- const db = new Database(tempPath, { readonly: true })
76
-
77
- try {
78
- const stmt = db.query(sql)
79
- if (mode === 'all') {
80
- return (params ? stmt.all(...params) : stmt.all()) as T[]
81
- }
82
-
83
- return (params ? stmt.get(...params) : stmt.get()) as T | null
84
- } finally {
85
- db.close()
86
- }
87
- }
88
-
89
- const Database = require('better-sqlite3')
90
- const db = new Database(tempPath, { readonly: true })
72
+ const db = openReadonlyDatabase(tempPath)
91
73
 
92
74
  try {
93
75
  const stmt = db.prepare(sql)
@@ -95,7 +77,8 @@ export class ChromiumCookieReader {
95
77
  return (params ? stmt.all(...params) : stmt.all()) as T[]
96
78
  }
97
79
 
98
- return (params ? stmt.get(...params) : stmt.get()) as T | null
80
+ const row = params ? stmt.get(...params) : stmt.get()
81
+ return (row ?? null) as T | null
99
82
  } finally {
100
83
  db.close()
101
84
  }