agent-messenger 2.23.6 → 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.
- package/.claude-plugin/plugin.json +1 -1
- package/.github/workflows/ci.yml +4 -0
- package/README.md +12 -1
- package/bun.lock +10 -72
- package/dist/package.json +7 -4
- package/dist/src/platforms/kakaotalk/token-extractor.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/token-extractor.js +11 -38
- package/dist/src/platforms/kakaotalk/token-extractor.js.map +1 -1
- package/dist/src/platforms/slack/commands/auth.d.ts.map +1 -1
- package/dist/src/platforms/slack/commands/auth.js +88 -5
- package/dist/src/platforms/slack/commands/auth.js.map +1 -1
- package/dist/src/platforms/slack/ensure-auth.d.ts +2 -1
- package/dist/src/platforms/slack/ensure-auth.d.ts.map +1 -1
- package/dist/src/platforms/slack/ensure-auth.js +49 -2
- package/dist/src/platforms/slack/ensure-auth.js.map +1 -1
- package/dist/src/platforms/slack/index.d.ts +4 -0
- package/dist/src/platforms/slack/index.d.ts.map +1 -1
- package/dist/src/platforms/slack/index.js +2 -0
- package/dist/src/platforms/slack/index.js.map +1 -1
- package/dist/src/platforms/slack/qr-http-login.d.ts +14 -0
- package/dist/src/platforms/slack/qr-http-login.d.ts.map +1 -0
- package/dist/src/platforms/slack/qr-http-login.js +90 -0
- package/dist/src/platforms/slack/qr-http-login.js.map +1 -0
- package/dist/src/platforms/slack/qr-login.d.ts +10 -0
- package/dist/src/platforms/slack/qr-login.d.ts.map +1 -0
- package/dist/src/platforms/slack/qr-login.js +72 -0
- package/dist/src/platforms/slack/qr-login.js.map +1 -0
- package/dist/src/platforms/slack/token-extractor.d.ts.map +1 -1
- package/dist/src/platforms/slack/token-extractor.js +5 -11
- package/dist/src/platforms/slack/token-extractor.js.map +1 -1
- package/dist/src/shared/chromium/cookie-reader.d.ts.map +1 -1
- package/dist/src/shared/chromium/cookie-reader.js +4 -19
- package/dist/src/shared/chromium/cookie-reader.js.map +1 -1
- package/dist/src/shared/sqlite.d.ts +10 -0
- package/dist/src/shared/sqlite.d.ts.map +1 -0
- package/dist/src/shared/sqlite.js +46 -0
- package/dist/src/shared/sqlite.js.map +1 -0
- package/dist/src/vendor/linejs/base/request/mod.js +1 -1
- package/dist/src/vendor/linejs/base/request/mod.test.ts +54 -0
- package/docs/content/docs/cli/slack.mdx +22 -0
- package/docs/content/docs/sdk/slack.mdx +15 -0
- package/package.json +7 -4
- package/skills/agent-channeltalk/SKILL.md +1 -1
- package/skills/agent-channeltalkbot/SKILL.md +1 -1
- package/skills/agent-discord/SKILL.md +1 -1
- package/skills/agent-discordbot/SKILL.md +1 -1
- package/skills/agent-instagram/SKILL.md +1 -1
- package/skills/agent-kakaotalk/SKILL.md +1 -1
- package/skills/agent-line/SKILL.md +1 -1
- package/skills/agent-slack/SKILL.md +45 -1
- package/skills/agent-slack/references/authentication.md +29 -0
- package/skills/agent-slackbot/SKILL.md +1 -1
- package/skills/agent-teams/SKILL.md +1 -1
- package/skills/agent-telegram/SKILL.md +1 -1
- package/skills/agent-telegrambot/SKILL.md +1 -1
- package/skills/agent-webex/SKILL.md +1 -1
- package/skills/agent-webexbot/SKILL.md +1 -1
- package/skills/agent-wechatbot/SKILL.md +1 -1
- package/skills/agent-whatsapp/SKILL.md +1 -1
- package/skills/agent-whatsappbot/SKILL.md +1 -1
- package/src/platforms/channeltalk/token-extractor.test.ts +2 -2
- package/src/platforms/kakaotalk/token-extractor.ts +13 -36
- package/src/platforms/slack/commands/auth.ts +106 -5
- package/src/platforms/slack/ensure-auth.test.ts +130 -19
- package/src/platforms/slack/ensure-auth.ts +57 -2
- package/src/platforms/slack/index.test.ts +10 -0
- package/src/platforms/slack/index.ts +4 -0
- package/src/platforms/slack/qr-http-login.test.ts +157 -0
- package/src/platforms/slack/qr-http-login.ts +120 -0
- package/src/platforms/slack/qr-login.test.ts +103 -0
- package/src/platforms/slack/qr-login.ts +90 -0
- package/src/platforms/slack/token-extractor-node-test.ts +5 -3
- package/src/platforms/slack/token-extractor.ts +4 -11
- package/src/shared/chromium/cookie-reader-node-test.ts +70 -0
- package/src/shared/chromium/cookie-reader-node.test.ts +10 -0
- package/src/shared/chromium/cookie-reader.ts +4 -21
- package/src/shared/sqlite.ts +61 -0
- package/src/vendor/linejs/base/request/mod.js +1 -1
- package/src/vendor/linejs/base/request/mod.test.ts +54 -0
|
@@ -34,6 +34,35 @@ This command:
|
|
|
34
34
|
|
|
35
35
|
Use `--browser-profile <path>` for agent-browser profiles, custom Chrome user data dirs, or portable browser profiles. The option can be repeated or given comma-separated paths, and explicit paths are included even when desktop credentials are also present.
|
|
36
36
|
|
|
37
|
+
### QR Code Sign-In
|
|
38
|
+
|
|
39
|
+
When the desktop app isn't installed and browser extraction isn't an option, you can sign in with a QR code from a device where you're already logged into Slack. This runs entirely over HTTP — no browser automation:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
# In Slack (desktop or web): your name (top-left) → "Sign in on mobile".
|
|
43
|
+
# Right-click the QR code → "Copy Image Address", then pipe it in:
|
|
44
|
+
pbpaste | agent-slack auth qr
|
|
45
|
+
|
|
46
|
+
# Or pass the data URL directly:
|
|
47
|
+
agent-slack auth qr "data:image/png;base64,iVBORw0KGgo..."
|
|
48
|
+
|
|
49
|
+
# Show each redirect hop while debugging:
|
|
50
|
+
agent-slack auth qr --debug
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
How it works:
|
|
54
|
+
|
|
55
|
+
1. Decodes the QR image (a `data:image/png;base64,...` string) to its one-time `z-app-` login URL
|
|
56
|
+
2. Follows Slack's server-side redirect chain, capturing the session `d` cookie (`xoxd-...`)
|
|
57
|
+
3. Retrieves the matching `xoxc-` client token from the established session
|
|
58
|
+
4. Validates against the Slack API and stores credentials like `auth extract`
|
|
59
|
+
|
|
60
|
+
Notes:
|
|
61
|
+
|
|
62
|
+
- The QR link is **single-use** — if it expires, generate a fresh one from Slack's "Sign in on mobile" screen.
|
|
63
|
+
- No Chrome/Chromium or desktop app is required; the flow uses plain HTTP requests.
|
|
64
|
+
- Works with workspaces that allow password/email sign-in. SSO-only / Enterprise Grid workspaces that disable the mobile QR flow are not supported.
|
|
65
|
+
|
|
37
66
|
### Platform-Specific Paths
|
|
38
67
|
|
|
39
68
|
**macOS (Direct Download):**
|
|
@@ -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.
|
|
4
|
+
version: 2.24.1
|
|
5
5
|
allowed-tools: Bash(agent-webexbot:*)
|
|
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
|
|
461
|
-
const db = new
|
|
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
|
|
6
|
+
import { openReadonlyDatabase } from '@/shared/sqlite'
|
|
8
7
|
|
|
9
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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,9 @@ 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'
|
|
13
|
+
import { loginWithQr } from '../qr-http-login'
|
|
12
14
|
import { type ExtractedWorkspace, TokenExtractor } from '../token-extractor'
|
|
13
15
|
|
|
14
16
|
export function formatCredentialDebug(ws: ExtractedWorkspace, showSecrets?: boolean): string {
|
|
@@ -79,6 +81,8 @@ async function extractAction(options: {
|
|
|
79
81
|
|
|
80
82
|
const validWorkspaces = []
|
|
81
83
|
const failureReasons: string[] = []
|
|
84
|
+
const resolvedTeamIds = new Set<string>()
|
|
85
|
+
const refreshCache = new Map<string, RefreshResult | null>()
|
|
82
86
|
for (const ws of workspaces) {
|
|
83
87
|
if (options.debug) {
|
|
84
88
|
debug(`[debug] Testing credentials for ${ws.workspace_id}...`)
|
|
@@ -87,10 +91,14 @@ async function extractAction(options: {
|
|
|
87
91
|
try {
|
|
88
92
|
const client = await new SlackClient().login({ token: ws.token, cookie: ws.cookie })
|
|
89
93
|
const authInfo = await client.testAuth()
|
|
94
|
+
if (!authInfo.team_id) throw new SlackError('testAuth returned empty team_id', 'invalid_auth')
|
|
90
95
|
ws.workspace_id = authInfo.team_id
|
|
91
96
|
ws.workspace_name = authInfo.team || ws.workspace_name
|
|
92
|
-
|
|
93
|
-
|
|
97
|
+
if (!resolvedTeamIds.has(ws.workspace_id)) {
|
|
98
|
+
resolvedTeamIds.add(ws.workspace_id)
|
|
99
|
+
validWorkspaces.push(ws)
|
|
100
|
+
await credManager.setWorkspace(ws)
|
|
101
|
+
}
|
|
94
102
|
|
|
95
103
|
if (options.debug) {
|
|
96
104
|
debug(`[debug] ✓ Valid: ${authInfo.team} (${authInfo.user})`)
|
|
@@ -111,11 +119,12 @@ async function extractAction(options: {
|
|
|
111
119
|
: `${ws.workspace_id} (trying all known domains)`
|
|
112
120
|
debug(`[debug] Attempting web token refresh for ${target}...`)
|
|
113
121
|
}
|
|
114
|
-
const refreshed = await tryWebTokenRefresh(ws, workspaceDomains)
|
|
115
|
-
if (refreshed) {
|
|
122
|
+
const refreshed = await tryWebTokenRefresh(ws, workspaceDomains, { resolvedTeamIds, refreshCache })
|
|
123
|
+
if (refreshed && !resolvedTeamIds.has(refreshed.workspace_id)) {
|
|
116
124
|
ws.token = refreshed.token
|
|
117
125
|
ws.workspace_id = refreshed.workspace_id
|
|
118
126
|
ws.workspace_name = refreshed.workspace_name
|
|
127
|
+
resolvedTeamIds.add(ws.workspace_id)
|
|
119
128
|
validWorkspaces.push(ws)
|
|
120
129
|
await credManager.setWorkspace(ws)
|
|
121
130
|
|
|
@@ -128,6 +137,26 @@ async function extractAction(options: {
|
|
|
128
137
|
}
|
|
129
138
|
}
|
|
130
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
|
+
|
|
131
160
|
if (validWorkspaces.length === 0) {
|
|
132
161
|
const errorMessage = getExtractionErrorMessage(failureReasons)
|
|
133
162
|
console.log(
|
|
@@ -230,6 +259,70 @@ async function statusAction(options: { pretty?: boolean }): Promise<void> {
|
|
|
230
259
|
}
|
|
231
260
|
}
|
|
232
261
|
|
|
262
|
+
async function qrAction(imageArg: string | undefined, options: { pretty?: boolean; debug?: boolean }): Promise<void> {
|
|
263
|
+
try {
|
|
264
|
+
const dataUrl = imageArg ?? (await readStdin())
|
|
265
|
+
if (!dataUrl) {
|
|
266
|
+
console.log(
|
|
267
|
+
formatOutput(
|
|
268
|
+
{
|
|
269
|
+
error: 'No QR image provided. Pass the copied QR image data URL as an argument, or pipe it via stdin.',
|
|
270
|
+
hint: 'In Slack: your name (top-left) → "Sign in on mobile" → right-click the QR → Copy Image Address.',
|
|
271
|
+
},
|
|
272
|
+
options.pretty,
|
|
273
|
+
),
|
|
274
|
+
)
|
|
275
|
+
process.exit(1)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const debugLog = options.debug ? (msg: string) => debug(`[debug] ${msg}`) : undefined
|
|
279
|
+
const session = await loginWithQr(dataUrl, { debug: debugLog })
|
|
280
|
+
|
|
281
|
+
const client = await new SlackClient().login({ token: session.token, cookie: session.cookie })
|
|
282
|
+
const authInfo = await client.testAuth()
|
|
283
|
+
|
|
284
|
+
const credManager = new CredentialManager()
|
|
285
|
+
const workspace: ExtractedWorkspace = {
|
|
286
|
+
workspace_id: authInfo.team_id,
|
|
287
|
+
workspace_name: authInfo.team || session.workspace,
|
|
288
|
+
token: session.token,
|
|
289
|
+
cookie: session.cookie,
|
|
290
|
+
}
|
|
291
|
+
await credManager.setWorkspace(workspace)
|
|
292
|
+
|
|
293
|
+
const config = await credManager.load()
|
|
294
|
+
if (!config.current_workspace) {
|
|
295
|
+
await credManager.setCurrentWorkspace(workspace.workspace_id)
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
console.log(
|
|
299
|
+
formatOutput(
|
|
300
|
+
{
|
|
301
|
+
workspace: `${workspace.workspace_id}/${workspace.workspace_name}`,
|
|
302
|
+
user: authInfo.user,
|
|
303
|
+
current: (await credManager.load()).current_workspace,
|
|
304
|
+
},
|
|
305
|
+
options.pretty,
|
|
306
|
+
),
|
|
307
|
+
)
|
|
308
|
+
} catch (error) {
|
|
309
|
+
handleError(error as Error)
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function readStdin(): Promise<string> {
|
|
314
|
+
if (process.stdin.isTTY) return Promise.resolve('')
|
|
315
|
+
return new Promise((resolve) => {
|
|
316
|
+
let data = ''
|
|
317
|
+
process.stdin.setEncoding('utf8')
|
|
318
|
+
process.stdin.on('data', (chunk) => {
|
|
319
|
+
data += chunk
|
|
320
|
+
})
|
|
321
|
+
process.stdin.on('end', () => resolve(data))
|
|
322
|
+
process.stdin.on('error', () => resolve(data))
|
|
323
|
+
})
|
|
324
|
+
}
|
|
325
|
+
|
|
233
326
|
export function getExtractionErrorMessage(failureReasons: string[]): string {
|
|
234
327
|
if (failureReasons.includes('missing_cookie')) {
|
|
235
328
|
return 'Cookie extraction failed. Grant Keychain access when prompted, and make sure you are signed into Slack in the desktop app or a supported Chromium browser.'
|
|
@@ -260,6 +353,14 @@ export const authCommand = new Command('auth')
|
|
|
260
353
|
.option('--unsafely-show-secrets', 'Show full token and cookie values in debug output')
|
|
261
354
|
.action((options: BrowserProfileOption & Parameters<typeof extractAction>[0]) => extractAction(options)),
|
|
262
355
|
)
|
|
356
|
+
.addCommand(
|
|
357
|
+
new Command('qr')
|
|
358
|
+
.description('Sign in by pasting a QR code from Slack\u2019s "Sign in on mobile" screen')
|
|
359
|
+
.argument('[image]', 'QR image data URL (data:image/png;base64,...); read from stdin if omitted')
|
|
360
|
+
.option('--pretty', 'Pretty print JSON output')
|
|
361
|
+
.option('--debug', 'Show debug output for troubleshooting')
|
|
362
|
+
.action(qrAction),
|
|
363
|
+
)
|
|
263
364
|
.addCommand(
|
|
264
365
|
new Command('logout')
|
|
265
366
|
.description('Logout from workspace')
|
|
@@ -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('
|
|
362
|
-
// given —
|
|
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.
|
|
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 —
|
|
381
|
-
|
|
382
|
-
|
|
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
|
|
500
|
-
// given — 20 candidate domains; only the first 16
|
|
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
|
|
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>,
|
|
@@ -145,9 +196,13 @@ async function refreshAndVerify(cookie: string, domain: string, fallbackName: st
|
|
|
145
196
|
|
|
146
197
|
const TOKEN_REGEX = /"api_token":"(xoxc-[a-zA-Z0-9-]+)"/
|
|
147
198
|
|
|
148
|
-
export async function refreshTokenFromWeb(
|
|
199
|
+
export async function refreshTokenFromWeb(
|
|
200
|
+
domain: string,
|
|
201
|
+
cookie: string,
|
|
202
|
+
fetchImpl: typeof fetch = fetch,
|
|
203
|
+
): Promise<string | null> {
|
|
149
204
|
try {
|
|
150
|
-
const response = await
|
|
205
|
+
const response = await fetchImpl(`https://${domain}.slack.com/ssb/redirect`, {
|
|
151
206
|
headers: { Cookie: `d=${cookie}` },
|
|
152
207
|
redirect: 'follow',
|
|
153
208
|
})
|