agent-messenger 2.22.0 → 2.23.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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +21 -0
- package/dist/package.json +1 -1
- package/dist/src/platforms/webex/client.d.ts +6 -0
- package/dist/src/platforms/webex/client.d.ts.map +1 -1
- package/dist/src/platforms/webex/client.js +34 -4
- package/dist/src/platforms/webex/client.js.map +1 -1
- package/dist/src/platforms/webex/commands/auth.d.ts +9 -1
- package/dist/src/platforms/webex/commands/auth.d.ts.map +1 -1
- package/dist/src/platforms/webex/commands/auth.js +141 -25
- package/dist/src/platforms/webex/commands/auth.js.map +1 -1
- package/dist/src/platforms/webex/credential-manager.d.ts.map +1 -1
- package/dist/src/platforms/webex/credential-manager.js +8 -4
- package/dist/src/platforms/webex/credential-manager.js.map +1 -1
- package/dist/src/platforms/webex/id-normalizer.d.ts +19 -0
- package/dist/src/platforms/webex/id-normalizer.d.ts.map +1 -0
- package/dist/src/platforms/webex/id-normalizer.js +60 -0
- package/dist/src/platforms/webex/id-normalizer.js.map +1 -0
- package/dist/src/platforms/webex/index.d.ts +4 -0
- package/dist/src/platforms/webex/index.d.ts.map +1 -1
- package/dist/src/platforms/webex/index.js +2 -0
- package/dist/src/platforms/webex/index.js.map +1 -1
- package/dist/src/platforms/webex/listener.d.ts +61 -0
- package/dist/src/platforms/webex/listener.d.ts.map +1 -0
- package/dist/src/platforms/webex/listener.js +222 -0
- package/dist/src/platforms/webex/listener.js.map +1 -0
- package/dist/src/platforms/webex/password-login.d.ts +18 -0
- package/dist/src/platforms/webex/password-login.d.ts.map +1 -0
- package/dist/src/platforms/webex/password-login.js +259 -0
- package/dist/src/platforms/webex/password-login.js.map +1 -0
- package/dist/src/platforms/webex/types.d.ts +2 -1
- package/dist/src/platforms/webex/types.d.ts.map +1 -1
- package/dist/src/platforms/webex/types.js +1 -1
- package/dist/src/platforms/webex/types.js.map +1 -1
- package/dist/src/platforms/webex/wdm-discovery.d.ts.map +1 -0
- package/dist/src/platforms/{webexbot → webex}/wdm-discovery.js +3 -3
- package/dist/src/platforms/webex/wdm-discovery.js.map +1 -0
- package/dist/src/platforms/webexbot/client.d.ts +4 -0
- package/dist/src/platforms/webexbot/client.d.ts.map +1 -1
- package/dist/src/platforms/webexbot/client.js +70 -8
- package/dist/src/platforms/webexbot/client.js.map +1 -1
- package/dist/src/platforms/webexbot/index.d.ts +2 -0
- package/dist/src/platforms/webexbot/index.d.ts.map +1 -1
- package/dist/src/platforms/webexbot/index.js +1 -0
- package/dist/src/platforms/webexbot/index.js.map +1 -1
- package/dist/src/platforms/webexbot/listener.d.ts +3 -41
- package/dist/src/platforms/webexbot/listener.d.ts.map +1 -1
- package/dist/src/platforms/webexbot/listener.js +13 -208
- package/dist/src/platforms/webexbot/listener.js.map +1 -1
- package/dist/src/platforms/webexbot/types.d.ts +1 -18
- package/dist/src/platforms/webexbot/types.d.ts.map +1 -1
- package/dist/src/platforms/webexbot/types.js.map +1 -1
- package/docs/content/docs/cli/webex.mdx +38 -12
- package/docs/content/docs/sdk/webexbot.mdx +16 -0
- package/package.json +1 -1
- 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 +1 -1
- 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 +76 -22
- package/skills/agent-webex/references/authentication.md +55 -14
- package/skills/agent-webex/references/common-patterns.md +5 -2
- package/skills/agent-webexbot/SKILL.md +3 -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/webex/cli.test.ts +31 -1
- package/src/platforms/webex/client.test.ts +57 -0
- package/src/platforms/webex/client.ts +39 -4
- package/src/platforms/webex/commands/auth.test.ts +189 -28
- package/src/platforms/webex/commands/auth.ts +194 -35
- package/src/platforms/webex/credential-manager.test.ts +40 -0
- package/src/platforms/webex/credential-manager.ts +7 -4
- package/src/platforms/webex/id-normalizer.test.ts +207 -0
- package/src/platforms/webex/id-normalizer.ts +76 -0
- package/src/platforms/webex/index.test.ts +6 -0
- package/src/platforms/webex/index.ts +4 -0
- package/src/platforms/webex/listener.test.ts +243 -0
- package/src/platforms/webex/listener.ts +285 -0
- package/src/platforms/webex/password-login.test.ts +193 -0
- package/src/platforms/webex/password-login.ts +332 -0
- package/src/platforms/webex/types.test.ts +16 -0
- package/src/platforms/webex/types.ts +2 -2
- package/src/platforms/{webexbot → webex}/wdm-discovery.ts +3 -3
- package/src/platforms/webexbot/client.test.ts +125 -1
- package/src/platforms/webexbot/client.ts +79 -8
- package/src/platforms/webexbot/index.ts +2 -0
- package/src/platforms/webexbot/listener.test.ts +37 -224
- package/src/platforms/webexbot/listener.ts +18 -250
- package/src/platforms/webexbot/types.ts +2 -23
- package/dist/src/platforms/webexbot/wdm-discovery.d.ts.map +0 -1
- package/dist/src/platforms/webexbot/wdm-discovery.js.map +0 -1
- /package/dist/src/platforms/{webexbot → webex}/wdm-discovery.d.ts +0 -0
- /package/src/platforms/{webexbot → webex}/wdm-discovery.test.ts +0 -0
|
@@ -1,11 +1,21 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect, spyOn, it } from 'bun:test'
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, mock, spyOn, it } from 'bun:test'
|
|
2
2
|
import * as childProcess from 'node:child_process'
|
|
3
|
+
import * as fs from 'node:fs'
|
|
3
4
|
|
|
4
5
|
import { WebexClient } from '../client'
|
|
5
6
|
import { WebexCredentialManager } from '../credential-manager'
|
|
7
|
+
import * as passwordLogin from '../password-login'
|
|
6
8
|
import { WebexTokenExtractor } from '../token-extractor'
|
|
7
9
|
import { WebexError } from '../types'
|
|
8
|
-
import { extractAction, loginAction, logoutAction, statusAction } from './auth'
|
|
10
|
+
import { extractAction, loginAction, logoutAction, oauthAction, statusAction } from './auth'
|
|
11
|
+
|
|
12
|
+
let promptQueue: string[] = []
|
|
13
|
+
mock.module('node:readline/promises', () => ({
|
|
14
|
+
createInterface: () => ({
|
|
15
|
+
question: async () => promptQueue.shift() ?? '',
|
|
16
|
+
close: () => {},
|
|
17
|
+
}),
|
|
18
|
+
}))
|
|
9
19
|
|
|
10
20
|
class ProcessExit extends Error {
|
|
11
21
|
constructor(readonly code?: string | number | null) {
|
|
@@ -18,6 +28,7 @@ describe('auth commands', () => {
|
|
|
18
28
|
let consoleErrorSpy: ReturnType<typeof spyOn>
|
|
19
29
|
let execSpy: ReturnType<typeof spyOn>
|
|
20
30
|
let stderrWriteSpy: ReturnType<typeof spyOn>
|
|
31
|
+
let stdoutWriteSpy: ReturnType<typeof spyOn>
|
|
21
32
|
const protoSpies: ReturnType<typeof spyOn>[] = []
|
|
22
33
|
let originalStdinTTY: boolean | undefined
|
|
23
34
|
let originalStdoutTTY: boolean | undefined
|
|
@@ -42,10 +53,12 @@ describe('auth commands', () => {
|
|
|
42
53
|
}
|
|
43
54
|
|
|
44
55
|
beforeEach(() => {
|
|
56
|
+
promptQueue = []
|
|
45
57
|
consoleSpy = spyOn(console, 'log').mockImplementation(() => {})
|
|
46
58
|
consoleErrorSpy = spyOn(console, 'error').mockImplementation(() => {})
|
|
47
59
|
execSpy = spyOn(childProcess, 'exec').mockImplementation((() => {}) as any)
|
|
48
60
|
stderrWriteSpy = spyOn(process.stderr, 'write').mockImplementation(() => true)
|
|
61
|
+
stdoutWriteSpy = spyOn(process.stdout, 'write').mockImplementation(() => true)
|
|
49
62
|
originalStdinTTY = process.stdin.isTTY
|
|
50
63
|
originalStdoutTTY = process.stdout.isTTY
|
|
51
64
|
// Default to interactive TTY for existing tests; non-interactive tests override.
|
|
@@ -62,6 +75,7 @@ describe('auth commands', () => {
|
|
|
62
75
|
consoleErrorSpy.mockRestore()
|
|
63
76
|
execSpy.mockRestore()
|
|
64
77
|
stderrWriteSpy.mockRestore()
|
|
78
|
+
stdoutWriteSpy.mockRestore()
|
|
65
79
|
for (const s of protoSpies) s.mockRestore()
|
|
66
80
|
protoSpies.length = 0
|
|
67
81
|
setTTY(originalStdinTTY)
|
|
@@ -99,9 +113,170 @@ describe('auth commands', () => {
|
|
|
99
113
|
expect(savedConfig.expiresAt).toBe(0)
|
|
100
114
|
expect(savedConfig.refreshToken).toBe('')
|
|
101
115
|
})
|
|
116
|
+
|
|
117
|
+
it('still allows --token login when non-interactive (bot/PAT path)', async () => {
|
|
118
|
+
setTTY(false)
|
|
119
|
+
protoSpy(WebexClient.prototype, 'login').mockResolvedValue(new WebexClient())
|
|
120
|
+
protoSpy(WebexClient.prototype, 'testAuth').mockResolvedValue(mockPerson)
|
|
121
|
+
protoSpy(WebexCredentialManager.prototype, 'saveConfig').mockResolvedValue(undefined)
|
|
122
|
+
|
|
123
|
+
await loginAction({ token: 'bot-token-123', pretty: false })
|
|
124
|
+
|
|
125
|
+
const lastCall = consoleSpy.mock.calls[consoleSpy.mock.calls.length - 1][0] as string
|
|
126
|
+
const output = JSON.parse(lastCall)
|
|
127
|
+
expect(output.authenticated).toBe(true)
|
|
128
|
+
expect(output.user.displayName).toBe('Test User')
|
|
129
|
+
})
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
describe('loginAction with --email/--password', () => {
|
|
133
|
+
const passwordConfig = {
|
|
134
|
+
accessToken: 'pw-access-token',
|
|
135
|
+
refreshToken: 'pw-refresh-token',
|
|
136
|
+
expiresAt: Date.now() + 3_600_000,
|
|
137
|
+
deviceUrl: 'https://wdm-r.wbx2.com/wdm/api/v1/devices/test-device',
|
|
138
|
+
userId: 'user-1',
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
it('logs in with email/password and saves the password token type', async () => {
|
|
142
|
+
const pwSpy = protoSpy(passwordLogin, 'loginWithPassword').mockResolvedValue(passwordConfig)
|
|
143
|
+
const saveSpy = protoSpy(WebexCredentialManager.prototype, 'saveConfig').mockResolvedValue(undefined)
|
|
144
|
+
protoSpy(WebexClient.prototype, 'login').mockResolvedValue(new WebexClient())
|
|
145
|
+
protoSpy(WebexClient.prototype, 'testAuth').mockResolvedValue(mockPerson)
|
|
146
|
+
|
|
147
|
+
await loginAction({ email: 'alice@example.com', password: 'hunter2', pretty: false })
|
|
148
|
+
|
|
149
|
+
expect(pwSpy).toHaveBeenCalledWith('alice@example.com', 'hunter2', { idbrokerHost: undefined })
|
|
150
|
+
const savedConfig = saveSpy.mock.calls[0][0] as { tokenType: string; clientId: string }
|
|
151
|
+
expect(savedConfig.tokenType).toBe('password')
|
|
152
|
+
expect(savedConfig.clientId).toBe(passwordLogin.WEB_CLIENT_ID)
|
|
153
|
+
|
|
154
|
+
const lastCall = consoleSpy.mock.calls[consoleSpy.mock.calls.length - 1][0] as string
|
|
155
|
+
const output = JSON.parse(lastCall)
|
|
156
|
+
expect(output.authenticated).toBe(true)
|
|
157
|
+
expect(output.user.displayName).toBe('Test User')
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('forwards --idbroker-host to the password login flow', async () => {
|
|
161
|
+
const pwSpy = protoSpy(passwordLogin, 'loginWithPassword').mockResolvedValue(passwordConfig)
|
|
162
|
+
protoSpy(WebexCredentialManager.prototype, 'saveConfig').mockResolvedValue(undefined)
|
|
163
|
+
protoSpy(WebexClient.prototype, 'login').mockResolvedValue(new WebexClient())
|
|
164
|
+
protoSpy(WebexClient.prototype, 'testAuth').mockResolvedValue(mockPerson)
|
|
165
|
+
|
|
166
|
+
await loginAction({
|
|
167
|
+
email: 'alice@example.com',
|
|
168
|
+
password: 'hunter2',
|
|
169
|
+
idbrokerHost: 'https://idbroker.example.com',
|
|
170
|
+
pretty: false,
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
expect(pwSpy).toHaveBeenCalledWith('alice@example.com', 'hunter2', {
|
|
174
|
+
idbrokerHost: 'https://idbroker.example.com',
|
|
175
|
+
})
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('reads the password from stdin with --password-stdin', async () => {
|
|
179
|
+
const pwSpy = protoSpy(passwordLogin, 'loginWithPassword').mockResolvedValue(passwordConfig)
|
|
180
|
+
protoSpy(fs, 'readFileSync').mockReturnValue('stdin-secret\n')
|
|
181
|
+
protoSpy(WebexCredentialManager.prototype, 'saveConfig').mockResolvedValue(undefined)
|
|
182
|
+
protoSpy(WebexClient.prototype, 'login').mockResolvedValue(new WebexClient())
|
|
183
|
+
protoSpy(WebexClient.prototype, 'testAuth').mockResolvedValue(mockPerson)
|
|
184
|
+
|
|
185
|
+
await loginAction({ email: 'alice@example.com', passwordStdin: true, pretty: false })
|
|
186
|
+
|
|
187
|
+
expect(pwSpy).toHaveBeenCalledWith('alice@example.com', 'stdin-secret', { idbrokerHost: undefined })
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('rejects passing both --password and --password-stdin', async () => {
|
|
191
|
+
const exitSpy = protoSpy(process, 'exit').mockImplementation((code?: string | number | null) => {
|
|
192
|
+
throw new ProcessExit(code)
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
await expect(
|
|
196
|
+
loginAction({ email: 'alice@example.com', password: 'p', passwordStdin: true, pretty: false }),
|
|
197
|
+
).rejects.toThrow(ProcessExit)
|
|
198
|
+
|
|
199
|
+
const lastCall = stderrWriteSpy.mock.calls[stderrWriteSpy.mock.calls.length - 1][0] as string
|
|
200
|
+
const output = JSON.parse(lastCall)
|
|
201
|
+
expect(output.error).toContain('Use only one of --password or --password-stdin')
|
|
202
|
+
expect(exitSpy).toHaveBeenCalledWith(1)
|
|
203
|
+
})
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
describe('loginAction interactive prompts', () => {
|
|
207
|
+
const passwordConfig = {
|
|
208
|
+
accessToken: 'pw-access-token',
|
|
209
|
+
refreshToken: 'pw-refresh-token',
|
|
210
|
+
expiresAt: Date.now() + 3_600_000,
|
|
211
|
+
deviceUrl: 'https://wdm-r.wbx2.com/wdm/api/v1/devices/test-device',
|
|
212
|
+
userId: 'user-1',
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
it('prompts for email and password when none are provided', async () => {
|
|
216
|
+
promptQueue = ['alice@example.com', 'prompted-secret']
|
|
217
|
+
const pwSpy = protoSpy(passwordLogin, 'loginWithPassword').mockResolvedValue(passwordConfig)
|
|
218
|
+
protoSpy(WebexCredentialManager.prototype, 'saveConfig').mockResolvedValue(undefined)
|
|
219
|
+
protoSpy(WebexClient.prototype, 'login').mockResolvedValue(new WebexClient())
|
|
220
|
+
protoSpy(WebexClient.prototype, 'testAuth').mockResolvedValue(mockPerson)
|
|
221
|
+
|
|
222
|
+
await loginAction({ pretty: false })
|
|
223
|
+
|
|
224
|
+
expect(pwSpy).toHaveBeenCalledWith('alice@example.com', 'prompted-secret', { idbrokerHost: undefined })
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it('prompts only for the password when --email is provided without a password', async () => {
|
|
228
|
+
promptQueue = ['prompted-secret']
|
|
229
|
+
const pwSpy = protoSpy(passwordLogin, 'loginWithPassword').mockResolvedValue(passwordConfig)
|
|
230
|
+
protoSpy(WebexCredentialManager.prototype, 'saveConfig').mockResolvedValue(undefined)
|
|
231
|
+
protoSpy(WebexClient.prototype, 'login').mockResolvedValue(new WebexClient())
|
|
232
|
+
protoSpy(WebexClient.prototype, 'testAuth').mockResolvedValue(mockPerson)
|
|
233
|
+
|
|
234
|
+
await loginAction({ email: 'alice@example.com', pretty: false })
|
|
235
|
+
|
|
236
|
+
expect(pwSpy).toHaveBeenCalledWith('alice@example.com', 'prompted-secret', { idbrokerHost: undefined })
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
it('preserves whitespace in the prompted password', async () => {
|
|
240
|
+
promptQueue = [' spaced secret ']
|
|
241
|
+
const pwSpy = protoSpy(passwordLogin, 'loginWithPassword').mockResolvedValue(passwordConfig)
|
|
242
|
+
protoSpy(WebexCredentialManager.prototype, 'saveConfig').mockResolvedValue(undefined)
|
|
243
|
+
protoSpy(WebexClient.prototype, 'login').mockResolvedValue(new WebexClient())
|
|
244
|
+
protoSpy(WebexClient.prototype, 'testAuth').mockResolvedValue(mockPerson)
|
|
245
|
+
|
|
246
|
+
await loginAction({ email: 'alice@example.com', pretty: false })
|
|
247
|
+
|
|
248
|
+
expect(pwSpy).toHaveBeenCalledWith('alice@example.com', ' spaced secret ', { idbrokerHost: undefined })
|
|
249
|
+
})
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
describe('loginAction non-interactive without credentials', () => {
|
|
253
|
+
it('errors when no email is provided and points to auth oauth', async () => {
|
|
254
|
+
setTTY(false)
|
|
255
|
+
const exitSpy = protoSpy(process, 'exit').mockImplementation(() => undefined as never)
|
|
256
|
+
|
|
257
|
+
await loginAction({ pretty: false })
|
|
258
|
+
|
|
259
|
+
const lastCall = consoleSpy.mock.calls[consoleSpy.mock.calls.length - 1][0] as string
|
|
260
|
+
const output = JSON.parse(lastCall)
|
|
261
|
+
expect(output.error).toContain('Email required')
|
|
262
|
+
expect(output.error).toContain('auth oauth')
|
|
263
|
+
expect(exitSpy).toHaveBeenCalledWith(1)
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
it('errors when --email is provided but no password is available', async () => {
|
|
267
|
+
setTTY(false)
|
|
268
|
+
const exitSpy = protoSpy(process, 'exit').mockImplementation(() => undefined as never)
|
|
269
|
+
|
|
270
|
+
await loginAction({ email: 'alice@example.com', pretty: false })
|
|
271
|
+
|
|
272
|
+
const lastCall = consoleSpy.mock.calls[consoleSpy.mock.calls.length - 1][0] as string
|
|
273
|
+
const output = JSON.parse(lastCall)
|
|
274
|
+
expect(output.error).toContain('Password required')
|
|
275
|
+
expect(exitSpy).toHaveBeenCalledWith(1)
|
|
276
|
+
})
|
|
102
277
|
})
|
|
103
278
|
|
|
104
|
-
describe('
|
|
279
|
+
describe('oauthAction with --client-id and --client-secret', () => {
|
|
105
280
|
it('uses provided credentials for Device Grant flow', async () => {
|
|
106
281
|
protoSpy(WebexCredentialManager.prototype, 'requestDeviceCode').mockResolvedValue({
|
|
107
282
|
deviceCode: 'd',
|
|
@@ -120,7 +295,7 @@ describe('auth commands', () => {
|
|
|
120
295
|
protoSpy(WebexClient.prototype, 'login').mockResolvedValue(new WebexClient())
|
|
121
296
|
protoSpy(WebexClient.prototype, 'testAuth').mockResolvedValue(mockPerson)
|
|
122
297
|
|
|
123
|
-
await
|
|
298
|
+
await oauthAction({ clientId: 'my-id', clientSecret: 'my-secret', pretty: false })
|
|
124
299
|
|
|
125
300
|
expect(WebexCredentialManager.prototype.requestDeviceCode).toHaveBeenCalledWith('my-id')
|
|
126
301
|
expect(WebexCredentialManager.prototype.pollDeviceToken).toHaveBeenCalledWith(
|
|
@@ -150,7 +325,7 @@ describe('auth commands', () => {
|
|
|
150
325
|
protoSpy(WebexClient.prototype, 'login').mockResolvedValue(new WebexClient())
|
|
151
326
|
protoSpy(WebexClient.prototype, 'testAuth').mockResolvedValue(mockPerson)
|
|
152
327
|
|
|
153
|
-
await
|
|
328
|
+
await oauthAction({ clientId: 'my-id', clientSecret: 'my-secret', pretty: false })
|
|
154
329
|
|
|
155
330
|
const savedConfig = saveSpy.mock.calls[0][0] as { tokenType: string }
|
|
156
331
|
expect(savedConfig.tokenType).toBe('oauth')
|
|
@@ -174,7 +349,7 @@ describe('auth commands', () => {
|
|
|
174
349
|
protoSpy(WebexClient.prototype, 'login').mockResolvedValue(new WebexClient())
|
|
175
350
|
protoSpy(WebexClient.prototype, 'testAuth').mockResolvedValue(mockPerson)
|
|
176
351
|
|
|
177
|
-
await
|
|
352
|
+
await oauthAction({ clientId: 'my-id', clientSecret: 'my-secret', pretty: false })
|
|
178
353
|
|
|
179
354
|
const savedConfig = saveSpy.mock.calls[0][0] as { clientId: string; clientSecret: string }
|
|
180
355
|
expect(savedConfig.clientId).toBe('my-id')
|
|
@@ -182,7 +357,7 @@ describe('auth commands', () => {
|
|
|
182
357
|
})
|
|
183
358
|
})
|
|
184
359
|
|
|
185
|
-
describe('
|
|
360
|
+
describe('oauthAction non-interactive (no TTY)', () => {
|
|
186
361
|
const device = {
|
|
187
362
|
deviceCode: 'webex-device-code-abc123',
|
|
188
363
|
userCode: 'USER-CODE',
|
|
@@ -199,7 +374,7 @@ describe('auth commands', () => {
|
|
|
199
374
|
const pollSpy = protoSpy(WebexCredentialManager.prototype, 'pollDeviceToken')
|
|
200
375
|
const exitSpy = protoSpy(process, 'exit').mockImplementation(() => undefined as never)
|
|
201
376
|
|
|
202
|
-
await
|
|
377
|
+
await oauthAction({ pretty: false })
|
|
203
378
|
|
|
204
379
|
expect(requestSpy).toHaveBeenCalled()
|
|
205
380
|
expect(exchangeSpy).not.toHaveBeenCalled()
|
|
@@ -212,7 +387,7 @@ describe('auth commands', () => {
|
|
|
212
387
|
expect(output.verification_uri_complete).toBe(device.verificationUriComplete)
|
|
213
388
|
expect(output.user_code).toBe(device.userCode)
|
|
214
389
|
expect(output.device_code).toBe(device.deviceCode)
|
|
215
|
-
expect(output.message).toContain('--device-code')
|
|
390
|
+
expect(output.message).toContain('auth oauth --device-code')
|
|
216
391
|
expect(exitSpy).toHaveBeenCalledWith(0)
|
|
217
392
|
})
|
|
218
393
|
|
|
@@ -221,7 +396,7 @@ describe('auth commands', () => {
|
|
|
221
396
|
protoSpy(WebexCredentialManager.prototype, 'requestDeviceCode').mockResolvedValue(device)
|
|
222
397
|
protoSpy(process, 'exit').mockImplementation(() => undefined as never)
|
|
223
398
|
|
|
224
|
-
await
|
|
399
|
+
await oauthAction({ pretty: false })
|
|
225
400
|
|
|
226
401
|
expect(execSpy).not.toHaveBeenCalled()
|
|
227
402
|
})
|
|
@@ -232,7 +407,7 @@ describe('auth commands', () => {
|
|
|
232
407
|
const requestSpy = protoSpy(WebexCredentialManager.prototype, 'requestDeviceCode')
|
|
233
408
|
const exitSpy = protoSpy(process, 'exit').mockImplementation(() => undefined as never)
|
|
234
409
|
|
|
235
|
-
await
|
|
410
|
+
await oauthAction({ deviceCode: 'webex-device-code-abc123', pretty: false })
|
|
236
411
|
|
|
237
412
|
expect(requestSpy).not.toHaveBeenCalled()
|
|
238
413
|
|
|
@@ -253,7 +428,7 @@ describe('auth commands', () => {
|
|
|
253
428
|
protoSpy(WebexClient.prototype, 'login').mockResolvedValue(new WebexClient())
|
|
254
429
|
protoSpy(WebexClient.prototype, 'testAuth').mockResolvedValue(mockPerson)
|
|
255
430
|
|
|
256
|
-
await
|
|
431
|
+
await oauthAction({
|
|
257
432
|
deviceCode: 'webex-device-code-abc123',
|
|
258
433
|
clientId: 'my-id',
|
|
259
434
|
clientSecret: 'my-secret',
|
|
@@ -276,7 +451,7 @@ describe('auth commands', () => {
|
|
|
276
451
|
protoSpy(WebexCredentialManager.prototype, 'exchangeDeviceCode').mockResolvedValue({ status: 'expired' })
|
|
277
452
|
const exitSpy = protoSpy(process, 'exit').mockImplementation(() => undefined as never)
|
|
278
453
|
|
|
279
|
-
await
|
|
454
|
+
await oauthAction({ deviceCode: 'webex-device-code-abc123', pretty: false })
|
|
280
455
|
|
|
281
456
|
const lastCall = consoleSpy.mock.calls[consoleSpy.mock.calls.length - 1][0] as string
|
|
282
457
|
const output = JSON.parse(lastCall)
|
|
@@ -297,26 +472,12 @@ describe('auth commands', () => {
|
|
|
297
472
|
protoSpy(WebexClient.prototype, 'login').mockResolvedValue(new WebexClient())
|
|
298
473
|
protoSpy(WebexClient.prototype, 'testAuth').mockResolvedValue(mockPerson)
|
|
299
474
|
|
|
300
|
-
await
|
|
475
|
+
await oauthAction({ deviceCode: 'webex-device-code-abc123', pretty: false })
|
|
301
476
|
|
|
302
477
|
expect(exchangeSpy).toHaveBeenCalled()
|
|
303
478
|
expect(requestSpy).not.toHaveBeenCalled()
|
|
304
479
|
expect(pollSpy).not.toHaveBeenCalled()
|
|
305
480
|
})
|
|
306
|
-
|
|
307
|
-
it('still allows --token login when non-interactive (bot/PAT path)', async () => {
|
|
308
|
-
setTTY(false)
|
|
309
|
-
protoSpy(WebexClient.prototype, 'login').mockResolvedValue(new WebexClient())
|
|
310
|
-
protoSpy(WebexClient.prototype, 'testAuth').mockResolvedValue(mockPerson)
|
|
311
|
-
protoSpy(WebexCredentialManager.prototype, 'saveConfig').mockResolvedValue(undefined)
|
|
312
|
-
|
|
313
|
-
await loginAction({ token: 'bot-token-123', pretty: false })
|
|
314
|
-
|
|
315
|
-
const lastCall = consoleSpy.mock.calls[consoleSpy.mock.calls.length - 1][0] as string
|
|
316
|
-
const output = JSON.parse(lastCall)
|
|
317
|
-
expect(output.authenticated).toBe(true)
|
|
318
|
-
expect(output.user.displayName).toBe('Test User')
|
|
319
|
-
})
|
|
320
481
|
})
|
|
321
482
|
|
|
322
483
|
describe('statusAction', () => {
|
|
@@ -1,14 +1,18 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { Writable } from 'node:stream'
|
|
3
|
+
|
|
1
4
|
import { Command } from 'commander'
|
|
2
5
|
|
|
3
6
|
import { collectBrowserProfileOption } from '@/shared/chromium'
|
|
4
7
|
import { handleError } from '@/shared/utils/error-handler'
|
|
5
8
|
import { isInteractive } from '@/shared/utils/interactive'
|
|
6
9
|
import { formatOutput } from '@/shared/utils/output'
|
|
7
|
-
import { info, debug } from '@/shared/utils/stderr'
|
|
10
|
+
import { info, debug, error as stderrError } from '@/shared/utils/stderr'
|
|
8
11
|
|
|
9
12
|
import { getWebexAppCredentials } from '../app-config'
|
|
10
13
|
import { WebexClient } from '../client'
|
|
11
14
|
import { WebexCredentialManager } from '../credential-manager'
|
|
15
|
+
import { loginWithPassword, WEB_CLIENT_ID, WEB_CLIENT_SECRET } from '../password-login'
|
|
12
16
|
import { WebexTokenExtractor } from '../token-extractor'
|
|
13
17
|
import { WebexError } from '../types'
|
|
14
18
|
|
|
@@ -28,6 +32,39 @@ async function openBrowser(url: string): Promise<void> {
|
|
|
28
32
|
exec(command)
|
|
29
33
|
}
|
|
30
34
|
|
|
35
|
+
async function promptText(message: string): Promise<string | undefined> {
|
|
36
|
+
const { createInterface } = await import('node:readline/promises')
|
|
37
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true })
|
|
38
|
+
try {
|
|
39
|
+
const answer = await rl.question(`${message}: `)
|
|
40
|
+
return answer.trim() || undefined
|
|
41
|
+
} finally {
|
|
42
|
+
rl.close()
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function promptHidden(message: string): Promise<string | undefined> {
|
|
47
|
+
const { createInterface } = await import('node:readline/promises')
|
|
48
|
+
const hiddenOutput = new (class extends Writable {
|
|
49
|
+
muted = false
|
|
50
|
+
_write(chunk: Buffer | string, encoding: BufferEncoding, cb: (error?: Error | null) => void): void {
|
|
51
|
+
if (!this.muted) process.stdout.write(chunk, encoding)
|
|
52
|
+
cb()
|
|
53
|
+
}
|
|
54
|
+
})()
|
|
55
|
+
const rl = createInterface({ input: process.stdin, output: hiddenOutput, terminal: true })
|
|
56
|
+
try {
|
|
57
|
+
hiddenOutput.muted = true
|
|
58
|
+
process.stdout.write(`${message}: `)
|
|
59
|
+
const answer = await rl.question('')
|
|
60
|
+
process.stdout.write('\n')
|
|
61
|
+
return answer === '' ? undefined : answer
|
|
62
|
+
} finally {
|
|
63
|
+
hiddenOutput.muted = false
|
|
64
|
+
rl.close()
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
31
68
|
async function resolveClientCredentials(options: {
|
|
32
69
|
clientId?: string
|
|
33
70
|
clientSecret?: string
|
|
@@ -46,9 +83,10 @@ async function resolveClientCredentials(options: {
|
|
|
46
83
|
|
|
47
84
|
interface LoginOptions {
|
|
48
85
|
token?: string
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
86
|
+
email?: string
|
|
87
|
+
password?: string
|
|
88
|
+
passwordStdin?: boolean
|
|
89
|
+
idbrokerHost?: string
|
|
52
90
|
pretty?: boolean
|
|
53
91
|
}
|
|
54
92
|
|
|
@@ -57,26 +95,134 @@ export async function loginAction(options: LoginOptions): Promise<void> {
|
|
|
57
95
|
const credManager = new WebexCredentialManager()
|
|
58
96
|
|
|
59
97
|
if (options.token) {
|
|
60
|
-
|
|
61
|
-
const person = await client.testAuth()
|
|
62
|
-
await credManager.saveConfig({
|
|
63
|
-
accessToken: options.token,
|
|
64
|
-
refreshToken: '',
|
|
65
|
-
expiresAt: 0,
|
|
66
|
-
tokenType: 'manual',
|
|
67
|
-
})
|
|
68
|
-
console.log(
|
|
69
|
-
formatOutput(
|
|
70
|
-
{
|
|
71
|
-
user: { id: person.id, displayName: person.displayName, emails: person.emails },
|
|
72
|
-
authenticated: true,
|
|
73
|
-
},
|
|
74
|
-
options.pretty,
|
|
75
|
-
),
|
|
76
|
-
)
|
|
98
|
+
await loginWithToken(credManager, options.token, options.pretty)
|
|
77
99
|
return
|
|
78
100
|
}
|
|
79
101
|
|
|
102
|
+
if (options.password && options.passwordStdin) {
|
|
103
|
+
throw new Error('Use only one of --password or --password-stdin.')
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const interactive = isInteractive()
|
|
107
|
+
|
|
108
|
+
let email = options.email
|
|
109
|
+
if (!email) {
|
|
110
|
+
if (!interactive) {
|
|
111
|
+
console.log(
|
|
112
|
+
formatOutput(
|
|
113
|
+
{
|
|
114
|
+
error:
|
|
115
|
+
'Email required. Use --email <email> with --password or --password-stdin, ' +
|
|
116
|
+
'or run "agent-webex auth oauth" for browser-based login.',
|
|
117
|
+
},
|
|
118
|
+
options.pretty,
|
|
119
|
+
),
|
|
120
|
+
)
|
|
121
|
+
process.exit(1)
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
email = await promptText('Webex email')
|
|
125
|
+
if (!email) {
|
|
126
|
+
stderrError('Email is required.')
|
|
127
|
+
process.exit(1)
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
let password = options.password
|
|
133
|
+
if (!password && options.passwordStdin) {
|
|
134
|
+
password = readFileSync(0, 'utf-8').replace(/\r?\n$/, '')
|
|
135
|
+
}
|
|
136
|
+
if (!password) {
|
|
137
|
+
if (!interactive) {
|
|
138
|
+
console.log(
|
|
139
|
+
formatOutput(
|
|
140
|
+
{ error: 'Password required. Use --password or --password-stdin, or run interactively to be prompted.' },
|
|
141
|
+
options.pretty,
|
|
142
|
+
),
|
|
143
|
+
)
|
|
144
|
+
process.exit(1)
|
|
145
|
+
return
|
|
146
|
+
}
|
|
147
|
+
password = await promptHidden('Password')
|
|
148
|
+
if (!password) {
|
|
149
|
+
stderrError('Password is required.')
|
|
150
|
+
process.exit(1)
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
await loginWithEmailPassword(credManager, email, password, options)
|
|
156
|
+
} catch (error) {
|
|
157
|
+
handleError(error as Error)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function loginWithToken(credManager: WebexCredentialManager, token: string, pretty?: boolean): Promise<void> {
|
|
162
|
+
const client = await new WebexClient().login({ token })
|
|
163
|
+
const person = await client.testAuth()
|
|
164
|
+
await credManager.saveConfig({
|
|
165
|
+
accessToken: token,
|
|
166
|
+
refreshToken: '',
|
|
167
|
+
expiresAt: 0,
|
|
168
|
+
tokenType: 'manual',
|
|
169
|
+
})
|
|
170
|
+
console.log(
|
|
171
|
+
formatOutput(
|
|
172
|
+
{
|
|
173
|
+
user: { id: person.id, displayName: person.displayName, emails: person.emails },
|
|
174
|
+
authenticated: true,
|
|
175
|
+
},
|
|
176
|
+
pretty,
|
|
177
|
+
),
|
|
178
|
+
)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function loginWithEmailPassword(
|
|
182
|
+
credManager: WebexCredentialManager,
|
|
183
|
+
email: string,
|
|
184
|
+
password: string,
|
|
185
|
+
options: LoginOptions,
|
|
186
|
+
): Promise<void> {
|
|
187
|
+
const config = await loginWithPassword(email, password, { idbrokerHost: options.idbrokerHost })
|
|
188
|
+
|
|
189
|
+
await credManager.saveConfig({
|
|
190
|
+
...config,
|
|
191
|
+
tokenType: 'password',
|
|
192
|
+
clientId: WEB_CLIENT_ID,
|
|
193
|
+
clientSecret: WEB_CLIENT_SECRET,
|
|
194
|
+
encryptionKeys: {},
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
const client = await new WebexClient().login({
|
|
198
|
+
token: config.accessToken,
|
|
199
|
+
deviceUrl: config.deviceUrl,
|
|
200
|
+
tokenType: 'password',
|
|
201
|
+
})
|
|
202
|
+
const person = await client.testAuth()
|
|
203
|
+
|
|
204
|
+
console.log(
|
|
205
|
+
formatOutput(
|
|
206
|
+
{
|
|
207
|
+
authenticated: true,
|
|
208
|
+
user: { id: person.id, displayName: person.displayName, emails: person.emails },
|
|
209
|
+
},
|
|
210
|
+
options.pretty,
|
|
211
|
+
),
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
interface OAuthOptions {
|
|
216
|
+
deviceCode?: string
|
|
217
|
+
clientId?: string
|
|
218
|
+
clientSecret?: string
|
|
219
|
+
pretty?: boolean
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export async function oauthAction(options: OAuthOptions): Promise<void> {
|
|
223
|
+
try {
|
|
224
|
+
const credManager = new WebexCredentialManager()
|
|
225
|
+
|
|
80
226
|
if (options.deviceCode) {
|
|
81
227
|
await finishDeviceGrant(credManager, options)
|
|
82
228
|
return
|
|
@@ -126,7 +272,7 @@ export async function loginAction(options: LoginOptions): Promise<void> {
|
|
|
126
272
|
|
|
127
273
|
async function startNonInteractiveDeviceGrant(
|
|
128
274
|
credManager: WebexCredentialManager,
|
|
129
|
-
options:
|
|
275
|
+
options: OAuthOptions,
|
|
130
276
|
): Promise<void> {
|
|
131
277
|
const { clientId } = await resolveClientCredentials(options)
|
|
132
278
|
const device = await credManager.requestDeviceCode(clientId)
|
|
@@ -142,7 +288,7 @@ async function startNonInteractiveDeviceGrant(
|
|
|
142
288
|
device_code: device.deviceCode,
|
|
143
289
|
expires_at: expiresAt,
|
|
144
290
|
message:
|
|
145
|
-
'Show the user `verification_uri` and `user_code` (or just `verification_uri_complete`) and ask them to approve access in any browser. After they approve, run `agent-webex auth
|
|
291
|
+
'Show the user `verification_uri` and `user_code` (or just `verification_uri_complete`) and ask them to approve access in any browser. After they approve, run `agent-webex auth oauth --device-code <device_code>` to retrieve the token. The device code expires at `expires_at`. If you passed `--client-id`/`--client-secret`, pass them again on the second call.',
|
|
146
292
|
},
|
|
147
293
|
options.pretty,
|
|
148
294
|
),
|
|
@@ -150,7 +296,7 @@ async function startNonInteractiveDeviceGrant(
|
|
|
150
296
|
process.exit(0)
|
|
151
297
|
}
|
|
152
298
|
|
|
153
|
-
async function finishDeviceGrant(credManager: WebexCredentialManager, options:
|
|
299
|
+
async function finishDeviceGrant(credManager: WebexCredentialManager, options: OAuthOptions): Promise<void> {
|
|
154
300
|
const { clientId, clientSecret } = await resolveClientCredentials(options)
|
|
155
301
|
const result = await credManager.exchangeDeviceCode(options.deviceCode!, clientId, clientSecret)
|
|
156
302
|
|
|
@@ -177,7 +323,7 @@ async function finishDeviceGrant(credManager: WebexCredentialManager, options: L
|
|
|
177
323
|
next_action: 'still_pending',
|
|
178
324
|
device_code: options.deviceCode,
|
|
179
325
|
message:
|
|
180
|
-
'User has not approved access yet. Confirm with the user that they completed authorization in the browser, then run `agent-webex auth
|
|
326
|
+
'User has not approved access yet. Confirm with the user that they completed authorization in the browser, then run `agent-webex auth oauth --device-code <device_code>` again to retry.',
|
|
181
327
|
},
|
|
182
328
|
options.pretty,
|
|
183
329
|
),
|
|
@@ -191,7 +337,7 @@ async function finishDeviceGrant(credManager: WebexCredentialManager, options: L
|
|
|
191
337
|
{
|
|
192
338
|
next_action: 'restart',
|
|
193
339
|
error: result.status === 'expired' ? 'Device code expired.' : `Device code exchange failed: ${result.message}`,
|
|
194
|
-
message: 'This device code is no longer valid. Run `agent-webex auth
|
|
340
|
+
message: 'This device code is no longer valid. Run `agent-webex auth oauth` to start a new login.',
|
|
195
341
|
},
|
|
196
342
|
options.pretty,
|
|
197
343
|
),
|
|
@@ -252,7 +398,7 @@ export async function extractAction(options: {
|
|
|
252
398
|
{
|
|
253
399
|
error:
|
|
254
400
|
'No Webex token found in any browser. Make sure you are logged in at https://web.webex.com (not webex.com) in Chrome, Edge, Arc, or Brave.',
|
|
255
|
-
hint: 'Run "auth
|
|
401
|
+
hint: 'Run "auth oauth" for OAuth Device Grant flow, or --debug for more info.',
|
|
256
402
|
},
|
|
257
403
|
options.pretty,
|
|
258
404
|
),
|
|
@@ -304,7 +450,7 @@ export async function extractAction(options: {
|
|
|
304
450
|
formatOutput(
|
|
305
451
|
{
|
|
306
452
|
error: 'Extracted browser token is expired and could not be refreshed.',
|
|
307
|
-
hint: 'Log in at https://web.webex.com (not webex.com) in your browser, then run "auth extract" again. Or use "auth
|
|
453
|
+
hint: 'Log in at https://web.webex.com (not webex.com) in your browser, then run "auth extract" again. Or use "auth oauth" for OAuth Device Grant flow.',
|
|
308
454
|
},
|
|
309
455
|
options.pretty,
|
|
310
456
|
),
|
|
@@ -366,21 +512,34 @@ export const authCommand = new Command('auth')
|
|
|
366
512
|
.addCommand(
|
|
367
513
|
new Command('login')
|
|
368
514
|
.description(
|
|
369
|
-
'Log in to Webex
|
|
370
|
-
'
|
|
371
|
-
'
|
|
372
|
-
'browser, call again with --device-code to exchange it for a token. Pass --token for ' +
|
|
373
|
-
'fully unattended auth.',
|
|
515
|
+
'Log in to Webex with your email and password (first-party token, messages appear as you). ' +
|
|
516
|
+
'Run with no flags in a terminal to be prompted for email and password. ' +
|
|
517
|
+
'Pass --token for a bot or personal access token. For browser-based OAuth, use `auth oauth`.',
|
|
374
518
|
)
|
|
519
|
+
.option('--email <email>', 'Webex email address (prompted if omitted in a terminal)')
|
|
520
|
+
.option('--password <password>', 'Password for --email login (prompted securely if omitted in a terminal)')
|
|
521
|
+
.option('--password-stdin', 'Read the password for --email login from stdin')
|
|
522
|
+
.option('--idbroker-host <host>', 'Override IdBroker host for email/password login')
|
|
375
523
|
.option('--token <token>', 'Use a bot token or personal access token directly')
|
|
524
|
+
.option('--pretty', 'Pretty print JSON output')
|
|
525
|
+
.action(loginAction),
|
|
526
|
+
)
|
|
527
|
+
.addCommand(
|
|
528
|
+
new Command('oauth')
|
|
529
|
+
.description(
|
|
530
|
+
'Log in to Webex via OAuth Device Grant. In a TTY this opens a browser and waits for approval. ' +
|
|
531
|
+
'When non-interactive (AI agents, CI/CD): the first call starts the flow and returns a verification ' +
|
|
532
|
+
'URL, user code, and device code. After the user approves in a browser, call again with --device-code ' +
|
|
533
|
+
'to exchange it for a token.',
|
|
534
|
+
)
|
|
376
535
|
.option(
|
|
377
536
|
'--device-code <code>',
|
|
378
|
-
'OAuth Device Grant code returned from a previous non-interactive `auth
|
|
537
|
+
'OAuth Device Grant code returned from a previous non-interactive `auth oauth` call',
|
|
379
538
|
)
|
|
380
539
|
.option('--client-id <id>', 'Webex Integration client ID')
|
|
381
540
|
.option('--client-secret <secret>', 'Webex Integration client secret')
|
|
382
541
|
.option('--pretty', 'Pretty print JSON output')
|
|
383
|
-
.action(
|
|
542
|
+
.action(oauthAction),
|
|
384
543
|
)
|
|
385
544
|
.addCommand(
|
|
386
545
|
new Command('extract')
|