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/.github/dependabot.yml +11 -0
- package/.github/workflows/periapsis-vulnerability-daily.yml +58 -0
- package/.github/workflows/periapsis-vulnerability-exception-request.yml +86 -0
- package/.github/workflows/periapsis-vulnerability-pr-check.yml +23 -0
- package/README.md +71 -7
- package/TODO.md +15 -0
- package/bin/cli.js +117 -21
- package/core/agent.js +18 -5
- package/core/turnLoop.js +162 -2
- package/package.json +3 -2
- package/policy/vulnerability-exceptions.json +36 -0
- package/policy/vulnerability-notifications.json +1 -0
- package/policy/vulnerability-owners.json +1 -0
- package/policy/vulnerability-policy.json +33 -0
- package/tests/binTests/cli.helpers.test.js +55 -0
- package/tests/coreTests/config.test.js +3 -2
- package/tests/coreTests/turnLoop.test.js +114 -0
- package/tests/toolsTests/mfaCode.test.js +445 -0
- package/tests/toolsTests/toolSchema.test.js +8 -0
- package/tools/chromeBrowser.js +2 -0
- package/tools/mfaCode.js +637 -0
- package/tools/toolSchema.js +28 -0
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
|
-
|
|
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: ${
|
|
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
|
+
"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
|
-
"
|
|
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 @@
|
|
|
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
|
|
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
|
|