testronaut 1.3.6 → 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.
package/core/turnLoop.js CHANGED
@@ -107,6 +107,28 @@ function extractDocIdFromUrl(url = '') {
107
107
  return m ? m[1] : '';
108
108
  }
109
109
 
110
+ function isMfaLikeFill(fnName, args = {}) {
111
+ if (fnName !== 'fill') return false;
112
+ const haystack = [
113
+ args.selector,
114
+ args.label,
115
+ args.placeholder,
116
+ args.name,
117
+ args.testId,
118
+ args.role,
119
+ ].map(v => String(v || '').toLowerCase()).join(' ');
120
+
121
+ return /\b(mfa|totp|otp|verification|2fa|code)\b/.test(haystack);
122
+ }
123
+
124
+ function getFillText(args = {}) {
125
+ return String(args.text ?? args.value ?? args.input ?? args.keys ?? '');
126
+ }
127
+
128
+ function safeListLabel(list = []) {
129
+ return Array.isArray(list) && list.length ? list.join(', ') : '(none)';
130
+ }
131
+
110
132
  // Rolling token counters used for self-throttling
111
133
  let totalTokensUsed = 0;
112
134
  let turnTimestamps = [];
@@ -428,6 +450,97 @@ export const turnLoop = async (
428
450
  step.events.push(errorMessage
429
451
  ? `👤 Human-in-the-loop input ${step.humanInput.status}: ${errorMessage.replace(/^ERROR:\s*/, '')}`
430
452
  : '👤 Human-in-the-loop input provided.');
453
+
454
+ if (!errorMessage) {
455
+ try {
456
+ const parsed = JSON.parse(result);
457
+ agentMemory.lastVerificationInput = {
458
+ source: 'human_input',
459
+ value: parsed.value,
460
+ codeType: parsed.codeType || args.codeType || 'verification_code',
461
+ };
462
+ } catch {
463
+ // ignore malformed tool result
464
+ }
465
+ }
466
+ }
467
+
468
+ if (fnName === 'get_mfa_code') {
469
+ step.mfa = step.mfa || {};
470
+ step.mfa.requested = true;
471
+ try {
472
+ const parsed = JSON.parse(result);
473
+ step.mfa.nickname = parsed.nickname || args.nickname || null;
474
+ step.mfa.status = parsed.ok ? 'provided' : parsed.code || 'unavailable';
475
+ step.mfa.availableNicknames = parsed.availableNicknames || [];
476
+ step.mfa.responseKeys = parsed.responseKeys || [];
477
+ step.mfa.listStatus = parsed.mfaListStatus || null;
478
+ agentMemory.lastMfaLookup = parsed.ok
479
+ ? {
480
+ ok: true,
481
+ nickname: parsed.nickname || args.nickname || null,
482
+ value: parsed.value,
483
+ secondsRemaining: parsed.mfaCode?.secondsRemaining,
484
+ resolvedFromList: !!parsed.resolvedFromList,
485
+ requestedNickname: parsed.requestedNickname,
486
+ availableNicknames: parsed.availableNicknames || [],
487
+ }
488
+ : {
489
+ ok: false,
490
+ nickname: parsed.nickname || args.nickname || null,
491
+ code: parsed.code,
492
+ error: parsed.error,
493
+ availableNicknames: parsed.availableNicknames || [],
494
+ responseKeys: parsed.responseKeys || [],
495
+ mfaListStatus: parsed.mfaListStatus || null,
496
+ };
497
+
498
+ step.events.push(
499
+ parsed.ok
500
+ ? `🔐 MFA code retrieved for "${step.mfa.nickname || 'configured MFA'}".`
501
+ : `🔐 MFA code unavailable: ${parsed.error || parsed.code || 'unknown error'}`
502
+ );
503
+ if (parsed.ok) {
504
+ const detailLine = [
505
+ `🔐 MFA source: API`,
506
+ `nickname="${step.mfa.nickname || 'configured MFA'}"`,
507
+ parsed.resolvedFromList ? `resolvedFromList=true` : null,
508
+ Number.isFinite(parsed.mfaCode?.secondsRemaining)
509
+ ? `secondsRemaining=${parsed.mfaCode.secondsRemaining}`
510
+ : null,
511
+ ].filter(Boolean).join(' ');
512
+ console.log(detailLine);
513
+ step.events.push(detailLine);
514
+ } else {
515
+ const reasonLine = `🔐 MFA unavailable reason: ${parsed.code || 'unknown'} - ${parsed.error || 'unknown error'}`;
516
+ console.log(reasonLine);
517
+ step.events.push(reasonLine);
518
+
519
+ if (Array.isArray(parsed.availableNicknames)) {
520
+ const listLine = `🔐 MFA list endpoint nicknames: ${safeListLabel(parsed.availableNicknames)}`;
521
+ console.log(listLine);
522
+ step.events.push(listLine);
523
+ }
524
+
525
+ if (parsed.mfaListStatus && parsed.mfaListStatus !== 'available') {
526
+ const listStatusLine = `🔐 MFA list endpoint status: ${JSON.stringify(parsed.mfaListStatus)}`;
527
+ console.log(listStatusLine);
528
+ step.events.push(listStatusLine);
529
+ } else if (parsed.mfaListStatus === 'available') {
530
+ const listStatusLine = '🔐 MFA list endpoint status: available';
531
+ console.log(listStatusLine);
532
+ step.events.push(listStatusLine);
533
+ }
534
+
535
+ if (Array.isArray(parsed.responseKeys) && parsed.responseKeys.length) {
536
+ const keysLine = `🔐 MFA API response keys: ${parsed.responseKeys.join(', ')}`;
537
+ console.log(keysLine);
538
+ step.events.push(keysLine);
539
+ }
540
+ }
541
+ } catch {
542
+ step.mfa.status = errorMessage ? 'error' : 'unknown';
543
+ }
431
544
  }
432
545
 
433
546
  // Capture and log any file upload/download events (for reports)
@@ -460,7 +573,36 @@ export const turnLoop = async (
460
573
  // non-JSON results ignored
461
574
  }
462
575
 
463
- console.log(`[tool ] ← ${fnName} result:`, errorMessage ? '❌ Failed' : '✅ Success');
576
+ let toolStatusLabel = errorMessage ? '❌ Failed' : '✅ Success';
577
+ if (fnName === 'get_mfa_code' && !errorMessage) {
578
+ try {
579
+ const parsed = JSON.parse(result);
580
+ toolStatusLabel = parsed.ok
581
+ ? '✅ Code retrieved'
582
+ : `⚠️ Unavailable${parsed.code ? ` (${parsed.code})` : ''}`;
583
+ } catch {
584
+ toolStatusLabel = '⚠️ Unavailable';
585
+ }
586
+ }
587
+
588
+ console.log(`[tool ] ← ${fnName} result:`, toolStatusLabel);
589
+
590
+ if (isMfaLikeFill(fnName, args)) {
591
+ const fillText = getFillText(args);
592
+ let sourceLine;
593
+ if (agentMemory.lastMfaLookup?.ok && fillText === agentMemory.lastMfaLookup.value) {
594
+ sourceLine = `🔐 MFA fill source: get_mfa_code nickname="${agentMemory.lastMfaLookup.nickname || 'configured MFA'}"`;
595
+ } else if (agentMemory.lastVerificationInput?.value && fillText === agentMemory.lastVerificationInput.value) {
596
+ sourceLine = `🔐 MFA fill source: request_human_input codeType="${agentMemory.lastVerificationInput.codeType}"`;
597
+ } else if (agentMemory.lastMfaLookup && !agentMemory.lastMfaLookup.ok) {
598
+ sourceLine = `⚠️ MFA fill source: not from get_mfa_code. Last MFA lookup failed with ${agentMemory.lastMfaLookup.code || 'unknown'}: ${agentMemory.lastMfaLookup.error || 'unknown error'}`;
599
+ } else {
600
+ sourceLine = '⚠️ MFA fill source: not from a recorded get_mfa_code or request_human_input result.';
601
+ }
602
+ console.log(sourceLine);
603
+ step.events.push(sourceLine);
604
+ }
605
+
464
606
  let resultForLog = result;
465
607
  if (fnName === 'request_human_input') {
466
608
  try {
@@ -474,8 +616,26 @@ export const turnLoop = async (
474
616
  resultForLog = errorMessage || 'Human input received.';
475
617
  }
476
618
  }
619
+ if (fnName === 'get_mfa_code') {
620
+ try {
621
+ const parsed = JSON.parse(result);
622
+ resultForLog = JSON.stringify({
623
+ ...parsed,
624
+ value: maskPreview(parsed.value),
625
+ redactedValue: maskPreview(parsed.value),
626
+ mfaCode: parsed.mfaCode
627
+ ? {
628
+ ...parsed.mfaCode,
629
+ code: maskPreview(parsed.mfaCode.code),
630
+ }
631
+ : parsed.mfaCode,
632
+ });
633
+ } catch {
634
+ resultForLog = errorMessage || 'MFA code lookup completed.';
635
+ }
636
+ }
477
637
  const truncated = resultForLog.length > 1000 ? resultForLog.slice(0, 1000) + '…' : resultForLog;
478
- step.events.push(`[tool ] ← ${fnName} result: ${errorMessage ? '❌ Failed' : '✅ Success'}`);
638
+ step.events.push(`[tool ] ← ${fnName} result: ${toolStatusLabel}`);
479
639
  step.events.push(`[tool ] ← ${truncated}`);
480
640
 
481
641
  // Screenshot detection (for report metadata only)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "testronaut",
3
- "version": "1.3.6",
3
+ "version": "1.4.0",
4
4
  "main": "./runner/testronaut.js",
5
5
  "bin": {
6
6
  "testronaut": "bin/cli.js"
@@ -44,6 +44,7 @@
44
44
  ],
45
45
  "devDependencies": {
46
46
  "@types/node": "^24.9.1",
47
- "vitest": "^4.0.1"
47
+ "periapsis": "^2.0.1",
48
+ "vitest": "^4.1.9"
48
49
  }
49
50
  }
@@ -0,0 +1,36 @@
1
+ [
2
+ {
3
+ "id": "VEX-2026-001",
4
+ "type": "alert",
5
+ "repo": "mission-testronaut/testronaut-cli",
6
+ "alertNumber": 16,
7
+ "appliesToSeverities": [
8
+ "critical"
9
+ ],
10
+ "reason": "Upstream fix not yet available. Reviewing impact and testing patched version. No production exposure — dev dependency only.",
11
+ "acceptedUntil": "2026-06-30T00:00:00.000Z",
12
+ "approvedBy": [
13
+ "Shane Fast"
14
+ ],
15
+ "createdAt": "2026-06-17T17:14:47.945Z",
16
+ "evidenceRef": "https://github.com/mission-testronaut/testronaut-cli/security/dependabot/16"
17
+ },
18
+ {
19
+ "id": "VEX-2026-002",
20
+ "type": "package",
21
+ "package": "vitest",
22
+ "ecosystem": "npm",
23
+ "repo": "mission-testronaut/testronaut-cli",
24
+ "alertNumber": 16,
25
+ "appliesToSeverities": [
26
+ "critical"
27
+ ],
28
+ "reason": "need extra time to address",
29
+ "acceptedUntil": "2026-07-04T00:00:00.000Z",
30
+ "approvedBy": [
31
+ "Shane Fast"
32
+ ],
33
+ "createdAt": "2026-07-03T01:28:55.590Z",
34
+ "evidenceRef": "https://github.com/mission-testronaut/testronaut-cli/security/dependabot/16"
35
+ }
36
+ ]
@@ -0,0 +1 @@
1
+ {}
@@ -0,0 +1,33 @@
1
+ {
2
+ "version": 1,
3
+ "rollout": {
4
+ "enabled": true,
5
+ "gracePeriodUntil": null,
6
+ "mode": "observe"
7
+ },
8
+ "slaDays": {
9
+ "critical": 3,
10
+ "high": 30,
11
+ "medium": 60,
12
+ "low": 100
13
+ },
14
+ "warningThresholdDays": {
15
+ "critical": 1,
16
+ "high": 7,
17
+ "medium": 14,
18
+ "low": 30
19
+ },
20
+ "prCheck": {
21
+ "enabled": true,
22
+ "failOnBreach": true,
23
+ "advisoryOnly": false,
24
+ "minimumSeverity": "high"
25
+ },
26
+ "exceptions": {
27
+ "requireApproval": true,
28
+ "maxDurationDays": 90,
29
+ "allowPackageScope": true,
30
+ "allowRepoWideScope": true,
31
+ "allowOrgWideScope": false
32
+ }
33
+ }
@@ -115,4 +115,59 @@ describe('cli helpers', () => {
115
115
  expect(createVercelBypassHeader('abc')).toEqual({ 'x-vercel-protection-bypass': 'abc' });
116
116
  expect(createVercelBypassHeader('')).toEqual({});
117
117
  });
118
+
119
+ it('parses run options from -o and removes them from args', () => {
120
+ const { parseRunOptionsArgs } = __test__;
121
+ const res = parseRunOptionsArgs(['-o', 'mfa=github-test-mfa', 'login.mission.js']);
122
+
123
+ expect(res.options).toEqual({ mfa: 'github-test-mfa' });
124
+ expect(res.args).toEqual(['login.mission.js']);
125
+ expect(res.invalid).toBe(false);
126
+ });
127
+
128
+ it('parses comma-separated inline run options', () => {
129
+ const { parseRunOptionsArgs } = __test__;
130
+ const res = parseRunOptionsArgs(['--options=mfa=github-test-mfa,foo=bar', 'login']);
131
+
132
+ expect(res.options).toEqual({ mfa: 'github-test-mfa', foo: 'bar' });
133
+ expect(res.args).toEqual(['login']);
134
+ expect(res.invalid).toBe(false);
135
+ });
136
+
137
+ it('flags invalid run options', () => {
138
+ const { parseRunOptionsArgs } = __test__;
139
+ const res = parseRunOptionsArgs(['--options', 'mfa', 'login']);
140
+
141
+ expect(res.options).toEqual({});
142
+ expect(res.args).toEqual(['login']);
143
+ expect(res.invalid).toBe(true);
144
+ });
145
+
146
+ describe('detectCliName', () => {
147
+ const { detectCliName } = __test__;
148
+
149
+ it('returns "npx testronaut" when npm_command is exec (npx invocation)', () => {
150
+ expect(detectCliName('exec', '/some/path/cli.js')).toBe('npx testronaut');
151
+ });
152
+
153
+ it('returns "testronaut" when argv[1] basename is testronaut (global install)', () => {
154
+ expect(detectCliName(undefined, '/usr/local/bin/testronaut')).toBe('testronaut');
155
+ });
156
+
157
+ it('returns "npx testronaut" when argv[1] is cli.js (direct node invocation)', () => {
158
+ expect(detectCliName(undefined, '/home/user/testronaut-cli/bin/cli.js')).toBe('npx testronaut');
159
+ });
160
+
161
+ it('npm_command=exec takes precedence over a global-bin argv[1]', () => {
162
+ expect(detectCliName('exec', '/usr/local/bin/testronaut')).toBe('npx testronaut');
163
+ });
164
+
165
+ it('returns "npx testronaut" when argv[1] is undefined', () => {
166
+ expect(detectCliName(undefined, undefined)).toBe('npx testronaut');
167
+ });
168
+
169
+ it('returns "npx testronaut" when argv[1] is empty string', () => {
170
+ expect(detectCliName(undefined, '')).toBe('npx testronaut');
171
+ });
172
+ });
118
173
  });
@@ -1,10 +1,11 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
2
 
3
+ const { readFile } = vi.hoisted(() => ({ readFile: vi.fn() }));
4
+
3
5
  vi.mock('node:fs/promises', () => ({
4
- readFile: vi.fn(),
6
+ readFile,
5
7
  }));
6
8
 
7
- import { readFile } from 'node:fs/promises';
8
9
  import {
9
10
  loadConfig,
10
11
  getMaxTurns,
@@ -12,6 +12,7 @@ const { shared } = vi.hoisted(() => {
12
12
 
13
13
  // Browser tool spies available to the chromeBrowser mock
14
14
  chromeToolSpies: {
15
+ fill: vi.fn(async () => 'filled'),
15
16
  click_text: vi.fn(async () => JSON.stringify({ ok: true, clicked: 'Hello' })),
16
17
  get_dom: vi.fn(async () => '<html>dom</html>'),
17
18
  screenshot: vi.fn(async () =>
@@ -23,6 +24,15 @@ const { shared } = vi.hoisted(() => {
23
24
  })
24
25
  ),
25
26
  list_local_files: vi.fn(async () => JSON.stringify({ files: ['a.pdf', 'b.pdf'] })),
27
+ get_mfa_code: vi.fn(async () => JSON.stringify({
28
+ ok: false,
29
+ code: 'invalid_response',
30
+ error: 'MFA API response did not include a recognized code field. Response keys: nickname, message.',
31
+ nickname: 'rudy-poo',
32
+ availableNicknames: ['rudy-poo', 'github-prod'],
33
+ mfaListStatus: 'available',
34
+ responseKeys: ['nickname', 'message'],
35
+ })),
26
36
  },
27
37
  },
28
38
  };
@@ -62,9 +72,11 @@ vi.mock('../../tools/tokenControl.js', () => ({
62
72
  vi.mock('../../tools/toolSchema.js', () => ({
63
73
  default: [
64
74
  { type: 'function', function: { name: 'click_text', description: 'click by text', parameters: {} } },
75
+ { type: 'function', function: { name: 'fill', description: 'fill input', parameters: {} } },
65
76
  { type: 'function', function: { name: 'get_dom', description: 'get DOM html', parameters: {} } },
66
77
  { type: 'function', function: { name: 'screenshot', description: 'take a screenshot', parameters: {} } },
67
78
  { type: 'function', function: { name: 'list_local_files', description: 'list files', parameters: {} } },
79
+ { type: 'function', function: { name: 'get_mfa_code', description: 'get mfa', parameters: {} } },
68
80
  ],
69
81
  }));
70
82
 
@@ -115,9 +127,11 @@ describe('turnLoop', () => {
115
127
  beforeEach(() => {
116
128
  browser = {};
117
129
  // reset spies and chat mock
130
+ shared.chromeToolSpies.fill.mockClear();
118
131
  shared.chromeToolSpies.click_text.mockClear();
119
132
  shared.chromeToolSpies.get_dom.mockClear();
120
133
  shared.chromeToolSpies.screenshot.mockClear();
134
+ shared.chromeToolSpies.get_mfa_code.mockClear();
121
135
  if (shared.chatMock) shared.chatMock.mockReset();
122
136
  });
123
137
 
@@ -230,6 +244,106 @@ describe('turnLoop', () => {
230
244
  expect(res.steps[2].result).toMatch(/Success/);
231
245
  });
232
246
 
247
+ it('logs unavailable MFA lookups without marking the tool as a success', async () => {
248
+ shared.chatMock.mockResolvedValueOnce({
249
+ message: {
250
+ role: 'assistant',
251
+ content: '',
252
+ tool_calls: [
253
+ {
254
+ id: 'tool_mfa',
255
+ type: 'function',
256
+ function: { name: 'get_mfa_code', arguments: JSON.stringify({ nickname: 'rudy-poo' }) },
257
+ },
258
+ ],
259
+ },
260
+ usage: { total_tokens: 7 },
261
+ });
262
+
263
+ shared.chatMock.mockResolvedValueOnce({
264
+ message: { role: 'assistant', content: 'FINAL: graceful failure noted' },
265
+ usage: { total_tokens: 5 },
266
+ });
267
+
268
+ const messages = baseMessages();
269
+ const res = await turnLoop(browser, messages, 2, 0, 0, {}, { steps: [], missionName: 'demo' });
270
+
271
+ expect(res.success).toBe(true);
272
+ expect(shared.chromeToolSpies.get_mfa_code).toHaveBeenCalledWith(
273
+ browser,
274
+ { nickname: 'rudy-poo' },
275
+ expect.any(Object)
276
+ );
277
+ expect(res.steps[0].mfa).toMatchObject({
278
+ requested: true,
279
+ nickname: 'rudy-poo',
280
+ status: 'invalid_response',
281
+ availableNicknames: ['rudy-poo', 'github-prod'],
282
+ responseKeys: ['nickname', 'message'],
283
+ listStatus: 'available',
284
+ });
285
+ expect(res.steps[0].events).toContain(
286
+ '[tool ] ← get_mfa_code result: ⚠️ Unavailable (invalid_response)'
287
+ );
288
+ expect(res.steps[0].events.join('\n')).toContain('Response keys: nickname, message');
289
+ expect(res.steps[0].events).toContain('🔐 MFA list endpoint nicknames: rudy-poo, github-prod');
290
+ expect(res.steps[0].events).toContain('🔐 MFA list endpoint status: available');
291
+ expect(res.steps[0].events).toContain('🔐 MFA API response keys: nickname, message');
292
+ expect(res.steps[0].events.join('\n')).not.toContain(
293
+ '[tool ] ← get_mfa_code result: ✅ Success'
294
+ );
295
+ });
296
+
297
+ it('logs when an MFA field is filled with a value not sourced from MFA tools', async () => {
298
+ shared.chatMock.mockResolvedValueOnce({
299
+ message: {
300
+ role: 'assistant',
301
+ content: '',
302
+ tool_calls: [
303
+ {
304
+ id: 'tool_mfa',
305
+ type: 'function',
306
+ function: { name: 'get_mfa_code', arguments: JSON.stringify({ nickname: 'rudy-poo' }) },
307
+ },
308
+ ],
309
+ },
310
+ usage: { total_tokens: 7 },
311
+ });
312
+
313
+ shared.chatMock.mockResolvedValueOnce({
314
+ message: {
315
+ role: 'assistant',
316
+ content: '',
317
+ tool_calls: [
318
+ {
319
+ id: 'tool_fill_mfa',
320
+ type: 'function',
321
+ function: { name: 'fill', arguments: JSON.stringify({ selector: '#mfa-code', text: '123456' }) },
322
+ },
323
+ ],
324
+ },
325
+ usage: { total_tokens: 6 },
326
+ });
327
+
328
+ shared.chatMock.mockResolvedValueOnce({
329
+ message: { role: 'assistant', content: 'FINAL: graceful failure noted' },
330
+ usage: { total_tokens: 5 },
331
+ });
332
+
333
+ const messages = baseMessages();
334
+ const res = await turnLoop(browser, messages, 3, 0, 0, {}, { steps: [], missionName: 'demo' });
335
+
336
+ expect(res.success).toBe(true);
337
+ expect(shared.chromeToolSpies.fill).toHaveBeenCalledWith(
338
+ browser,
339
+ { selector: '#mfa-code', text: '123456' },
340
+ expect.any(Object)
341
+ );
342
+ expect(res.steps[1].events.join('\n')).toContain(
343
+ '⚠️ MFA fill source: not from get_mfa_code. Last MFA lookup failed with invalid_response'
344
+ );
345
+ });
346
+
233
347
  describe('__docProgressInternals helpers', () => {
234
348
  const { parseDocListFromDom, extractDocIdFromUrl, ensureDocProgress } = __docProgressInternals;
235
349