agent-messenger 2.1.0 → 2.3.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 (217) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.env.template +35 -17
  3. package/README.md +7 -7
  4. package/bun.lock +31 -7
  5. package/dist/package.json +5 -3
  6. package/dist/src/platforms/channeltalk/commands/auth.d.ts.map +1 -1
  7. package/dist/src/platforms/channeltalk/commands/auth.js +35 -28
  8. package/dist/src/platforms/channeltalk/commands/auth.js.map +1 -1
  9. package/dist/src/platforms/channeltalk/ensure-auth.js +6 -6
  10. package/dist/src/platforms/channeltalk/ensure-auth.js.map +1 -1
  11. package/dist/src/platforms/channeltalk/token-extractor.d.ts +23 -1
  12. package/dist/src/platforms/channeltalk/token-extractor.d.ts.map +1 -1
  13. package/dist/src/platforms/channeltalk/token-extractor.js +299 -29
  14. package/dist/src/platforms/channeltalk/token-extractor.js.map +1 -1
  15. package/dist/src/platforms/discord/commands/auth.d.ts.map +1 -1
  16. package/dist/src/platforms/discord/commands/auth.js +57 -49
  17. package/dist/src/platforms/discord/commands/auth.js.map +1 -1
  18. package/dist/src/platforms/discord/ensure-auth.js +3 -3
  19. package/dist/src/platforms/discord/ensure-auth.js.map +1 -1
  20. package/dist/src/platforms/discord/token-extractor.d.ts +6 -1
  21. package/dist/src/platforms/discord/token-extractor.d.ts.map +1 -1
  22. package/dist/src/platforms/discord/token-extractor.js +167 -14
  23. package/dist/src/platforms/discord/token-extractor.js.map +1 -1
  24. package/dist/src/platforms/instagram/client.d.ts +2 -0
  25. package/dist/src/platforms/instagram/client.d.ts.map +1 -1
  26. package/dist/src/platforms/instagram/client.js +2 -2
  27. package/dist/src/platforms/instagram/client.js.map +1 -1
  28. package/dist/src/platforms/instagram/commands/auth.d.ts.map +1 -1
  29. package/dist/src/platforms/instagram/commands/auth.js +107 -14
  30. package/dist/src/platforms/instagram/commands/auth.js.map +1 -1
  31. package/dist/src/platforms/instagram/ensure-auth.d.ts.map +1 -1
  32. package/dist/src/platforms/instagram/ensure-auth.js +57 -11
  33. package/dist/src/platforms/instagram/ensure-auth.js.map +1 -1
  34. package/dist/src/platforms/instagram/index.d.ts +1 -0
  35. package/dist/src/platforms/instagram/index.d.ts.map +1 -1
  36. package/dist/src/platforms/instagram/index.js +1 -0
  37. package/dist/src/platforms/instagram/index.js.map +1 -1
  38. package/dist/src/platforms/instagram/token-extractor.d.ts +44 -0
  39. package/dist/src/platforms/instagram/token-extractor.d.ts.map +1 -0
  40. package/dist/src/platforms/instagram/token-extractor.js +407 -0
  41. package/dist/src/platforms/instagram/token-extractor.js.map +1 -0
  42. package/dist/src/platforms/kakaotalk/client.d.ts.map +1 -1
  43. package/dist/src/platforms/kakaotalk/client.js +2 -1
  44. package/dist/src/platforms/kakaotalk/client.js.map +1 -1
  45. package/dist/src/platforms/kakaotalk/commands/auth.d.ts.map +1 -1
  46. package/dist/src/platforms/kakaotalk/commands/auth.js +14 -13
  47. package/dist/src/platforms/kakaotalk/commands/auth.js.map +1 -1
  48. package/dist/src/platforms/kakaotalk/protocol/connection.d.ts.map +1 -1
  49. package/dist/src/platforms/kakaotalk/protocol/connection.js +2 -1
  50. package/dist/src/platforms/kakaotalk/protocol/connection.js.map +1 -1
  51. package/dist/src/platforms/line/client.d.ts.map +1 -1
  52. package/dist/src/platforms/line/client.js +36 -9
  53. package/dist/src/platforms/line/client.js.map +1 -1
  54. package/dist/src/platforms/line/commands/auth.d.ts.map +1 -1
  55. package/dist/src/platforms/line/commands/auth.js +6 -5
  56. package/dist/src/platforms/line/commands/auth.js.map +1 -1
  57. package/dist/src/platforms/slack/commands/auth.d.ts.map +1 -1
  58. package/dist/src/platforms/slack/commands/auth.js +11 -10
  59. package/dist/src/platforms/slack/commands/auth.js.map +1 -1
  60. package/dist/src/platforms/slack/token-extractor.d.ts +9 -0
  61. package/dist/src/platforms/slack/token-extractor.d.ts.map +1 -1
  62. package/dist/src/platforms/slack/token-extractor.js +300 -23
  63. package/dist/src/platforms/slack/token-extractor.js.map +1 -1
  64. package/dist/src/platforms/teams/commands/auth.d.ts.map +1 -1
  65. package/dist/src/platforms/teams/commands/auth.js +9 -8
  66. package/dist/src/platforms/teams/commands/auth.js.map +1 -1
  67. package/dist/src/platforms/teams/ensure-auth.d.ts.map +1 -1
  68. package/dist/src/platforms/teams/ensure-auth.js +2 -1
  69. package/dist/src/platforms/teams/ensure-auth.js.map +1 -1
  70. package/dist/src/platforms/teams/token-extractor.d.ts +5 -0
  71. package/dist/src/platforms/teams/token-extractor.d.ts.map +1 -1
  72. package/dist/src/platforms/teams/token-extractor.js +161 -29
  73. package/dist/src/platforms/teams/token-extractor.js.map +1 -1
  74. package/dist/src/platforms/telegram/client.d.ts.map +1 -1
  75. package/dist/src/platforms/telegram/client.js +25 -7
  76. package/dist/src/platforms/telegram/client.js.map +1 -1
  77. package/dist/src/platforms/telegram/commands/auth.d.ts.map +1 -1
  78. package/dist/src/platforms/telegram/commands/auth.js +6 -5
  79. package/dist/src/platforms/telegram/commands/auth.js.map +1 -1
  80. package/dist/src/platforms/webex/client.d.ts +12 -0
  81. package/dist/src/platforms/webex/client.d.ts.map +1 -1
  82. package/dist/src/platforms/webex/client.js +168 -1
  83. package/dist/src/platforms/webex/client.js.map +1 -1
  84. package/dist/src/platforms/webex/commands/auth.d.ts +4 -0
  85. package/dist/src/platforms/webex/commands/auth.d.ts.map +1 -1
  86. package/dist/src/platforms/webex/commands/auth.js +50 -4
  87. package/dist/src/platforms/webex/commands/auth.js.map +1 -1
  88. package/dist/src/platforms/webex/credential-manager.js +1 -1
  89. package/dist/src/platforms/webex/credential-manager.js.map +1 -1
  90. package/dist/src/platforms/webex/encryption.d.ts +10 -0
  91. package/dist/src/platforms/webex/encryption.d.ts.map +1 -0
  92. package/dist/src/platforms/webex/encryption.js +49 -0
  93. package/dist/src/platforms/webex/encryption.js.map +1 -0
  94. package/dist/src/platforms/webex/ensure-auth.d.ts.map +1 -1
  95. package/dist/src/platforms/webex/ensure-auth.js +25 -5
  96. package/dist/src/platforms/webex/ensure-auth.js.map +1 -1
  97. package/dist/src/platforms/webex/index.d.ts +2 -0
  98. package/dist/src/platforms/webex/index.d.ts.map +1 -1
  99. package/dist/src/platforms/webex/index.js +1 -0
  100. package/dist/src/platforms/webex/index.js.map +1 -1
  101. package/dist/src/platforms/webex/token-extractor.d.ts +29 -0
  102. package/dist/src/platforms/webex/token-extractor.d.ts.map +1 -0
  103. package/dist/src/platforms/webex/token-extractor.js +393 -0
  104. package/dist/src/platforms/webex/token-extractor.js.map +1 -0
  105. package/dist/src/platforms/webex/types.d.ts +8 -1
  106. package/dist/src/platforms/webex/types.d.ts.map +1 -1
  107. package/dist/src/platforms/webex/types.js +4 -1
  108. package/dist/src/platforms/webex/types.js.map +1 -1
  109. package/dist/src/platforms/whatsapp/client.d.ts.map +1 -1
  110. package/dist/src/platforms/whatsapp/client.js +6 -2
  111. package/dist/src/platforms/whatsapp/client.js.map +1 -1
  112. package/dist/src/shared/utils/derived-key-cache.d.ts +1 -1
  113. package/dist/src/shared/utils/derived-key-cache.d.ts.map +1 -1
  114. package/dist/src/shared/utils/error-handler.d.ts +1 -1
  115. package/dist/src/shared/utils/error-handler.d.ts.map +1 -1
  116. package/dist/src/shared/utils/error-handler.js +3 -2
  117. package/dist/src/shared/utils/error-handler.js.map +1 -1
  118. package/dist/src/shared/utils/stderr.d.ts +5 -0
  119. package/dist/src/shared/utils/stderr.d.ts.map +1 -0
  120. package/dist/src/shared/utils/stderr.js +18 -0
  121. package/dist/src/shared/utils/stderr.js.map +1 -0
  122. package/docs/content/docs/cli/channeltalk.mdx +7 -7
  123. package/docs/content/docs/cli/discord.mdx +3 -3
  124. package/docs/content/docs/cli/instagram.mdx +28 -6
  125. package/docs/content/docs/cli/slack.mdx +2 -2
  126. package/docs/content/docs/cli/teams.mdx +6 -4
  127. package/docs/content/docs/cli/webex.mdx +32 -11
  128. package/e2e/README.md +132 -8
  129. package/e2e/channeltalk.e2e.test.ts +2 -7
  130. package/e2e/channeltalkbot.e2e.test.ts +2 -6
  131. package/e2e/config.ts +172 -10
  132. package/e2e/helpers.ts +7 -0
  133. package/e2e/instagram.e2e.test.ts +97 -0
  134. package/e2e/kakaotalk.e2e.test.ts +74 -0
  135. package/e2e/line.e2e.test.ts +92 -0
  136. package/e2e/teams.e2e.test.ts +46 -1
  137. package/e2e/telegram.e2e.test.ts +84 -0
  138. package/e2e/webex.e2e.test.ts +190 -0
  139. package/e2e/whatsapp.e2e.test.ts +90 -0
  140. package/e2e/whatsappbot.e2e.test.ts +78 -0
  141. package/package.json +5 -3
  142. package/skills/agent-channeltalk/SKILL.md +9 -9
  143. package/skills/agent-channeltalk/references/authentication.md +21 -18
  144. package/skills/agent-channeltalkbot/SKILL.md +1 -1
  145. package/skills/agent-discord/SKILL.md +5 -5
  146. package/skills/agent-discord/references/authentication.md +8 -8
  147. package/skills/agent-discordbot/SKILL.md +1 -1
  148. package/skills/agent-instagram/SKILL.md +51 -9
  149. package/skills/agent-instagram/references/authentication.md +35 -3
  150. package/skills/agent-kakaotalk/SKILL.md +1 -1
  151. package/skills/agent-line/SKILL.md +1 -1
  152. package/skills/agent-slack/SKILL.md +5 -5
  153. package/skills/agent-slack/references/authentication.md +8 -8
  154. package/skills/agent-slackbot/SKILL.md +1 -1
  155. package/skills/agent-teams/SKILL.md +6 -6
  156. package/skills/agent-teams/references/authentication.md +8 -8
  157. package/skills/agent-telegram/SKILL.md +1 -1
  158. package/skills/agent-webex/SKILL.md +35 -15
  159. package/skills/agent-webex/references/authentication.md +63 -9
  160. package/skills/agent-webex/references/common-patterns.md +6 -3
  161. package/skills/agent-whatsapp/SKILL.md +1 -1
  162. package/skills/agent-whatsappbot/SKILL.md +1 -1
  163. package/src/platforms/channeltalk/commands/auth.test.ts +5 -5
  164. package/src/platforms/channeltalk/commands/auth.ts +38 -32
  165. package/src/platforms/channeltalk/ensure-auth.test.ts +6 -6
  166. package/src/platforms/channeltalk/ensure-auth.ts +6 -6
  167. package/src/platforms/channeltalk/token-extractor.test.ts +182 -15
  168. package/src/platforms/channeltalk/token-extractor.ts +344 -30
  169. package/src/platforms/discord/commands/auth.test.ts +3 -3
  170. package/src/platforms/discord/commands/auth.ts +58 -54
  171. package/src/platforms/discord/ensure-auth.test.ts +3 -3
  172. package/src/platforms/discord/ensure-auth.ts +3 -3
  173. package/src/platforms/discord/token-extractor.test.ts +199 -27
  174. package/src/platforms/discord/token-extractor.ts +190 -17
  175. package/src/platforms/instagram/client.ts +2 -2
  176. package/src/platforms/instagram/commands/auth.ts +133 -14
  177. package/src/platforms/instagram/ensure-auth.ts +63 -12
  178. package/src/platforms/instagram/index.ts +1 -0
  179. package/src/platforms/instagram/token-extractor.test.ts +424 -0
  180. package/src/platforms/instagram/token-extractor.ts +478 -0
  181. package/src/platforms/kakaotalk/client.ts +3 -1
  182. package/src/platforms/kakaotalk/commands/auth.ts +14 -13
  183. package/src/platforms/kakaotalk/protocol/connection.ts +3 -1
  184. package/src/platforms/line/client.ts +39 -14
  185. package/src/platforms/line/commands/auth.ts +7 -6
  186. package/src/platforms/slack/cli.test.ts +6 -5
  187. package/src/platforms/slack/commands/auth.test.ts +11 -7
  188. package/src/platforms/slack/commands/auth.ts +11 -10
  189. package/src/platforms/slack/token-extractor.test.ts +98 -1
  190. package/src/platforms/slack/token-extractor.ts +338 -26
  191. package/src/platforms/teams/commands/auth.ts +9 -8
  192. package/src/platforms/teams/ensure-auth.ts +3 -1
  193. package/src/platforms/teams/token-extractor.test.ts +136 -17
  194. package/src/platforms/teams/token-extractor.ts +182 -31
  195. package/src/platforms/telegram/client.test.ts +134 -0
  196. package/src/platforms/telegram/client.ts +27 -6
  197. package/src/platforms/telegram/commands/auth.ts +6 -5
  198. package/src/platforms/webex/client.test.ts +314 -0
  199. package/src/platforms/webex/client.ts +231 -1
  200. package/src/platforms/webex/commands/auth.ts +71 -4
  201. package/src/platforms/webex/commands/member.test.ts +10 -1
  202. package/src/platforms/webex/commands/message.test.ts +9 -5
  203. package/src/platforms/webex/commands/snapshot.test.ts +13 -4
  204. package/src/platforms/webex/commands/space.test.ts +12 -2
  205. package/src/platforms/webex/credential-manager.ts +1 -1
  206. package/src/platforms/webex/encryption.ts +53 -0
  207. package/src/platforms/webex/ensure-auth.test.ts +4 -0
  208. package/src/platforms/webex/ensure-auth.ts +27 -4
  209. package/src/platforms/webex/index.ts +2 -0
  210. package/src/platforms/webex/token-extractor.test.ts +327 -0
  211. package/src/platforms/webex/token-extractor.ts +460 -0
  212. package/src/platforms/webex/types.ts +8 -2
  213. package/src/platforms/webex/typings/node-jose.d.ts +27 -0
  214. package/src/platforms/whatsapp/client.ts +11 -7
  215. package/src/shared/utils/derived-key-cache.ts +1 -1
  216. package/src/shared/utils/error-handler.ts +4 -2
  217. package/src/shared/utils/stderr.ts +22 -0
@@ -1,4 +1,4 @@
1
- import { afterEach, describe, expect, test } from 'bun:test'
1
+ import { afterEach, describe, expect, spyOn, test } from 'bun:test'
2
2
  import { createCipheriv, randomBytes } from 'node:crypto'
3
3
  import { existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
4
4
  import { tmpdir } from 'node:os'
@@ -57,8 +57,47 @@ describe('ChannelTokenExtractor', () => {
57
57
  })
58
58
  })
59
59
 
60
+ describe('getBrowserCookiesPaths', () => {
61
+ test('returns browser cookie paths on macOS including Default profile', () => {
62
+ const extractor = new ChannelTokenExtractor('darwin')
63
+ const paths = extractor.getBrowserCookiesPaths()
64
+
65
+ const chromeBase = join(
66
+ process.env.HOME || '/tmp',
67
+ 'Library',
68
+ 'Application Support',
69
+ 'Google',
70
+ 'Chrome',
71
+ )
72
+ expect(paths).toContain(join(chromeBase, 'Default', 'Cookies'))
73
+ expect(paths).toContain(join(chromeBase, 'Default', 'Network', 'Cookies'))
74
+ })
75
+
76
+ test('returns browser cookie paths on Linux', () => {
77
+ const extractor = new ChannelTokenExtractor('linux')
78
+ const paths = extractor.getBrowserCookiesPaths()
79
+
80
+ const chromeBase = join(process.env.HOME || '/tmp', '.config', 'google-chrome')
81
+ expect(paths).toContain(join(chromeBase, 'Default', 'Cookies'))
82
+ })
83
+
84
+ test('returns browser cookie paths on Windows', () => {
85
+ const extractor = new ChannelTokenExtractor('win32')
86
+ const paths = extractor.getBrowserCookiesPaths()
87
+
88
+ const localAppData = process.env.LOCALAPPDATA || join(process.env.HOME || '/tmp', 'AppData', 'Local')
89
+ const chromeBase = join(localAppData, 'Google', 'Chrome', 'User Data')
90
+ expect(paths).toContain(join(chromeBase, 'Default', 'Cookies'))
91
+ })
92
+
93
+ test('returns empty array for unsupported platform', () => {
94
+ const extractor = new ChannelTokenExtractor('freebsd' as NodeJS.Platform)
95
+ expect(extractor.getBrowserCookiesPaths()).toEqual([])
96
+ })
97
+ })
98
+
60
99
  describe('extract', () => {
61
- test('returns null when cookies path does not exist', async () => {
100
+ test('returns empty array when desktop cookies path does not exist', async () => {
62
101
  class MissingPathExtractor extends ChannelTokenExtractor {
63
102
  override getCookiesPath(): string | null {
64
103
  return null
@@ -67,7 +106,45 @@ describe('ChannelTokenExtractor', () => {
67
106
 
68
107
  const extractor = new MissingPathExtractor('darwin')
69
108
 
70
- expect(await extractor.extract()).toBeNull()
109
+ expect(await extractor.extract()).toEqual([])
110
+ })
111
+
112
+ test('tries desktop app before browser profiles and collects both', async () => {
113
+ const extractor = new ChannelTokenExtractor('darwin')
114
+
115
+ const desktopSpy = spyOn(extractor as any, 'extractFromDesktopApp').mockResolvedValue({
116
+ accountCookie: 'desktop-account',
117
+ sessionCookie: 'desktop-session',
118
+ })
119
+ const browserSpy = spyOn(extractor as any, 'extractAllFromBrowserPaths').mockResolvedValue([])
120
+
121
+ const result = await extractor.extract()
122
+
123
+ expect(desktopSpy).toHaveBeenCalled()
124
+ expect(browserSpy).toHaveBeenCalled()
125
+ expect(result[0]?.accountCookie).toBe('desktop-account')
126
+
127
+ desktopSpy.mockRestore()
128
+ browserSpy.mockRestore()
129
+ })
130
+
131
+ test('includes browser profiles even when desktop extraction returns null', async () => {
132
+ const extractor = new ChannelTokenExtractor('darwin')
133
+
134
+ const desktopSpy = spyOn(extractor as any, 'extractFromDesktopApp').mockResolvedValue(null)
135
+ const browserSpy = spyOn(extractor as any, 'extractAllFromBrowserPaths').mockResolvedValue([{
136
+ accountCookie: 'browser-account',
137
+ sessionCookie: undefined,
138
+ }])
139
+
140
+ const result = await extractor.extract()
141
+
142
+ expect(desktopSpy).toHaveBeenCalled()
143
+ expect(browserSpy).toHaveBeenCalled()
144
+ expect(result[0]?.accountCookie).toBe('browser-account')
145
+
146
+ desktopSpy.mockRestore()
147
+ browserSpy.mockRestore()
71
148
  })
72
149
 
73
150
  test('extracts plaintext cookies from a real sqlite database', async () => {
@@ -92,10 +169,10 @@ describe('ChannelTokenExtractor', () => {
92
169
 
93
170
  const extractor = new TestExtractor(dbPath)
94
171
 
95
- expect(await extractor.extract()).toEqual({
172
+ expect(await extractor.extract()).toEqual([{
96
173
  accountCookie: 'account-jwt',
97
174
  sessionCookie: 'session-jwt',
98
- })
175
+ }])
99
176
  })
100
177
 
101
178
  test('returns token with undefined sessionCookie when only x-account is present', async () => {
@@ -119,12 +196,12 @@ describe('ChannelTokenExtractor', () => {
119
196
  const extractor = new TestExtractor(dbPath)
120
197
  const result = await extractor.extract()
121
198
 
122
- expect(result).not.toBeNull()
123
- expect(result?.accountCookie).toBe('account-jwt')
124
- expect(result?.sessionCookie).toBeUndefined()
199
+ expect(result).not.toEqual([])
200
+ expect(result[0]?.accountCookie).toBe('account-jwt')
201
+ expect(result[0]?.sessionCookie).toBeUndefined()
125
202
  })
126
203
 
127
- test('returns null when x-account is missing', async () => {
204
+ test('returns empty array when x-account is missing', async () => {
128
205
  const tempDir = mkdtempSync(join(tmpdir(), 'channel-cookie-db-'))
129
206
  tempDirs.push(tempDir)
130
207
  const dbPath = join(tempDir, 'Cookies')
@@ -144,7 +221,7 @@ describe('ChannelTokenExtractor', () => {
144
221
 
145
222
  const extractor = new TestExtractor(dbPath)
146
223
 
147
- expect(await extractor.extract()).toBeNull()
224
+ expect(await extractor.extract()).toEqual([])
148
225
  })
149
226
 
150
227
  test('decrypts AES-256-GCM encrypted cookies using master key', async () => {
@@ -194,12 +271,53 @@ describe('ChannelTokenExtractor', () => {
194
271
  const result = await extractor.extract()
195
272
 
196
273
  // then
197
- expect(result).not.toBeNull()
198
- expect(result?.accountCookie).toBe('encrypted-account-jwt')
199
- expect(result?.sessionCookie).toBe('encrypted-session-jwt')
274
+ expect(result).not.toEqual([])
275
+ expect(result[0]?.accountCookie).toBe('encrypted-account-jwt')
276
+ expect(result[0]?.sessionCookie).toBe('encrypted-session-jwt')
200
277
  })
201
278
 
202
- test('returns null when DPAPI decryption fails', async () => {
279
+ test('deduplicates entries with the same accountCookie from desktop and browser', async () => {
280
+ const extractor = new ChannelTokenExtractor('darwin')
281
+
282
+ const desktopSpy = spyOn(extractor as any, 'extractFromDesktopApp').mockResolvedValue({
283
+ accountCookie: 'same-account-cookie',
284
+ sessionCookie: 'desktop-session',
285
+ })
286
+ const browserSpy = spyOn(extractor as any, 'extractAllFromBrowserPaths').mockResolvedValue([{
287
+ accountCookie: 'same-account-cookie',
288
+ sessionCookie: 'browser-session',
289
+ }])
290
+
291
+ const result = await extractor.extract()
292
+ expect(result).toHaveLength(1)
293
+ expect(result[0]?.accountCookie).toBe('same-account-cookie')
294
+
295
+ desktopSpy.mockRestore()
296
+ browserSpy.mockRestore()
297
+ })
298
+
299
+ test('returns multiple distinct accounts from desktop and browser sources', async () => {
300
+ const extractor = new ChannelTokenExtractor('darwin')
301
+
302
+ const desktopSpy = spyOn(extractor as any, 'extractFromDesktopApp').mockResolvedValue({
303
+ accountCookie: 'desktop-account-cookie',
304
+ sessionCookie: 'desktop-session',
305
+ })
306
+ const browserSpy = spyOn(extractor as any, 'extractAllFromBrowserPaths').mockResolvedValue([{
307
+ accountCookie: 'browser-account-cookie',
308
+ sessionCookie: 'browser-session',
309
+ }])
310
+
311
+ const result = await extractor.extract()
312
+ expect(result).toHaveLength(2)
313
+ expect(result.map((r) => r.accountCookie)).toContain('desktop-account-cookie')
314
+ expect(result.map((r) => r.accountCookie)).toContain('browser-account-cookie')
315
+
316
+ desktopSpy.mockRestore()
317
+ browserSpy.mockRestore()
318
+ })
319
+
320
+ test('returns empty array when DPAPI decryption fails', async () => {
203
321
  // given
204
322
  const masterKey = randomBytes(32)
205
323
  const encryptAccount = encryptAESGCM('account-jwt', masterKey)
@@ -238,7 +356,7 @@ describe('ChannelTokenExtractor', () => {
238
356
  const result = await extractor.extract()
239
357
 
240
358
  // then
241
- expect(result).toBeNull()
359
+ expect(result).toEqual([])
242
360
  })
243
361
  })
244
362
 
@@ -248,6 +366,55 @@ describe('ChannelTokenExtractor', () => {
248
366
  expect(extractor.decryptDPAPI(Buffer.from('test'))).toBeNull()
249
367
  })
250
368
  })
369
+
370
+ describe('decryptBrowserCookie', () => {
371
+ test('decrypts v10-prefixed browser cookie using macOS keychain password (AES-128-CBC)', () => {
372
+ // given — AES-128-CBC encrypted cookie with macOS keychain-derived key
373
+ const { createCipheriv, pbkdf2Sync } = require('node:crypto')
374
+ const password = 'test-keychain-password'
375
+ const key = pbkdf2Sync(password, 'saltysalt', 1003, 16, 'sha1')
376
+ const iv = Buffer.alloc(16, 0x20)
377
+ const plainValue = 'test-channel-account-value'
378
+
379
+ const cipher = createCipheriv('aes-128-cbc', key, iv)
380
+ const ciphertext = Buffer.concat([cipher.update(plainValue, 'utf8'), cipher.final()])
381
+ const encrypted = Buffer.concat([Buffer.from('v10'), ciphertext])
382
+
383
+ const darwinExtractor = new ChannelTokenExtractor('darwin')
384
+ const execSecuritySpy = spyOn(darwinExtractor as any, 'execSecurityCommand').mockReturnValue(password)
385
+
386
+ // when
387
+ const result = (darwinExtractor as any).decryptBrowserCookie(encrypted, '/fake/path/Cookies')
388
+
389
+ // then
390
+ expect(result).toBe(plainValue)
391
+
392
+ execSecuritySpy.mockRestore()
393
+ })
394
+
395
+ test('decrypts v10-prefixed browser cookie using Linux peanuts key (AES-128-CBC)', () => {
396
+ // given — AES-128-CBC encrypted cookie with Linux Chromium peanuts key
397
+ const { createCipheriv, pbkdf2Sync } = require('node:crypto')
398
+ const key = pbkdf2Sync('peanuts', 'saltysalt', 1, 16, 'sha1')
399
+ const iv = Buffer.alloc(16, 0x20)
400
+ const plainValue = 'test-channel-account-linux'
401
+
402
+ const cipher = createCipheriv('aes-128-cbc', key, iv)
403
+ const ciphertext = Buffer.concat([cipher.update(plainValue, 'utf8'), cipher.final()])
404
+ const encrypted = Buffer.concat([Buffer.from('v10'), ciphertext])
405
+
406
+ const linuxExtractor = new ChannelTokenExtractor('linux')
407
+
408
+ // when
409
+ const result = (linuxExtractor as any).decryptBrowserCookie(
410
+ encrypted,
411
+ '/home/user/.config/google-chrome/Default/Cookies',
412
+ )
413
+
414
+ // then
415
+ expect(result).toBe(plainValue)
416
+ })
417
+ })
251
418
  })
252
419
 
253
420
  function encryptAESGCM(plaintext: string, key: Buffer): Buffer {
@@ -1,6 +1,6 @@
1
- import { createDecipheriv } from 'node:crypto'
2
- import { copyFileSync, existsSync, readFileSync, unlinkSync } from 'node:fs'
3
1
  import { execSync } from 'node:child_process'
2
+ import { createDecipheriv, pbkdf2Sync } from 'node:crypto'
3
+ import { copyFileSync, existsSync, readFileSync, readdirSync, unlinkSync } from 'node:fs'
4
4
  import { homedir, tmpdir } from 'node:os'
5
5
  import { join } from 'node:path'
6
6
 
@@ -8,8 +8,66 @@ import type { ExtractedChannelToken } from './types'
8
8
 
9
9
  type CookieRow = { name: string; value: string; encrypted_value: Uint8Array | Buffer }
10
10
 
11
+ interface BrowserConfig {
12
+ name: string
13
+ darwin: string
14
+ linux: string
15
+ win32: string
16
+ }
17
+
18
+ interface KeychainVariant {
19
+ service: string
20
+ account: string
21
+ }
22
+
23
+ const BROWSERS: BrowserConfig[] = [
24
+ {
25
+ name: 'Chrome',
26
+ darwin: join('Google', 'Chrome'),
27
+ linux: 'google-chrome',
28
+ win32: join('Google', 'Chrome', 'User Data'),
29
+ },
30
+ {
31
+ name: 'Chrome Canary',
32
+ darwin: join('Google', 'Chrome Canary'),
33
+ linux: 'google-chrome-unstable',
34
+ win32: join('Google', 'Chrome SxS', 'User Data'),
35
+ },
36
+ {
37
+ name: 'Edge',
38
+ darwin: 'Microsoft Edge',
39
+ linux: 'microsoft-edge',
40
+ win32: join('Microsoft', 'Edge', 'User Data'),
41
+ },
42
+ {
43
+ name: 'Arc',
44
+ darwin: join('Arc', 'User Data'),
45
+ linux: '',
46
+ win32: join('Arc', 'User Data'),
47
+ },
48
+ {
49
+ name: 'Brave',
50
+ darwin: join('BraveSoftware', 'Brave-Browser'),
51
+ linux: join('BraveSoftware', 'Brave-Browser'),
52
+ win32: join('BraveSoftware', 'Brave-Browser', 'User Data'),
53
+ },
54
+ {
55
+ name: 'Vivaldi',
56
+ darwin: 'Vivaldi',
57
+ linux: 'vivaldi',
58
+ win32: join('Vivaldi', 'User Data'),
59
+ },
60
+ {
61
+ name: 'Chromium',
62
+ darwin: 'Chromium',
63
+ linux: 'chromium',
64
+ win32: join('Chromium', 'User Data'),
65
+ },
66
+ ]
67
+
11
68
  export class ChannelTokenExtractor {
12
69
  private platform: NodeJS.Platform
70
+ private cachedKey: Buffer | null = null
13
71
 
14
72
  constructor(platform?: NodeJS.Platform) {
15
73
  this.platform = platform ?? process.platform
@@ -67,7 +125,123 @@ export class ChannelTokenExtractor {
67
125
  return existsSync(localStatePath) ? localStatePath : null
68
126
  }
69
127
 
70
- async extract(): Promise<ExtractedChannelToken | null> {
128
+ getBrowserCookiesPaths(): string[] {
129
+ const paths: string[] = []
130
+
131
+ for (const browser of BROWSERS) {
132
+ const browserBase = this.getBrowserBasePath(browser)
133
+ if (!browserBase) continue
134
+
135
+ const profileDirs = this.discoverProfileDirs(browserBase)
136
+ for (const profileDir of profileDirs) {
137
+ paths.push(join(profileDir, 'Cookies'))
138
+ paths.push(join(profileDir, 'Network', 'Cookies'))
139
+ }
140
+ }
141
+
142
+ return paths
143
+ }
144
+
145
+ private getBrowserBasePath(browser: BrowserConfig): string | null {
146
+ let relative: string
147
+
148
+ switch (this.platform) {
149
+ case 'darwin':
150
+ relative = browser.darwin
151
+ if (!relative) return null
152
+ return join(homedir(), 'Library', 'Application Support', relative)
153
+ case 'linux':
154
+ relative = browser.linux
155
+ if (!relative) return null
156
+ return join(homedir(), '.config', relative)
157
+ case 'win32':
158
+ relative = browser.win32
159
+ if (!relative) return null
160
+ return join(
161
+ process.env.LOCALAPPDATA || join(homedir(), 'AppData', 'Local'),
162
+ relative,
163
+ )
164
+ default:
165
+ return null
166
+ }
167
+ }
168
+
169
+ private discoverProfileDirs(browserBase: string): string[] {
170
+ const dirs: string[] = []
171
+
172
+ dirs.push(join(browserBase, 'Default'))
173
+
174
+ if (!existsSync(browserBase)) return dirs
175
+
176
+ try {
177
+ const entries = readdirSync(browserBase, { withFileTypes: true })
178
+ for (const entry of entries) {
179
+ if (!entry.isDirectory()) continue
180
+ if (!/^Profile \d+$/i.test(entry.name)) continue
181
+ dirs.push(join(browserBase, entry.name))
182
+ }
183
+ } catch {}
184
+
185
+ return dirs
186
+ }
187
+
188
+ private findLocalStateForCookiePath(cookiePath: string): string | null {
189
+ const parts = cookiePath.split(/[/\\]/)
190
+ for (let levels = 2; levels <= 4; levels++) {
191
+ if (parts.length < levels) break
192
+ const base = parts.slice(0, parts.length - levels).join('/')
193
+ const candidate = join(base, 'Local State')
194
+ if (existsSync(candidate)) return candidate
195
+ }
196
+ return null
197
+ }
198
+
199
+ getKeychainVariants(): KeychainVariant[] {
200
+ return [
201
+ { service: 'Chrome Safe Storage', account: 'Chrome' },
202
+ { service: 'Chrome Canary Safe Storage', account: 'Chrome Canary' },
203
+ { service: 'Microsoft Edge Safe Storage', account: 'Microsoft Edge' },
204
+ { service: 'Arc Safe Storage', account: 'Arc' },
205
+ { service: 'Brave Safe Storage', account: 'Brave' },
206
+ { service: 'Vivaldi Safe Storage', account: 'Vivaldi' },
207
+ { service: 'Chromium Safe Storage', account: 'Chromium' },
208
+ ]
209
+ }
210
+
211
+ async extract(): Promise<ExtractedChannelToken[]> {
212
+ const results: ExtractedChannelToken[] = []
213
+ const seenAccounts = new Set<string>()
214
+
215
+ const desktopResult = await this.extractFromDesktopApp()
216
+ if (desktopResult && !seenAccounts.has(desktopResult.accountCookie)) {
217
+ seenAccounts.add(desktopResult.accountCookie)
218
+ results.push(desktopResult)
219
+ }
220
+
221
+ for (const browserResult of await this.extractAllFromBrowserPaths()) {
222
+ if (!seenAccounts.has(browserResult.accountCookie)) {
223
+ seenAccounts.add(browserResult.accountCookie)
224
+ results.push(browserResult)
225
+ }
226
+ }
227
+
228
+ return results
229
+ }
230
+
231
+ private async extractAllFromBrowserPaths(): Promise<ExtractedChannelToken[]> {
232
+ const results: ExtractedChannelToken[] = []
233
+ const cookiePaths = this.getBrowserCookiesPaths()
234
+
235
+ for (const cookiePath of cookiePaths) {
236
+ if (!existsSync(cookiePath)) continue
237
+ const result = await this.extractFromBrowserCookiePath(cookiePath)
238
+ if (result) results.push(result)
239
+ }
240
+
241
+ return results
242
+ }
243
+
244
+ private async extractFromDesktopApp(): Promise<ExtractedChannelToken | null> {
71
245
  const cookiesPath = this.getCookiesPath()
72
246
  if (!cookiesPath) {
73
247
  return null
@@ -77,28 +251,7 @@ export class ChannelTokenExtractor {
77
251
 
78
252
  try {
79
253
  copyFileSync(cookiesPath, tempPath)
80
- const sql = `
81
- SELECT name, value, encrypted_value FROM cookies
82
- WHERE name IN ('x-account', 'ch-session-1', 'ch-session')
83
- AND host_key LIKE '%.channel.io%'
84
- `
85
- const rows: CookieRow[] = typeof globalThis.Bun !== 'undefined'
86
- ? await (async () => {
87
- const { Database } = await import('bun:sqlite')
88
- const db = new Database(tempPath, { readonly: true })
89
- const result = db.query(sql).all() as CookieRow[]
90
- db.close()
91
- return result
92
- })()
93
- : await (async () => {
94
- const { createRequire } = await import('node:module')
95
- const req = createRequire(import.meta.url)
96
- const Database = req('better-sqlite3')
97
- const db = new Database(tempPath, { readonly: true })
98
- const result = db.prepare(sql).all() as CookieRow[]
99
- db.close()
100
- return result
101
- })()
254
+ const rows = await this.queryCookieDB(tempPath)
102
255
 
103
256
  const accountCookie = this.getCookieValue(rows, 'x-account')
104
257
  const sessionCookie =
@@ -119,6 +272,73 @@ export class ChannelTokenExtractor {
119
272
  }
120
273
  }
121
274
 
275
+ private async extractFromBrowsers(): Promise<ExtractedChannelToken | null> {
276
+ const cookiePaths = this.getBrowserCookiesPaths()
277
+
278
+ for (const cookiePath of cookiePaths) {
279
+ if (!existsSync(cookiePath)) continue
280
+
281
+ const result = await this.extractFromBrowserCookiePath(cookiePath)
282
+ if (result) return result
283
+ }
284
+
285
+ return null
286
+ }
287
+
288
+ private async extractFromBrowserCookiePath(cookiePath: string): Promise<ExtractedChannelToken | null> {
289
+ const tempPath = join(tmpdir(), `channel-browser-cookies-${Date.now()}`)
290
+
291
+ try {
292
+ copyFileSync(cookiePath, tempPath)
293
+ const rows = await this.queryCookieDB(tempPath)
294
+
295
+ const accountCookie = this.getBrowserCookieValue(rows, 'x-account', cookiePath)
296
+ const sessionCookie =
297
+ this.getBrowserCookieValue(rows, 'ch-session-1', cookiePath) ??
298
+ this.getBrowserCookieValue(rows, 'ch-session', cookiePath)
299
+
300
+ return accountCookie ? { accountCookie, sessionCookie } : null
301
+ } catch {
302
+ return null
303
+ } finally {
304
+ try {
305
+ if (existsSync(tempPath)) {
306
+ unlinkSync(tempPath)
307
+ }
308
+ } catch {
309
+ /* temp file cleanup failure is non-critical */
310
+ }
311
+ }
312
+ }
313
+
314
+ private async queryCookieDB(dbPath: string): Promise<CookieRow[]> {
315
+ const sql = `
316
+ SELECT name, value, encrypted_value FROM cookies
317
+ WHERE name IN ('x-account', 'ch-session-1', 'ch-session')
318
+ AND host_key LIKE '%.channel.io%'
319
+ `
320
+
321
+ if (typeof globalThis.Bun !== 'undefined') {
322
+ return await (async () => {
323
+ const { Database } = await import('bun:sqlite')
324
+ const db = new Database(dbPath, { readonly: true })
325
+ const result = db.query(sql).all() as CookieRow[]
326
+ db.close()
327
+ return result
328
+ })()
329
+ }
330
+
331
+ return await (async () => {
332
+ const { createRequire } = await import('node:module')
333
+ const req = createRequire(import.meta.url)
334
+ const Database = req('better-sqlite3')
335
+ const db = new Database(dbPath, { readonly: true })
336
+ const result = db.prepare(sql).all() as CookieRow[]
337
+ db.close()
338
+ return result
339
+ })()
340
+ }
341
+
122
342
  private getCookieValue(rows: CookieRow[], name: string): string | undefined {
123
343
  const row = rows.find((r) => r.name === name)
124
344
  if (!row) return undefined
@@ -133,6 +353,20 @@ export class ChannelTokenExtractor {
133
353
  return this.decryptCookie(encrypted) ?? undefined
134
354
  }
135
355
 
356
+ private getBrowserCookieValue(rows: CookieRow[], name: string, dbPath: string): string | undefined {
357
+ const row = rows.find((r) => r.name === name)
358
+ if (!row) return undefined
359
+
360
+ if (row.value && row.value.length > 0) {
361
+ return row.value
362
+ }
363
+
364
+ const encrypted = Buffer.from(row.encrypted_value)
365
+ if (encrypted.length === 0) return undefined
366
+
367
+ return this.decryptBrowserCookie(encrypted, dbPath) ?? undefined
368
+ }
369
+
136
370
  private decryptCookie(encryptedValue: Buffer): string | null {
137
371
  if (!this.isEncryptedValue(encryptedValue)) {
138
372
  return encryptedValue.toString('utf8')
@@ -145,21 +379,37 @@ export class ChannelTokenExtractor {
145
379
  return null
146
380
  }
147
381
 
382
+ private decryptBrowserCookie(encryptedValue: Buffer, dbPath: string): string | null {
383
+ if (!this.isEncryptedValue(encryptedValue)) {
384
+ return encryptedValue.toString('utf8')
385
+ }
386
+
387
+ if (this.platform === 'win32') {
388
+ const localStatePath = this.findLocalStateForCookiePath(dbPath)
389
+ return this.decryptWindowsCookie(encryptedValue, localStatePath ?? undefined)
390
+ } else if (this.platform === 'darwin') {
391
+ return this.decryptMacCookie(encryptedValue)
392
+ } else if (this.platform === 'linux') {
393
+ return this.decryptLinuxCookie(encryptedValue)
394
+ }
395
+
396
+ return null
397
+ }
398
+
148
399
  private isEncryptedValue(value: Buffer): boolean {
149
400
  if (!value || value.length < 4) return false
150
401
  const prefix = value.subarray(0, 3).toString('utf8')
151
402
  return prefix === 'v10' || prefix === 'v11'
152
403
  }
153
404
 
154
- private decryptWindowsCookie(encryptedData: Buffer): string | null {
405
+ private decryptWindowsCookie(encryptedData: Buffer, localStatePath?: string): string | null {
155
406
  try {
156
- const localStatePath = this.getLocalStatePath()
157
- if (!localStatePath) return null
407
+ const statePath = localStatePath ?? this.getLocalStatePath()
408
+ if (!statePath) return null
158
409
 
159
- const localState = JSON.parse(readFileSync(localStatePath, 'utf8'))
410
+ const localState = JSON.parse(readFileSync(statePath, 'utf8'))
160
411
  const encryptedKey = Buffer.from(localState.os_crypt.encrypted_key, 'base64')
161
412
 
162
- // Remove "DPAPI" prefix (5 bytes)
163
413
  const dpapiBlobKey = encryptedKey.subarray(5)
164
414
  const masterKey = this.decryptDPAPI(dpapiBlobKey)
165
415
  if (!masterKey) return null
@@ -170,6 +420,70 @@ export class ChannelTokenExtractor {
170
420
  }
171
421
  }
172
422
 
423
+ private decryptMacCookie(encryptedData: Buffer): string | null {
424
+ if (this.cachedKey) {
425
+ const decrypted = this.decryptAESCBC(encryptedData, this.cachedKey)
426
+ if (decrypted) return decrypted
427
+ }
428
+
429
+ for (const variant of this.getKeychainVariants()) {
430
+ const password = this.execSecurityCommand(variant.service, variant.account)
431
+ if (!password) continue
432
+
433
+ const key = pbkdf2Sync(password, 'saltysalt', 1003, 16, 'sha1')
434
+ const decrypted = this.decryptAESCBC(encryptedData, key)
435
+ if (decrypted) {
436
+ this.cachedKey = key
437
+ return decrypted
438
+ }
439
+ }
440
+
441
+ return null
442
+ }
443
+
444
+ private decryptLinuxCookie(encryptedData: Buffer): string | null {
445
+ const key = pbkdf2Sync('peanuts', 'saltysalt', 1, 16, 'sha1')
446
+ return this.decryptAESCBC(encryptedData, key)
447
+ }
448
+
449
+ private execSecurityCommand(service: string, account: string): string | null {
450
+ try {
451
+ const safeService = service.replace(/"/g, '\\"')
452
+ const safeAccount = account.replace(/"/g, '\\"')
453
+ const result = execSync(
454
+ `security find-generic-password -s "${safeService}" -a "${safeAccount}" -w 2>/dev/null`,
455
+ { encoding: 'utf8' },
456
+ )
457
+ return result.trim() || null
458
+ } catch {
459
+ return null
460
+ }
461
+ }
462
+
463
+ private decryptAESCBC(encryptedData: Buffer, key: Buffer): string | null {
464
+ try {
465
+ const ciphertext = encryptedData.subarray(3)
466
+ const iv = Buffer.alloc(16, 0x20)
467
+
468
+ const decipher = createDecipheriv('aes-128-cbc', key, iv)
469
+ decipher.setAutoPadding(true)
470
+
471
+ const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()])
472
+
473
+ // Chromium v130+ integrity hash: 32-byte non-printable prefix
474
+ if (decrypted.length > 32) {
475
+ const hasNonPrintablePrefix = decrypted.subarray(0, 32).some((b) => b < 0x20 || b > 0x7e)
476
+ if (hasNonPrintablePrefix) {
477
+ return decrypted.subarray(32).toString('utf8')
478
+ }
479
+ }
480
+
481
+ return decrypted.toString('utf8')
482
+ } catch {
483
+ return null
484
+ }
485
+ }
486
+
173
487
  decryptDPAPI(encryptedBlob: Buffer): Buffer | null {
174
488
  if (this.platform !== 'win32') return null
175
489
  try {