outcome-cli 1.0.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.
Files changed (113) hide show
  1. package/README.md +261 -0
  2. package/package.json +95 -0
  3. package/src/agents/README.md +139 -0
  4. package/src/agents/adapters/anthropic.adapter.ts +166 -0
  5. package/src/agents/adapters/dalle.adapter.ts +145 -0
  6. package/src/agents/adapters/gemini.adapter.ts +134 -0
  7. package/src/agents/adapters/imagen.adapter.ts +106 -0
  8. package/src/agents/adapters/nano-banana.adapter.ts +129 -0
  9. package/src/agents/adapters/openai.adapter.ts +165 -0
  10. package/src/agents/adapters/veo.adapter.ts +130 -0
  11. package/src/agents/agent.schema.property.test.ts +379 -0
  12. package/src/agents/agent.schema.test.ts +148 -0
  13. package/src/agents/agent.schema.ts +263 -0
  14. package/src/agents/index.ts +60 -0
  15. package/src/agents/registered-agent.schema.ts +356 -0
  16. package/src/agents/registry.ts +97 -0
  17. package/src/agents/tournament-configs.property.test.ts +266 -0
  18. package/src/cli/README.md +145 -0
  19. package/src/cli/commands/define.ts +79 -0
  20. package/src/cli/commands/list.ts +46 -0
  21. package/src/cli/commands/logs.ts +83 -0
  22. package/src/cli/commands/run.ts +416 -0
  23. package/src/cli/commands/verify.ts +110 -0
  24. package/src/cli/index.ts +81 -0
  25. package/src/config/README.md +128 -0
  26. package/src/config/env.ts +262 -0
  27. package/src/config/index.ts +19 -0
  28. package/src/eval/README.md +318 -0
  29. package/src/eval/ai-judge.test.ts +435 -0
  30. package/src/eval/ai-judge.ts +368 -0
  31. package/src/eval/code-validators.ts +414 -0
  32. package/src/eval/evaluateOutcome.property.test.ts +1174 -0
  33. package/src/eval/evaluateOutcome.ts +591 -0
  34. package/src/eval/immigration-validators.ts +122 -0
  35. package/src/eval/index.ts +90 -0
  36. package/src/eval/judge-cache.ts +402 -0
  37. package/src/eval/tournament-validators.property.test.ts +439 -0
  38. package/src/eval/validators.property.test.ts +1118 -0
  39. package/src/eval/validators.ts +1199 -0
  40. package/src/eval/weighted-scorer.ts +285 -0
  41. package/src/index.ts +17 -0
  42. package/src/league/README.md +188 -0
  43. package/src/league/health-check.ts +353 -0
  44. package/src/league/index.ts +93 -0
  45. package/src/league/killAgent.ts +151 -0
  46. package/src/league/league.test.ts +1151 -0
  47. package/src/league/runLeague.ts +843 -0
  48. package/src/league/scoreAgent.ts +175 -0
  49. package/src/modules/omnibridge/__tests__/.gitkeep +1 -0
  50. package/src/modules/omnibridge/__tests__/auth-tunnel.property.test.ts +524 -0
  51. package/src/modules/omnibridge/__tests__/deterministic-logger.property.test.ts +965 -0
  52. package/src/modules/omnibridge/__tests__/ghost-api.property.test.ts +461 -0
  53. package/src/modules/omnibridge/__tests__/omnibridge-integration.test.ts +542 -0
  54. package/src/modules/omnibridge/__tests__/parallel-executor.property.test.ts +671 -0
  55. package/src/modules/omnibridge/__tests__/semantic-normalizer.property.test.ts +521 -0
  56. package/src/modules/omnibridge/__tests__/semantic-normalizer.test.ts +254 -0
  57. package/src/modules/omnibridge/__tests__/session-vault.property.test.ts +367 -0
  58. package/src/modules/omnibridge/__tests__/shadow-session.property.test.ts +523 -0
  59. package/src/modules/omnibridge/__tests__/triangulation-engine.property.test.ts +292 -0
  60. package/src/modules/omnibridge/__tests__/verification-engine.property.test.ts +769 -0
  61. package/src/modules/omnibridge/api/.gitkeep +1 -0
  62. package/src/modules/omnibridge/api/ghost-api.ts +1087 -0
  63. package/src/modules/omnibridge/auth/.gitkeep +1 -0
  64. package/src/modules/omnibridge/auth/auth-tunnel.ts +843 -0
  65. package/src/modules/omnibridge/auth/session-vault.ts +577 -0
  66. package/src/modules/omnibridge/core/.gitkeep +1 -0
  67. package/src/modules/omnibridge/core/semantic-normalizer.ts +702 -0
  68. package/src/modules/omnibridge/core/triangulation-engine.ts +530 -0
  69. package/src/modules/omnibridge/core/types.ts +610 -0
  70. package/src/modules/omnibridge/execution/.gitkeep +1 -0
  71. package/src/modules/omnibridge/execution/deterministic-logger.ts +629 -0
  72. package/src/modules/omnibridge/execution/parallel-executor.ts +542 -0
  73. package/src/modules/omnibridge/execution/shadow-session.ts +794 -0
  74. package/src/modules/omnibridge/index.ts +212 -0
  75. package/src/modules/omnibridge/omnibridge.ts +510 -0
  76. package/src/modules/omnibridge/verification/.gitkeep +1 -0
  77. package/src/modules/omnibridge/verification/verification-engine.ts +783 -0
  78. package/src/outcomes/README.md +75 -0
  79. package/src/outcomes/acquire-pilot-customer.ts +297 -0
  80. package/src/outcomes/code-delivery-outcomes.ts +89 -0
  81. package/src/outcomes/code-outcomes.ts +256 -0
  82. package/src/outcomes/code_review_battle.test.ts +135 -0
  83. package/src/outcomes/code_review_battle.ts +135 -0
  84. package/src/outcomes/cold_email_battle.ts +97 -0
  85. package/src/outcomes/content_creation_battle.ts +160 -0
  86. package/src/outcomes/f1_stem_opt_compliance.ts +61 -0
  87. package/src/outcomes/index.ts +107 -0
  88. package/src/outcomes/lead_gen_battle.test.ts +113 -0
  89. package/src/outcomes/lead_gen_battle.ts +99 -0
  90. package/src/outcomes/outcome.schema.property.test.ts +229 -0
  91. package/src/outcomes/outcome.schema.ts +187 -0
  92. package/src/outcomes/qualified_sales_interest.ts +118 -0
  93. package/src/outcomes/swarm_planner.property.test.ts +370 -0
  94. package/src/outcomes/swarm_planner.ts +96 -0
  95. package/src/outcomes/web_extraction.ts +234 -0
  96. package/src/runtime/README.md +220 -0
  97. package/src/runtime/agentRunner.test.ts +341 -0
  98. package/src/runtime/agentRunner.ts +746 -0
  99. package/src/runtime/claudeAdapter.ts +232 -0
  100. package/src/runtime/costTracker.ts +123 -0
  101. package/src/runtime/index.ts +34 -0
  102. package/src/runtime/modelAdapter.property.test.ts +305 -0
  103. package/src/runtime/modelAdapter.ts +144 -0
  104. package/src/runtime/openaiAdapter.ts +235 -0
  105. package/src/utils/README.md +122 -0
  106. package/src/utils/command-runner.ts +134 -0
  107. package/src/utils/cost-guard.ts +379 -0
  108. package/src/utils/errors.test.ts +290 -0
  109. package/src/utils/errors.ts +442 -0
  110. package/src/utils/index.ts +37 -0
  111. package/src/utils/logger.test.ts +361 -0
  112. package/src/utils/logger.ts +419 -0
  113. package/src/utils/output-parsers.ts +216 -0
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Score Agent - Agent scoring and winner determination
3
+ *
4
+ * Determines the winner of a league based on evaluation results and metrics.
5
+ *
6
+ * @module league/scoreAgent
7
+ * @see Requirements 4.5
8
+ */
9
+
10
+ import type { EvaluationResult } from '../eval/evaluateOutcome.js';
11
+
12
+ /**
13
+ * Metrics collected during an agent's run.
14
+ */
15
+ export interface AgentMetrics {
16
+ /** Agent ID */
17
+ agentId: string;
18
+ /** Tokens spent */
19
+ tokensSpent: number;
20
+ /** Number of attempts made */
21
+ attempts: number;
22
+ /** Duration in milliseconds */
23
+ durationMs: number;
24
+ }
25
+
26
+ /**
27
+ * Score for an agent's performance.
28
+ */
29
+ export interface AgentScore {
30
+ /** Agent ID */
31
+ agentId: string;
32
+ /** Whether the agent achieved success */
33
+ success: boolean;
34
+ /** Tokens spent */
35
+ cost: number;
36
+ /** Number of attempts made */
37
+ attempts: number;
38
+ /** Duration in milliseconds */
39
+ duration: number;
40
+ /** Timestamp when success was achieved (if applicable) */
41
+ successTimestamp?: number;
42
+ }
43
+
44
+ /**
45
+ * Scores an agent based on evaluation result and metrics.
46
+ *
47
+ * @param result - Evaluation result from the agent's artifact
48
+ * @param metrics - Metrics collected during the run
49
+ * @returns AgentScore for ranking
50
+ *
51
+ * @example
52
+ * const score = scoreAgent(evalResult, {
53
+ * agentId: 'agent-1',
54
+ * tokensSpent: 500,
55
+ * attempts: 2,
56
+ * durationMs: 5000
57
+ * });
58
+ *
59
+ * @see Requirements 4.5
60
+ */
61
+ export function scoreAgent(
62
+ result: EvaluationResult,
63
+ metrics: AgentMetrics
64
+ ): AgentScore {
65
+ return {
66
+ agentId: metrics.agentId,
67
+ success: result.status === 'SUCCESS',
68
+ cost: metrics.tokensSpent,
69
+ attempts: metrics.attempts,
70
+ duration: metrics.durationMs,
71
+ successTimestamp: result.status === 'SUCCESS' ? Date.now() : undefined,
72
+ };
73
+ }
74
+
75
+ /**
76
+ * Determines the winner from a list of agent scores.
77
+ *
78
+ * Winner criteria (in order):
79
+ * 1. Must have success === true
80
+ * 2. Earliest successTimestamp wins (first to succeed)
81
+ *
82
+ * @param scores - Array of agent scores
83
+ * @returns Winning AgentScore or null if no winner
84
+ *
85
+ * @example
86
+ * const winner = determineWinner(scores);
87
+ * if (winner) {
88
+ * console.log(`Winner: ${winner.agentId}`);
89
+ * }
90
+ *
91
+ * @see Requirements 4.5
92
+ */
93
+ export function determineWinner(scores: AgentScore[]): AgentScore | null {
94
+ // Filter to only successful agents
95
+ const successfulAgents = scores.filter((s) => s.success);
96
+
97
+ if (successfulAgents.length === 0) {
98
+ return null;
99
+ }
100
+
101
+ // Sort by success timestamp (earliest first)
102
+ successfulAgents.sort((a, b) => {
103
+ const timeA = a.successTimestamp ?? Infinity;
104
+ const timeB = b.successTimestamp ?? Infinity;
105
+ return timeA - timeB;
106
+ });
107
+
108
+ return successfulAgents[0];
109
+ }
110
+
111
+ /**
112
+ * Ranks all agents by performance.
113
+ *
114
+ * Ranking criteria:
115
+ * 1. Success status (successful agents rank higher)
116
+ * 2. For successful: earlier success timestamp
117
+ * 3. For failed: fewer attempts, lower cost
118
+ *
119
+ * @param scores - Array of agent scores
120
+ * @returns Sorted array with best performer first
121
+ */
122
+ export function rankAgents(scores: AgentScore[]): AgentScore[] {
123
+ return [...scores].sort((a, b) => {
124
+ // Successful agents rank higher
125
+ if (a.success && !b.success) return -1;
126
+ if (!a.success && b.success) return 1;
127
+
128
+ if (a.success && b.success) {
129
+ // Both successful: earlier timestamp wins
130
+ const timeA = a.successTimestamp ?? Infinity;
131
+ const timeB = b.successTimestamp ?? Infinity;
132
+ return timeA - timeB;
133
+ }
134
+
135
+ // Both failed: fewer attempts is better
136
+ if (a.attempts !== b.attempts) {
137
+ return a.attempts - b.attempts;
138
+ }
139
+
140
+ // Same attempts: lower cost is better
141
+ return a.cost - b.cost;
142
+ });
143
+ }
144
+
145
+ /**
146
+ * Calculates aggregate statistics for a league run.
147
+ *
148
+ * @param scores - Array of agent scores
149
+ * @returns Aggregate statistics
150
+ */
151
+ export function calculateLeagueStats(scores: AgentScore[]): {
152
+ totalAgents: number;
153
+ successfulAgents: number;
154
+ failedAgents: number;
155
+ totalCost: number;
156
+ averageCost: number;
157
+ totalAttempts: number;
158
+ averageAttempts: number;
159
+ } {
160
+ const totalAgents = scores.length;
161
+ const successfulAgents = scores.filter((s) => s.success).length;
162
+ const failedAgents = totalAgents - successfulAgents;
163
+ const totalCost = scores.reduce((sum, s) => sum + s.cost, 0);
164
+ const totalAttempts = scores.reduce((sum, s) => sum + s.attempts, 0);
165
+
166
+ return {
167
+ totalAgents,
168
+ successfulAgents,
169
+ failedAgents,
170
+ totalCost,
171
+ averageCost: totalAgents > 0 ? totalCost / totalAgents : 0,
172
+ totalAttempts,
173
+ averageAttempts: totalAgents > 0 ? totalAttempts / totalAgents : 0,
174
+ };
175
+ }
@@ -0,0 +1 @@
1
+ # Tests - Unit tests and Property-based tests
@@ -0,0 +1,524 @@
1
+ /**
2
+ * Property-Based Tests for Auth Tunnel (MFA Relay)
3
+ *
4
+ * These tests validate the correctness properties defined in the design document.
5
+ * Each property test runs minimum 100 iterations with randomly generated inputs.
6
+ *
7
+ * **Property 11: MFA Timeout**
8
+ * **Validates: Requirements 5.6**
9
+ */
10
+
11
+ import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest';
12
+ import * as fc from 'fast-check';
13
+ import {
14
+ AuthTunnel,
15
+ createAuthTunnel,
16
+ MFA_TIMEOUT_MS,
17
+ } from '../auth/auth-tunnel.js';
18
+ import type { MFAChallenge, ShadowSession } from '../core/types.js';
19
+
20
+ // =============================================================================
21
+ // Test Setup
22
+ // =============================================================================
23
+
24
+ let authTunnel: AuthTunnel;
25
+
26
+ beforeEach(() => {
27
+ vi.useFakeTimers();
28
+ authTunnel = createAuthTunnel({
29
+ mfaTimeoutMs: MFA_TIMEOUT_MS, // 5 minutes = 300000ms
30
+ });
31
+ });
32
+
33
+ afterEach(() => {
34
+ authTunnel.clear();
35
+ vi.useRealTimers();
36
+ });
37
+
38
+ // =============================================================================
39
+ // Arbitraries (Test Data Generators)
40
+ // =============================================================================
41
+
42
+ /**
43
+ * Generate a valid session ID.
44
+ */
45
+ const sessionIdArb = fc.stringMatching(/^shadow_[a-f0-9]{32}$/);
46
+
47
+ /**
48
+ * Generate MFA challenge types.
49
+ */
50
+ const mfaTypeArb: fc.Arbitrary<MFAChallenge['type']> = fc.constantFrom(
51
+ 'sms',
52
+ 'authenticator',
53
+ 'push',
54
+ 'email'
55
+ );
56
+
57
+ /**
58
+ * Generate a mock Shadow Session.
59
+ * Uses fixed timestamps relative to a base time to ensure deterministic test behavior.
60
+ */
61
+ const BASE_TIME = 1700000000000; // Fixed base timestamp for deterministic tests
62
+
63
+ const shadowSessionArb: fc.Arbitrary<ShadowSession> = fc.record({
64
+ id: sessionIdArb,
65
+ domain: fc.stringMatching(/^[a-z][a-z0-9-]{2,20}\.(com|org|net|io)$/),
66
+ status: fc.constant('active' as const),
67
+ createdAt: fc.integer({ min: BASE_TIME - 3600000, max: BASE_TIME }),
68
+ lastHeartbeat: fc.integer({ min: BASE_TIME - 60000, max: BASE_TIME }),
69
+ fingerprint: fc.record({
70
+ resolution: fc.record({
71
+ width: fc.integer({ min: 800, max: 3840 }),
72
+ height: fc.integer({ min: 600, max: 2160 }),
73
+ }),
74
+ userAgent: fc.constant('Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0'),
75
+ fonts: fc.array(fc.string(), { minLength: 1, maxLength: 5 }),
76
+ gpuSignature: fc.string(),
77
+ timezone: fc.constant('America/New_York'),
78
+ language: fc.constant('en-US'),
79
+ }),
80
+ });
81
+
82
+ /**
83
+ * Generate page content with MFA keywords.
84
+ */
85
+ const mfaPageContentArb = (type: MFAChallenge['type']): fc.Arbitrary<string> => {
86
+ const keywordsByType: Record<MFAChallenge['type'], string[]> = {
87
+ sms: ['Enter the verification code sent to your phone', 'SMS code', '6-digit code'],
88
+ authenticator: ['Enter the code from your authenticator app', 'Google Authenticator', 'TOTP'],
89
+ push: ['Approve the login request on your device', 'Push notification sent'],
90
+ email: ['Enter the code sent to your email', 'Email verification'],
91
+ };
92
+
93
+ return fc.constantFrom(...keywordsByType[type]).map((keyword) => {
94
+ return `<html><body><h1>Verification Required</h1><p>${keyword}</p><input type="text" name="code" /></body></html>`;
95
+ });
96
+ };
97
+
98
+ /**
99
+ * Generate valid MFA codes.
100
+ */
101
+ const validMfaCodeArb = fc.stringMatching(/^\d{6}$/);
102
+
103
+ /**
104
+ * Generate time delays in milliseconds.
105
+ */
106
+ const timeDelayArb = fc.integer({ min: 0, max: MFA_TIMEOUT_MS + 60000 });
107
+
108
+ // =============================================================================
109
+ // Property Tests
110
+ // =============================================================================
111
+
112
+ describe('Auth Tunnel Property Tests', () => {
113
+ /**
114
+ * **Feature: omnibridge, Property 11: MFA Timeout**
115
+ *
116
+ * *For any* MFA challenge, if no response is received within 5 minutes (300 seconds),
117
+ * the Auth_Tunnel SHALL return a timeout error.
118
+ *
119
+ * **Validates: Requirements 5.6**
120
+ */
121
+ test('Property 11: MFA Timeout - challenges timeout after 5 minutes', async () => {
122
+ await fc.assert(
123
+ fc.asyncProperty(
124
+ shadowSessionArb,
125
+ mfaTypeArb,
126
+ async (session, mfaType) => {
127
+ // Generate page content for the MFA type
128
+ const pageContent = await fc.sample(mfaPageContentArb(mfaType), 1)[0];
129
+
130
+ // Detect MFA challenge
131
+ const challenge = await authTunnel.detectMFA(session, pageContent);
132
+
133
+ // If no challenge detected, skip this iteration
134
+ if (!challenge) {
135
+ return true;
136
+ }
137
+
138
+ // Request code (starts the timeout timer)
139
+ await authTunnel.requestCode(challenge);
140
+
141
+ // Verify challenge is pending
142
+ expect(authTunnel.getChallenge(challenge.id)).not.toBeNull();
143
+ expect(authTunnel.isTimedOut(challenge.id)).toBe(false);
144
+
145
+ // PROPERTY: Before timeout, challenge should be valid
146
+ const remainingBefore = authTunnel.getRemainingTime(challenge.id);
147
+ expect(remainingBefore).toBeGreaterThan(0);
148
+ expect(remainingBefore).toBeLessThanOrEqual(MFA_TIMEOUT_MS);
149
+
150
+ // Advance time past the timeout (5 minutes + 1 second)
151
+ vi.advanceTimersByTime(MFA_TIMEOUT_MS + 1000);
152
+
153
+ // PROPERTY: After 5 minutes, challenge should be timed out
154
+ expect(authTunnel.isTimedOut(challenge.id)).toBe(true);
155
+ expect(authTunnel.getRemainingTime(challenge.id)).toBe(0);
156
+
157
+ // PROPERTY: Attempting to inject code after timeout should fail
158
+ const result = await authTunnel.injectCode(challenge, '123456');
159
+ expect(result.success).toBe(false);
160
+ expect(result.error).toBeDefined();
161
+ expect(result.error?.code).toBe('timeout');
162
+
163
+ // Clean up for next iteration
164
+ authTunnel.clear();
165
+
166
+ return true;
167
+ }
168
+ ),
169
+ { numRuns: 100 }
170
+ );
171
+ });
172
+
173
+ /**
174
+ * Additional property: Challenges within timeout window should be valid.
175
+ */
176
+ test('Property 11a: Challenges within timeout window remain valid', async () => {
177
+ await fc.assert(
178
+ fc.asyncProperty(
179
+ shadowSessionArb,
180
+ mfaTypeArb,
181
+ fc.integer({ min: 1000, max: MFA_TIMEOUT_MS - 1000 }), // Time within window
182
+ async (session, mfaType, timeWithinWindow) => {
183
+ const pageContent = await fc.sample(mfaPageContentArb(mfaType), 1)[0];
184
+
185
+ const challenge = await authTunnel.detectMFA(session, pageContent);
186
+ if (!challenge) return true;
187
+
188
+ await authTunnel.requestCode(challenge);
189
+
190
+ // Advance time but stay within timeout window
191
+ vi.advanceTimersByTime(timeWithinWindow);
192
+
193
+ // PROPERTY: Challenge should still be valid
194
+ expect(authTunnel.isTimedOut(challenge.id)).toBe(false);
195
+ expect(authTunnel.getRemainingTime(challenge.id)).toBeGreaterThan(0);
196
+
197
+ // Challenge should still be retrievable
198
+ const retrievedChallenge = authTunnel.getChallenge(challenge.id);
199
+ expect(retrievedChallenge).not.toBeNull();
200
+ expect(retrievedChallenge?.id).toBe(challenge.id);
201
+
202
+ authTunnel.clear();
203
+ return true;
204
+ }
205
+ ),
206
+ { numRuns: 100 }
207
+ );
208
+ });
209
+
210
+ /**
211
+ * Additional property: Successful code injection before timeout should work.
212
+ */
213
+ test('Property 11b: Code injection before timeout succeeds', async () => {
214
+ await fc.assert(
215
+ fc.asyncProperty(
216
+ shadowSessionArb,
217
+ mfaTypeArb,
218
+ validMfaCodeArb,
219
+ fc.integer({ min: 0, max: MFA_TIMEOUT_MS - 10000 }), // Time before timeout
220
+ async (session, mfaType, code, timeBeforeTimeout) => {
221
+ const pageContent = await fc.sample(mfaPageContentArb(mfaType), 1)[0];
222
+
223
+ const challenge = await authTunnel.detectMFA(session, pageContent);
224
+ if (!challenge) return true;
225
+
226
+ await authTunnel.requestCode(challenge);
227
+
228
+ // Advance time but stay before timeout
229
+ vi.advanceTimersByTime(timeBeforeTimeout);
230
+
231
+ // PROPERTY: Code injection should succeed before timeout
232
+ const result = await authTunnel.injectCode(challenge, code);
233
+ expect(result.success).toBe(true);
234
+ expect(result.error).toBeUndefined();
235
+
236
+ // Challenge should be removed after successful injection
237
+ expect(authTunnel.getChallenge(challenge.id)).toBeNull();
238
+
239
+ authTunnel.clear();
240
+ return true;
241
+ }
242
+ ),
243
+ { numRuns: 100 }
244
+ );
245
+ });
246
+ });
247
+
248
+ // =============================================================================
249
+ // Unit Tests for MFA Detection
250
+ // =============================================================================
251
+
252
+ describe('Auth Tunnel Unit Tests', () => {
253
+ test('detects SMS MFA challenge', async () => {
254
+ const session: ShadowSession = {
255
+ id: 'shadow_test123',
256
+ domain: 'example.com',
257
+ status: 'active',
258
+ createdAt: Date.now(),
259
+ lastHeartbeat: Date.now(),
260
+ fingerprint: {
261
+ resolution: { width: 1920, height: 1080 },
262
+ userAgent: 'Mozilla/5.0',
263
+ fonts: ['Arial'],
264
+ gpuSignature: 'NVIDIA',
265
+ timezone: 'America/New_York',
266
+ language: 'en-US',
267
+ },
268
+ };
269
+
270
+ const pageContent = `
271
+ <html>
272
+ <body>
273
+ <h1>Two-Factor Authentication</h1>
274
+ <p>We sent a verification code to your phone via SMS.</p>
275
+ <p>Enter the 6-digit code below:</p>
276
+ <input type="text" name="sms_code" placeholder="Enter code" />
277
+ <button type="submit">Verify</button>
278
+ </body>
279
+ </html>
280
+ `;
281
+
282
+ const challenge = await authTunnel.detectMFA(session, pageContent);
283
+
284
+ expect(challenge).not.toBeNull();
285
+ expect(challenge?.type).toBe('sms');
286
+ expect(challenge?.sessionId).toBe(session.id);
287
+ expect(challenge?.expiresAt).toBeGreaterThan(Date.now());
288
+ });
289
+
290
+ test('detects authenticator MFA challenge', async () => {
291
+ const session: ShadowSession = {
292
+ id: 'shadow_test456',
293
+ domain: 'example.com',
294
+ status: 'active',
295
+ createdAt: Date.now(),
296
+ lastHeartbeat: Date.now(),
297
+ fingerprint: {
298
+ resolution: { width: 1920, height: 1080 },
299
+ userAgent: 'Mozilla/5.0',
300
+ fonts: ['Arial'],
301
+ gpuSignature: 'NVIDIA',
302
+ timezone: 'America/New_York',
303
+ language: 'en-US',
304
+ },
305
+ };
306
+
307
+ const pageContent = `
308
+ <html>
309
+ <body>
310
+ <h1>Two-Factor Authentication</h1>
311
+ <p>Open your Google Authenticator app and enter the 6-digit code.</p>
312
+ <input type="text" name="totp_code" />
313
+ <button type="submit">Verify</button>
314
+ </body>
315
+ </html>
316
+ `;
317
+
318
+ const challenge = await authTunnel.detectMFA(session, pageContent);
319
+
320
+ expect(challenge).not.toBeNull();
321
+ expect(challenge?.type).toBe('authenticator');
322
+ });
323
+
324
+ test('detects push notification MFA challenge', async () => {
325
+ const session: ShadowSession = {
326
+ id: 'shadow_test789',
327
+ domain: 'example.com',
328
+ status: 'active',
329
+ createdAt: Date.now(),
330
+ lastHeartbeat: Date.now(),
331
+ fingerprint: {
332
+ resolution: { width: 1920, height: 1080 },
333
+ userAgent: 'Mozilla/5.0',
334
+ fonts: ['Arial'],
335
+ gpuSignature: 'NVIDIA',
336
+ timezone: 'America/New_York',
337
+ language: 'en-US',
338
+ },
339
+ };
340
+
341
+ const pageContent = `
342
+ <html>
343
+ <body>
344
+ <h1>Approve Login</h1>
345
+ <p>We sent a push notification to your device.</p>
346
+ <p>Tap to approve the login request.</p>
347
+ <p>Waiting for approval...</p>
348
+ </body>
349
+ </html>
350
+ `;
351
+
352
+ const challenge = await authTunnel.detectMFA(session, pageContent);
353
+
354
+ expect(challenge).not.toBeNull();
355
+ expect(challenge?.type).toBe('push');
356
+ });
357
+
358
+ test('detects email MFA challenge', async () => {
359
+ const session: ShadowSession = {
360
+ id: 'shadow_testabc',
361
+ domain: 'example.com',
362
+ status: 'active',
363
+ createdAt: Date.now(),
364
+ lastHeartbeat: Date.now(),
365
+ fingerprint: {
366
+ resolution: { width: 1920, height: 1080 },
367
+ userAgent: 'Mozilla/5.0',
368
+ fonts: ['Arial'],
369
+ gpuSignature: 'NVIDIA',
370
+ timezone: 'America/New_York',
371
+ language: 'en-US',
372
+ },
373
+ };
374
+
375
+ const pageContent = `
376
+ <html>
377
+ <body>
378
+ <h1>Email Verification</h1>
379
+ <p>We sent a verification email to your inbox.</p>
380
+ <p>Check your email and enter the email code below:</p>
381
+ <input type="text" name="email_code" />
382
+ <button type="submit">Verify</button>
383
+ </body>
384
+ </html>
385
+ `;
386
+
387
+ const challenge = await authTunnel.detectMFA(session, pageContent);
388
+
389
+ expect(challenge).not.toBeNull();
390
+ expect(challenge?.type).toBe('email');
391
+ });
392
+
393
+ test('returns null for non-MFA pages', async () => {
394
+ const session: ShadowSession = {
395
+ id: 'shadow_testxyz',
396
+ domain: 'example.com',
397
+ status: 'active',
398
+ createdAt: Date.now(),
399
+ lastHeartbeat: Date.now(),
400
+ fingerprint: {
401
+ resolution: { width: 1920, height: 1080 },
402
+ userAgent: 'Mozilla/5.0',
403
+ fonts: ['Arial'],
404
+ gpuSignature: 'NVIDIA',
405
+ timezone: 'America/New_York',
406
+ language: 'en-US',
407
+ },
408
+ };
409
+
410
+ const pageContent = `
411
+ <html>
412
+ <body>
413
+ <h1>Welcome to Our Website</h1>
414
+ <p>Browse our products and services.</p>
415
+ <a href="/products">View Products</a>
416
+ </body>
417
+ </html>
418
+ `;
419
+
420
+ const challenge = await authTunnel.detectMFA(session, pageContent);
421
+
422
+ expect(challenge).toBeNull();
423
+ });
424
+
425
+ test('MFA timeout constant is 5 minutes', () => {
426
+ expect(MFA_TIMEOUT_MS).toBe(300000); // 5 minutes in milliseconds
427
+ });
428
+
429
+ test('challenge expiration is set correctly', async () => {
430
+ const session: ShadowSession = {
431
+ id: 'shadow_exptest',
432
+ domain: 'example.com',
433
+ status: 'active',
434
+ createdAt: Date.now(),
435
+ lastHeartbeat: Date.now(),
436
+ fingerprint: {
437
+ resolution: { width: 1920, height: 1080 },
438
+ userAgent: 'Mozilla/5.0',
439
+ fonts: ['Arial'],
440
+ gpuSignature: 'NVIDIA',
441
+ timezone: 'America/New_York',
442
+ language: 'en-US',
443
+ },
444
+ };
445
+
446
+ const pageContent = 'Enter the verification code sent via SMS';
447
+ const beforeDetect = Date.now();
448
+
449
+ const challenge = await authTunnel.detectMFA(session, pageContent);
450
+
451
+ expect(challenge).not.toBeNull();
452
+ // Expiration should be approximately 5 minutes from now
453
+ expect(challenge!.expiresAt).toBeGreaterThanOrEqual(beforeDetect + MFA_TIMEOUT_MS);
454
+ expect(challenge!.expiresAt).toBeLessThanOrEqual(Date.now() + MFA_TIMEOUT_MS + 1000);
455
+ });
456
+
457
+ test('invalid code format is rejected', async () => {
458
+ const session: ShadowSession = {
459
+ id: 'shadow_codetest',
460
+ domain: 'example.com',
461
+ status: 'active',
462
+ createdAt: Date.now(),
463
+ lastHeartbeat: Date.now(),
464
+ fingerprint: {
465
+ resolution: { width: 1920, height: 1080 },
466
+ userAgent: 'Mozilla/5.0',
467
+ fonts: ['Arial'],
468
+ gpuSignature: 'NVIDIA',
469
+ timezone: 'America/New_York',
470
+ language: 'en-US',
471
+ },
472
+ };
473
+
474
+ const pageContent = 'Enter the verification code sent via SMS';
475
+ const challenge = await authTunnel.detectMFA(session, pageContent);
476
+
477
+ expect(challenge).not.toBeNull();
478
+ await authTunnel.requestCode(challenge!);
479
+
480
+ // Try invalid code formats
481
+ const invalidCodes = ['', ' ', 'abc', '12', '12345678901'];
482
+
483
+ for (const invalidCode of invalidCodes) {
484
+ const result = await authTunnel.injectCode(challenge!, invalidCode);
485
+ expect(result.success).toBe(false);
486
+ expect(result.error?.code).toBe('mfa_failed');
487
+ }
488
+ });
489
+
490
+ test('challenge can be cancelled', async () => {
491
+ const session: ShadowSession = {
492
+ id: 'shadow_canceltest',
493
+ domain: 'example.com',
494
+ status: 'active',
495
+ createdAt: Date.now(),
496
+ lastHeartbeat: Date.now(),
497
+ fingerprint: {
498
+ resolution: { width: 1920, height: 1080 },
499
+ userAgent: 'Mozilla/5.0',
500
+ fonts: ['Arial'],
501
+ gpuSignature: 'NVIDIA',
502
+ timezone: 'America/New_York',
503
+ language: 'en-US',
504
+ },
505
+ };
506
+
507
+ const pageContent = 'Enter the verification code sent via SMS';
508
+ const challenge = await authTunnel.detectMFA(session, pageContent);
509
+
510
+ expect(challenge).not.toBeNull();
511
+ await authTunnel.requestCode(challenge!);
512
+
513
+ // Challenge should be pending
514
+ expect(authTunnel.getPendingChallengeCount()).toBe(1);
515
+
516
+ // Cancel the challenge
517
+ const cancelled = authTunnel.cancelChallenge(challenge!.id);
518
+ expect(cancelled).toBe(true);
519
+
520
+ // Challenge should be removed
521
+ expect(authTunnel.getPendingChallengeCount()).toBe(0);
522
+ expect(authTunnel.getChallenge(challenge!.id)).toBeNull();
523
+ });
524
+ });