testronaut 1.3.7 → 1.4.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.
@@ -0,0 +1,445 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import fs from 'fs';
3
+ import os from 'os';
4
+ import path from 'path';
5
+
6
+ import {
7
+ getMfaCode,
8
+ resolveMfaNickname,
9
+ __test__,
10
+ } from '../../tools/mfaCode.js';
11
+
12
+ function response({ ok = true, status = 200, statusText = 'OK', body = {}, text, contentType = 'application/json' } = {}) {
13
+ return {
14
+ ok,
15
+ status,
16
+ statusText,
17
+ headers: {
18
+ get: (name) => name.toLowerCase() === 'content-type' ? contentType : '',
19
+ },
20
+ text: vi.fn().mockResolvedValue(text ?? JSON.stringify(body)),
21
+ };
22
+ }
23
+
24
+ describe('tools/mfaCode', () => {
25
+ const oldEnv = { ...process.env };
26
+
27
+ beforeEach(() => {
28
+ process.env = { ...oldEnv };
29
+ vi.clearAllMocks();
30
+ });
31
+
32
+ it('resolves MFA nickname from args, env, and config', () => {
33
+ expect(resolveMfaNickname({ nickname: 'from-args' }, { mfaName: 'from-config' })).toBe('from-args');
34
+
35
+ process.env.TESTRONAUT_MFA_NAME = 'from-env';
36
+ expect(resolveMfaNickname({}, { mfaName: 'from-config' })).toBe('from-env');
37
+
38
+ delete process.env.TESTRONAUT_MFA_NAME;
39
+ expect(resolveMfaNickname({}, { mfa: { name: 'from-nested-config' } })).toBe('from-nested-config');
40
+ });
41
+
42
+ it('returns a graceful error when nickname is missing', async () => {
43
+ const fetchImpl = vi.fn().mockResolvedValue(response({
44
+ body: { nicknames: [] },
45
+ }));
46
+
47
+ const result = await getMfaCode(
48
+ {},
49
+ { config: { sessionToken: 'session-token' }, fetchImpl }
50
+ );
51
+
52
+ expect(result).toMatchObject({
53
+ ok: false,
54
+ code: 'missing_nickname',
55
+ availableNicknames: [],
56
+ mfaListStatus: 'available',
57
+ });
58
+ });
59
+
60
+ it('returns a graceful error when sessionToken is missing', async () => {
61
+ const result = await getMfaCode({ nickname: 'github' }, { config: {} });
62
+
63
+ expect(result).toMatchObject({
64
+ ok: false,
65
+ code: 'missing_session_token',
66
+ nickname: 'github',
67
+ });
68
+ });
69
+
70
+ it('retrieves and normalizes an MFA code from the API', async () => {
71
+ const fetchImpl = vi.fn().mockResolvedValue(response({
72
+ body: {
73
+ nickname: 'GitHub',
74
+ mfaCode: {
75
+ code: '123456',
76
+ expiresAt: '2026-07-02T20:00:30.000Z',
77
+ secondsRemaining: 20,
78
+ },
79
+ },
80
+ }));
81
+
82
+ const result = await getMfaCode(
83
+ { nickname: 'GitHub', waitForFreshCode: false },
84
+ {
85
+ apiBase: 'https://api.example.test',
86
+ config: { sessionToken: 'session-token' },
87
+ fetchImpl,
88
+ }
89
+ );
90
+
91
+ expect(fetchImpl).toHaveBeenCalledWith(
92
+ 'https://api.example.test/api/mfa?nickname=GitHub',
93
+ expect.objectContaining({
94
+ method: 'GET',
95
+ headers: expect.objectContaining({
96
+ Authorization: 'Bearer session-token',
97
+ }),
98
+ })
99
+ );
100
+ expect(result).toEqual({
101
+ ok: true,
102
+ codeType: 'totp',
103
+ nickname: 'GitHub',
104
+ value: '123456',
105
+ redactedValue: '•••••• (6)',
106
+ mfaCode: {
107
+ code: '123456',
108
+ expiresAt: '2026-07-02T20:00:30.000Z',
109
+ secondsRemaining: 20,
110
+ },
111
+ });
112
+ });
113
+
114
+ it('lists MFA nicknames and auto-selects when exactly one exists', async () => {
115
+ const fetchImpl = vi
116
+ .fn()
117
+ .mockResolvedValueOnce(response({
118
+ body: { nicknames: ['GitHub Prod'] },
119
+ }))
120
+ .mockResolvedValueOnce(response({
121
+ body: {
122
+ nickname: 'GitHub Prod',
123
+ mfaCode: { code: '123456', secondsRemaining: 20 },
124
+ },
125
+ }));
126
+
127
+ const result = await getMfaCode(
128
+ { waitForFreshCode: false },
129
+ { apiBase: 'https://api.example.test', config: { sessionToken: 'session-token' }, fetchImpl }
130
+ );
131
+
132
+ expect(fetchImpl).toHaveBeenNthCalledWith(
133
+ 1,
134
+ 'https://api.example.test/api/mfa/list',
135
+ expect.objectContaining({ method: 'GET' })
136
+ );
137
+ expect(fetchImpl).toHaveBeenNthCalledWith(
138
+ 2,
139
+ 'https://api.example.test/api/mfa?nickname=GitHub+Prod',
140
+ expect.objectContaining({ method: 'GET' })
141
+ );
142
+ expect(result).toMatchObject({
143
+ ok: true,
144
+ nickname: 'GitHub Prod',
145
+ value: '123456',
146
+ resolvedFromList: true,
147
+ availableNicknames: ['GitHub Prod'],
148
+ });
149
+ });
150
+
151
+ it('returns available nicknames when no nickname is provided and multiple exist', async () => {
152
+ const fetchImpl = vi.fn().mockResolvedValue(response({
153
+ body: { nicknames: ['GitHub Prod', 'AWS Root'] },
154
+ }));
155
+
156
+ const result = await getMfaCode(
157
+ {},
158
+ { config: { sessionToken: 'session-token' }, fetchImpl }
159
+ );
160
+
161
+ expect(result).toMatchObject({
162
+ ok: false,
163
+ code: 'missing_nickname',
164
+ availableNicknames: ['GitHub Prod', 'AWS Root'],
165
+ mfaListStatus: 'available',
166
+ });
167
+ expect(result.error).toContain('GitHub Prod, AWS Root');
168
+ });
169
+
170
+ it('maps paid-plan failures without throwing', async () => {
171
+ const fetchImpl = vi.fn().mockResolvedValue(response({
172
+ ok: false,
173
+ status: 402,
174
+ statusText: 'Payment Required',
175
+ body: { error: 'MFA code generation requires an active paid subscription' },
176
+ }));
177
+
178
+ const result = await getMfaCode(
179
+ { nickname: 'github' },
180
+ { config: { sessionToken: 'session-token' }, fetchImpl }
181
+ );
182
+
183
+ expect(result).toMatchObject({
184
+ ok: false,
185
+ status: 402,
186
+ code: 'premium_required',
187
+ error:
188
+ 'This Testronaut account is not eligible for automated MFA because it does not have an active paid subscription.',
189
+ apiError: 'MFA code generation requires an active paid subscription',
190
+ nickname: 'github',
191
+ });
192
+ });
193
+
194
+ it('accepts alternate API response code shapes', () => {
195
+ expect(__test__.normalizeApiCode({ code: '654321' })).toMatchObject({ value: '654321' });
196
+ expect(__test__.normalizeApiCode({ totp: { value: '123456' } })).toMatchObject({ value: '123456' });
197
+ expect(__test__.normalizeApiCode({ otp: { token: '234567' } })).toMatchObject({ value: '234567' });
198
+ expect(__test__.normalizeApiCode({ mfaCode: { mfaCode: '345678' } })).toMatchObject({ value: '345678' });
199
+ expect(__test__.normalizeApiCode({ mfaCode: { code: 12345 } })).toMatchObject({ value: '012345' });
200
+ });
201
+
202
+ it('returns safe diagnostics for unrecognized successful responses', async () => {
203
+ const fetchImpl = vi.fn().mockResolvedValue(response({
204
+ body: { nickname: 'GitHub', message: 'ok but no code here' },
205
+ }));
206
+
207
+ const result = await getMfaCode(
208
+ { nickname: 'github' },
209
+ { config: { sessionToken: 'session-token' }, fetchImpl }
210
+ );
211
+
212
+ expect(result).toMatchObject({
213
+ ok: false,
214
+ status: 200,
215
+ code: 'invalid_response',
216
+ contentType: 'application/json',
217
+ responseKeys: ['nickname', 'message'],
218
+ nickname: 'github',
219
+ });
220
+ expect(result.error).toContain('Response keys: nickname, message');
221
+ });
222
+
223
+ it('maps missing MFA entries without throwing', async () => {
224
+ const fetchImpl = vi.fn().mockResolvedValue(response({
225
+ ok: false,
226
+ status: 404,
227
+ statusText: 'Not Found',
228
+ body: { error: 'MFA entry not found' },
229
+ }));
230
+
231
+ const result = await getMfaCode(
232
+ { nickname: 'github' },
233
+ { config: { sessionToken: 'session-token' }, fetchImpl }
234
+ );
235
+
236
+ expect(result).toMatchObject({
237
+ ok: false,
238
+ status: 404,
239
+ code: 'not_found',
240
+ nickname: 'github',
241
+ });
242
+ });
243
+
244
+ it('reports a clear error when the MFA code endpoint returns an app HTML shell', async () => {
245
+ const fetchImpl = vi.fn()
246
+ .mockResolvedValueOnce(response({
247
+ contentType: 'text/html; charset=utf-8',
248
+ text: '<!DOCTYPE html><html><head></head><body>App shell</body></html>',
249
+ }))
250
+ .mockResolvedValueOnce(response({
251
+ contentType: 'text/html; charset=utf-8',
252
+ text: '<!DOCTYPE html><html><head></head><body>App shell</body></html>',
253
+ }));
254
+
255
+ const result = await getMfaCode(
256
+ { nickname: 'github' },
257
+ { apiBase: 'https://staging.api.example.test', config: { sessionToken: 'session-token' }, fetchImpl }
258
+ );
259
+
260
+ expect(fetchImpl).toHaveBeenNthCalledWith(
261
+ 1,
262
+ 'https://staging.api.example.test/api/mfa?nickname=github',
263
+ expect.objectContaining({
264
+ headers: expect.objectContaining({
265
+ Accept: 'application/json',
266
+ }),
267
+ })
268
+ );
269
+ expect(result).toMatchObject({
270
+ ok: false,
271
+ status: 200,
272
+ code: 'non_json_response',
273
+ contentType: 'text/html; charset=utf-8',
274
+ nickname: 'github',
275
+ availableNicknames: [],
276
+ mfaListStatus: {
277
+ code: 'non_json_response',
278
+ status: 200,
279
+ },
280
+ });
281
+ expect(result.error).toMatch(/non-JSON response/i);
282
+ expect(result.apiError).toContain('https://staging.api.example.test/api/mfa');
283
+ });
284
+
285
+ it('uses the list endpoint to recover from fuzzy nickname mismatches', async () => {
286
+ const fetchImpl = vi
287
+ .fn()
288
+ .mockResolvedValueOnce(response({
289
+ ok: false,
290
+ status: 404,
291
+ statusText: 'Not Found',
292
+ body: { error: 'MFA entry not found' },
293
+ }))
294
+ .mockResolvedValueOnce(response({
295
+ body: { nicknames: ['rudy-poo'] },
296
+ }))
297
+ .mockResolvedValueOnce(response({
298
+ body: {
299
+ nickname: 'rudy-poo',
300
+ mfaCode: { code: '123456', secondsRemaining: 20 },
301
+ },
302
+ }));
303
+
304
+ const result = await getMfaCode(
305
+ { nickname: 'rudy poo', waitForFreshCode: false },
306
+ { apiBase: 'https://api.example.test', config: { sessionToken: 'session-token' }, fetchImpl }
307
+ );
308
+
309
+ expect(fetchImpl).toHaveBeenNthCalledWith(
310
+ 1,
311
+ 'https://api.example.test/api/mfa?nickname=rudy+poo',
312
+ expect.objectContaining({ method: 'GET' })
313
+ );
314
+ expect(fetchImpl).toHaveBeenNthCalledWith(
315
+ 2,
316
+ 'https://api.example.test/api/mfa/list',
317
+ expect.objectContaining({ method: 'GET' })
318
+ );
319
+ expect(fetchImpl).toHaveBeenNthCalledWith(
320
+ 3,
321
+ 'https://api.example.test/api/mfa?nickname=rudy-poo',
322
+ expect.objectContaining({ method: 'GET' })
323
+ );
324
+ expect(result).toMatchObject({
325
+ ok: true,
326
+ nickname: 'rudy-poo',
327
+ value: '123456',
328
+ requestedNickname: 'rudy poo',
329
+ resolvedFromList: true,
330
+ availableNicknames: ['rudy-poo'],
331
+ });
332
+ });
333
+
334
+ it('adds available nicknames when a requested nickname is not found', async () => {
335
+ const fetchImpl = vi
336
+ .fn()
337
+ .mockResolvedValueOnce(response({
338
+ ok: false,
339
+ status: 404,
340
+ statusText: 'Not Found',
341
+ body: { error: 'MFA entry not found' },
342
+ }))
343
+ .mockResolvedValueOnce(response({
344
+ body: { entries: [{ nickname: 'GitHub Prod' }, { nickname: 'AWS Root' }] },
345
+ }));
346
+
347
+ const result = await getMfaCode(
348
+ { nickname: 'missing' },
349
+ { config: { sessionToken: 'session-token' }, fetchImpl }
350
+ );
351
+
352
+ expect(result).toMatchObject({
353
+ ok: false,
354
+ code: 'not_found',
355
+ availableNicknames: ['GitHub Prod', 'AWS Root'],
356
+ mfaListStatus: 'available',
357
+ });
358
+ });
359
+
360
+ it('uses the effective API base and Vercel bypass header from env', async () => {
361
+ process.env.TESTRONAUT_API_BASE_EFFECTIVE = 'https://staging.api.example.test/';
362
+ process.env.TESTRONAUT_VERCEL_BYPASS = 'bypass-secret';
363
+ const fetchImpl = vi.fn().mockResolvedValue(response({
364
+ body: { nickname: 'GitHub', mfaCode: { code: '123456', secondsRemaining: 20 } },
365
+ }));
366
+
367
+ await getMfaCode(
368
+ { nickname: 'GitHub', waitForFreshCode: false },
369
+ { config: { sessionToken: 'session-token' }, fetchImpl }
370
+ );
371
+
372
+ expect(fetchImpl).toHaveBeenCalledWith(
373
+ 'https://staging.api.example.test/api/mfa?nickname=GitHub',
374
+ expect.objectContaining({
375
+ headers: expect.objectContaining({
376
+ Authorization: 'Bearer session-token',
377
+ 'x-vercel-protection-bypass': 'bypass-secret',
378
+ }),
379
+ })
380
+ );
381
+ });
382
+
383
+ it('writes sanitized MFA API request and response debug logs when debug is enabled', async () => {
384
+ const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'testronaut-mfa-debug-'));
385
+ process.env.TESTRONAUT_API_DEBUG = '1';
386
+ process.env.TESTRONAUT_VERCEL_BYPASS = 'bypass-secret';
387
+ const fetchImpl = vi
388
+ .fn()
389
+ .mockResolvedValueOnce(response({
390
+ body: { nickname: 'GitHub', mfaCode: { code: '123456', secondsRemaining: 20 } },
391
+ }))
392
+ .mockResolvedValueOnce(response({
393
+ body: { nicknames: ['GitHub', 'AWS Root'] },
394
+ }));
395
+
396
+ await getMfaCode(
397
+ { nickname: 'GitHub', waitForFreshCode: false },
398
+ {
399
+ cwd,
400
+ apiBase: 'https://api.example.test',
401
+ config: { sessionToken: 'session-token' },
402
+ fetchImpl,
403
+ }
404
+ );
405
+
406
+ await getMfaCode(
407
+ {},
408
+ {
409
+ cwd,
410
+ apiBase: 'https://api.example.test',
411
+ config: { sessionToken: 'session-token' },
412
+ fetchImpl,
413
+ }
414
+ );
415
+
416
+ const logPath = path.join(cwd, 'missions/mission_reports/api-debug.log');
417
+ const log = fs.readFileSync(logPath, 'utf8');
418
+
419
+ expect(log).toContain('"endpoint": "get_code"');
420
+ expect(log).toContain('https://api.example.test/api/mfa?nickname=GitHub');
421
+ expect(log).toContain('"endpoint": "list"');
422
+ expect(log).toContain('https://api.example.test/api/mfa/list');
423
+ expect(log).toContain('"statusCode": 200');
424
+ expect(log).toContain('"responseShape": "recognized_code"');
425
+ expect(log).toContain('"availableNicknames"');
426
+ expect(log).toContain('AWS Root');
427
+ expect(log).toContain('<redacted>');
428
+ expect(log).not.toContain('session-token');
429
+ expect(log).not.toContain('bypass-secret');
430
+ expect(log).not.toContain('123456');
431
+ });
432
+
433
+ it('classifies feature-disabled 404s separately', () => {
434
+ expect(__test__.errorCodeForStatus(404, 'MFA endpoints are not enabled')).toBe('feature_disabled');
435
+ });
436
+
437
+ it('formats friendly account access errors', () => {
438
+ expect(__test__.friendlyMfaError('premium_required')).toMatch(/not eligible/i);
439
+ });
440
+
441
+ it('matches listed MFA nicknames case-insensitively and punctuation-insensitively', () => {
442
+ expect(__test__.findListedNicknameMatch('RUDY POO', ['rudy-poo'])).toBe('rudy-poo');
443
+ expect(__test__.findListedNicknameMatch('github prod', ['GitHub Prod'])).toBe('GitHub Prod');
444
+ });
445
+ });
@@ -23,6 +23,14 @@ describe('tools/toolSchema', () => {
23
23
  expect(entry.function.parameters?.properties?.maxLength?.default).toBe(64);
24
24
  });
25
25
 
26
+ it('includes get_mfa_code for automated TOTP retrieval', () => {
27
+ const entry = toolsSchema.find(t => t.function?.name === 'get_mfa_code');
28
+ expect(entry?.type).toBe('function');
29
+ expect(entry.function.description).toMatch(/TOTP MFA code/i);
30
+ expect(entry.function.parameters?.properties).toHaveProperty('nickname');
31
+ expect(entry.function.parameters?.properties?.minSecondsRemaining?.default).toBe(5);
32
+ });
33
+
26
34
  it('defines navigate and download_file with required fields', () => {
27
35
  const nav = toolsSchema.find(t => t.function?.name === 'navigate');
28
36
  const download = toolsSchema.find(t => t.function?.name === 'download_file');
@@ -23,6 +23,7 @@ import fs from 'fs';
23
23
  import path from 'path';
24
24
  import { ensureBrowsers } from '../tools/playwrightSetup.js';
25
25
  import { requestHumanInput } from './humanInput.js';
26
+ import { getMfaCode } from './mfaCode.js';
26
27
 
27
28
  const FILES_DIR = path.join('missions', 'files');
28
29
  const REPORTS_DIR = path.join('missions', 'mission_reports');
@@ -1362,6 +1363,7 @@ export const CHROME_TOOL_MAP = {
1362
1363
  upload_file: (b, args) => b.upload_file(args),
1363
1364
  download_file: (b, args) => b.download_file(args),
1364
1365
  list_local_files: (b, args) => b.list_local_files(args),
1366
+ get_mfa_code: (b, args) => getMfaCode(args),
1365
1367
  request_human_input: (b, args, agentMemory) => requestHumanInput(args, agentMemory?.humanInput),
1366
1368
  resource_progress: (b, args, agentMemory) => {
1367
1369
  const prog = agentMemory?.docProgress;