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,843 @@
1
+ /**
2
+ * Auth Tunnel - MFA Relay for OmniBridge
3
+ *
4
+ * Secure relay for human-in-the-loop authentication.
5
+ * Handles MFA challenge detection, user notification, and code injection.
6
+ *
7
+ * Requirements: 5.1, 5.2, 5.3, 5.4, 5.6
8
+ */
9
+
10
+ import { randomBytes } from 'crypto';
11
+ import type {
12
+ MFAChallenge,
13
+ AuthResult,
14
+ AuthError,
15
+ ShadowSession,
16
+ IntentDocument,
17
+ IntentElement,
18
+ } from '../core/types.js';
19
+ import type { SessionVault, RawSessionData } from './session-vault.js';
20
+ import type { ShadowSessionOrchestrator } from '../execution/shadow-session.js';
21
+
22
+ // =============================================================================
23
+ // Constants
24
+ // =============================================================================
25
+
26
+ /** MFA timeout in milliseconds (5 minutes as per Requirements 5.6) */
27
+ export const MFA_TIMEOUT_MS = 300000; // 5 minutes = 300 seconds
28
+
29
+ /** MFA challenge polling interval */
30
+ export const MFA_POLL_INTERVAL_MS = 1000;
31
+
32
+ // =============================================================================
33
+ // Types
34
+ // =============================================================================
35
+
36
+ /**
37
+ * MFA detection patterns for different challenge types.
38
+ */
39
+ export interface MFADetectionPattern {
40
+ /** Type of MFA challenge */
41
+ type: MFAChallenge['type'];
42
+ /** Keywords to look for in page content */
43
+ keywords: string[];
44
+ /** Input field patterns (type, name, placeholder) */
45
+ inputPatterns: RegExp[];
46
+ /** ARIA roles that indicate MFA */
47
+ ariaRoles: string[];
48
+ }
49
+
50
+ /**
51
+ * User notification configuration.
52
+ */
53
+ export interface NotificationConfig {
54
+ /** Webhook URL for notifications */
55
+ webhookUrl?: string;
56
+ /** Dashboard notification callback */
57
+ dashboardCallback?: (challenge: MFAChallenge) => Promise<void>;
58
+ /** Email notification address */
59
+ notificationEmail?: string;
60
+ }
61
+
62
+ /**
63
+ * MFA code submission result.
64
+ */
65
+ export interface MFASubmissionResult {
66
+ success: boolean;
67
+ sessionState?: RawSessionData;
68
+ error?: AuthError;
69
+ }
70
+
71
+ /**
72
+ * Pending MFA challenge with additional metadata.
73
+ */
74
+ export interface PendingMFAChallenge extends MFAChallenge {
75
+ /** Callback to resolve the challenge */
76
+ resolve?: (code: string) => void;
77
+ /** Callback to reject the challenge */
78
+ reject?: (error: AuthError) => void;
79
+ /** Whether the challenge has been notified to user */
80
+ notified: boolean;
81
+ /** Number of code submission attempts */
82
+ attempts: number;
83
+ /** Maximum allowed attempts */
84
+ maxAttempts: number;
85
+ }
86
+
87
+ /**
88
+ * Auth Tunnel configuration.
89
+ */
90
+ export interface AuthTunnelConfig {
91
+ /** Session Vault for storing authenticated states */
92
+ vault?: SessionVault;
93
+ /** Shadow Session Orchestrator for browser sessions */
94
+ orchestrator?: ShadowSessionOrchestrator;
95
+ /** Notification configuration */
96
+ notificationConfig?: NotificationConfig;
97
+ /** MFA timeout in milliseconds (default: 300000 = 5 minutes) */
98
+ mfaTimeoutMs?: number;
99
+ /** Maximum MFA code submission attempts (default: 3) */
100
+ maxMfaAttempts?: number;
101
+ }
102
+
103
+ // =============================================================================
104
+ // MFA Detection Patterns
105
+ // =============================================================================
106
+
107
+ /**
108
+ * Detection patterns for different MFA challenge types.
109
+ * Used by the Semantic Engine to identify MFA input fields.
110
+ *
111
+ * Requirements: 5.1, 5.3
112
+ */
113
+ export const MFA_DETECTION_PATTERNS: MFADetectionPattern[] = [
114
+ {
115
+ type: 'sms',
116
+ keywords: [
117
+ 'sms', 'text message', 'verification code', 'security code',
118
+ 'phone code', 'mobile code', 'sent to your phone', 'sent a code',
119
+ '6-digit', '6 digit', 'enter code', 'enter the code',
120
+ ],
121
+ inputPatterns: [
122
+ /^(sms|phone|mobile|verification|security|otp|code|token)[-_]?(code|input|field)?$/i,
123
+ /^(one[-_]?time[-_]?password|otp)$/i,
124
+ /^mfa[-_]?code$/i,
125
+ ],
126
+ ariaRoles: ['textbox'],
127
+ },
128
+ {
129
+ type: 'authenticator',
130
+ keywords: [
131
+ 'authenticator', 'google authenticator', 'microsoft authenticator',
132
+ 'authy', 'totp', 'time-based', '2fa', 'two-factor',
133
+ 'authentication app', 'authenticator app', '6-digit code',
134
+ ],
135
+ inputPatterns: [
136
+ /^(totp|authenticator|2fa|two[-_]?factor)[-_]?(code|input|field)?$/i,
137
+ /^(auth[-_]?code|mfa[-_]?token)$/i,
138
+ ],
139
+ ariaRoles: ['textbox'],
140
+ },
141
+ {
142
+ type: 'push',
143
+ keywords: [
144
+ 'push notification', 'approve', 'confirm on your device',
145
+ 'check your phone', 'tap to approve', 'waiting for approval',
146
+ 'sent a notification', 'open your app',
147
+ ],
148
+ inputPatterns: [], // Push doesn't typically have input fields
149
+ ariaRoles: ['alert', 'status'],
150
+ },
151
+ {
152
+ type: 'email',
153
+ keywords: [
154
+ 'email code', 'sent to your email', 'check your email',
155
+ 'email verification', 'verification email', 'magic link',
156
+ 'email link', 'sent a link', 'emailed you',
157
+ ],
158
+ inputPatterns: [
159
+ /^(email[-_]?code|email[-_]?verification|email[-_]?otp)$/i,
160
+ ],
161
+ ariaRoles: ['textbox'],
162
+ },
163
+ ];
164
+
165
+ // =============================================================================
166
+ // Auth Tunnel Implementation
167
+ // =============================================================================
168
+
169
+ /**
170
+ * Auth Tunnel - MFA Relay
171
+ *
172
+ * Handles the human-in-the-loop authentication flow:
173
+ * 1. Detects MFA challenges in Shadow Sessions
174
+ * 2. Notifies users via dashboard/webhook
175
+ * 3. Injects MFA codes into sessions
176
+ * 4. Serializes successful auth state to vault
177
+ *
178
+ * Requirements: 5.1, 5.2, 5.3, 5.4, 5.6
179
+ */
180
+ export class AuthTunnel {
181
+ private readonly vault?: SessionVault;
182
+ private readonly orchestrator?: ShadowSessionOrchestrator;
183
+ private readonly notificationConfig: NotificationConfig;
184
+ private readonly mfaTimeoutMs: number;
185
+ private readonly maxMfaAttempts: number;
186
+
187
+ /** Pending MFA challenges by ID */
188
+ private pendingChallenges: Map<string, PendingMFAChallenge> = new Map();
189
+
190
+ /** Timeout timers for challenges */
191
+ private timeoutTimers: Map<string, NodeJS.Timeout> = new Map();
192
+
193
+ constructor(config: AuthTunnelConfig = {}) {
194
+ this.vault = config.vault;
195
+ this.orchestrator = config.orchestrator;
196
+ this.notificationConfig = config.notificationConfig ?? {};
197
+ this.mfaTimeoutMs = config.mfaTimeoutMs ?? MFA_TIMEOUT_MS;
198
+ this.maxMfaAttempts = config.maxMfaAttempts ?? 3;
199
+ }
200
+
201
+ /**
202
+ * Get the orchestrator instance (for future browser automation).
203
+ */
204
+ getOrchestrator(): ShadowSessionOrchestrator | undefined {
205
+ return this.orchestrator;
206
+ }
207
+
208
+ // ===========================================================================
209
+ // MFA Challenge Detection
210
+ // ===========================================================================
211
+
212
+ /**
213
+ * Generate a unique challenge ID.
214
+ */
215
+ private generateChallengeId(): string {
216
+ return `mfa_${randomBytes(16).toString('hex')}`;
217
+ }
218
+
219
+ /**
220
+ * Detect MFA challenge type from page content.
221
+ * Uses keyword matching and input field analysis.
222
+ *
223
+ * Requirements: 5.1, 5.3
224
+ */
225
+ detectMFAType(
226
+ pageContent: string,
227
+ intentDocument?: IntentDocument
228
+ ): MFAChallenge['type'] | null {
229
+ const contentLower = pageContent.toLowerCase();
230
+
231
+ // Score each MFA type based on keyword matches
232
+ const scores: Record<MFAChallenge['type'], number> = {
233
+ sms: 0,
234
+ authenticator: 0,
235
+ push: 0,
236
+ email: 0,
237
+ };
238
+
239
+ for (const pattern of MFA_DETECTION_PATTERNS) {
240
+ // Check keywords
241
+ for (const keyword of pattern.keywords) {
242
+ if (contentLower.includes(keyword.toLowerCase())) {
243
+ scores[pattern.type] += 1;
244
+ }
245
+ }
246
+
247
+ // Check input fields in intent document
248
+ if (intentDocument) {
249
+ for (const form of intentDocument.forms) {
250
+ for (const field of form.fields) {
251
+ for (const inputPattern of pattern.inputPatterns) {
252
+ if (
253
+ inputPattern.test(field.name) ||
254
+ inputPattern.test(field.intentId) ||
255
+ (field.placeholder && inputPattern.test(field.placeholder))
256
+ ) {
257
+ scores[pattern.type] += 2; // Input field match is stronger signal
258
+ }
259
+ }
260
+ }
261
+ }
262
+
263
+ // Check elements for ARIA roles
264
+ for (const element of intentDocument.elements) {
265
+ if (element.ariaRole && pattern.ariaRoles.includes(element.ariaRole)) {
266
+ // Check if element label contains MFA keywords
267
+ const labelLower = element.label.toLowerCase();
268
+ for (const keyword of pattern.keywords) {
269
+ if (labelLower.includes(keyword.toLowerCase())) {
270
+ scores[pattern.type] += 1;
271
+ }
272
+ }
273
+ }
274
+ }
275
+ }
276
+ }
277
+
278
+ // Find the type with highest score
279
+ let maxScore = 0;
280
+ let detectedType: MFAChallenge['type'] | null = null;
281
+
282
+ for (const [type, score] of Object.entries(scores)) {
283
+ if (score > maxScore) {
284
+ maxScore = score;
285
+ detectedType = type as MFAChallenge['type'];
286
+ }
287
+ }
288
+
289
+ // Require minimum score to confirm detection
290
+ return maxScore >= 2 ? detectedType : null;
291
+ }
292
+
293
+ /**
294
+ * Find MFA input field in the intent document.
295
+ * Uses Semantic Engine to identify the correct input field.
296
+ *
297
+ * Requirements: 5.1, 5.3
298
+ */
299
+ findMFAInputField(
300
+ intentDocument: IntentDocument,
301
+ mfaType: MFAChallenge['type']
302
+ ): IntentElement | null {
303
+ const pattern = MFA_DETECTION_PATTERNS.find((p) => p.type === mfaType);
304
+ if (!pattern) return null;
305
+
306
+ // First, check form fields
307
+ for (const form of intentDocument.forms) {
308
+ for (const field of form.fields) {
309
+ // Check input patterns
310
+ for (const inputPattern of pattern.inputPatterns) {
311
+ if (
312
+ inputPattern.test(field.name) ||
313
+ inputPattern.test(field.intentId) ||
314
+ (field.placeholder && inputPattern.test(field.placeholder))
315
+ ) {
316
+ // Find corresponding element
317
+ const element = intentDocument.elements.find(
318
+ (el) => el.intentId === field.intentId
319
+ );
320
+ if (element) return element;
321
+ }
322
+ }
323
+
324
+ // Check for generic code input fields
325
+ if (
326
+ field.type === 'text' ||
327
+ field.type === 'number' ||
328
+ field.type === 'tel'
329
+ ) {
330
+ const fieldLower = (field.label + field.name + (field.placeholder || '')).toLowerCase();
331
+ for (const keyword of pattern.keywords) {
332
+ if (fieldLower.includes(keyword.toLowerCase())) {
333
+ const element = intentDocument.elements.find(
334
+ (el) => el.intentId === field.intentId
335
+ );
336
+ if (element) return element;
337
+ }
338
+ }
339
+ }
340
+ }
341
+ }
342
+
343
+ // Fallback: check elements directly
344
+ for (const element of intentDocument.elements) {
345
+ if (element.role === 'input') {
346
+ const labelLower = element.label.toLowerCase();
347
+ for (const keyword of pattern.keywords) {
348
+ if (labelLower.includes(keyword.toLowerCase())) {
349
+ return element;
350
+ }
351
+ }
352
+ }
353
+ }
354
+
355
+ return null;
356
+ }
357
+
358
+ /**
359
+ * Generate user-friendly prompt for MFA challenge.
360
+ */
361
+ private generatePrompt(mfaType: MFAChallenge['type']): string {
362
+ switch (mfaType) {
363
+ case 'sms':
364
+ return 'Enter the verification code sent to your phone via SMS';
365
+ case 'authenticator':
366
+ return 'Enter the 6-digit code from your authenticator app';
367
+ case 'push':
368
+ return 'Approve the login request on your mobile device';
369
+ case 'email':
370
+ return 'Enter the verification code sent to your email';
371
+ default:
372
+ return 'Complete the verification to continue';
373
+ }
374
+ }
375
+
376
+ /**
377
+ * Detect MFA challenge in a Shadow Session.
378
+ * Returns the challenge if detected, null otherwise.
379
+ *
380
+ * Requirements: 5.1, 5.3
381
+ */
382
+ async detectMFA(
383
+ session: ShadowSession,
384
+ pageContent: string,
385
+ intentDocument?: IntentDocument
386
+ ): Promise<MFAChallenge | null> {
387
+ // Detect MFA type
388
+ const mfaType = this.detectMFAType(pageContent, intentDocument);
389
+ if (!mfaType) {
390
+ return null;
391
+ }
392
+
393
+ // Create MFA challenge
394
+ const challenge: MFAChallenge = {
395
+ id: this.generateChallengeId(),
396
+ sessionId: session.id,
397
+ type: mfaType,
398
+ prompt: this.generatePrompt(mfaType),
399
+ expiresAt: Date.now() + this.mfaTimeoutMs,
400
+ };
401
+
402
+ return challenge;
403
+ }
404
+
405
+ // ===========================================================================
406
+ // User Notification
407
+ // ===========================================================================
408
+
409
+ /**
410
+ * Send notification to user about MFA challenge.
411
+ * Supports dashboard callback and webhook notifications.
412
+ *
413
+ * Requirements: 5.1
414
+ */
415
+ async requestCode(challenge: MFAChallenge): Promise<void> {
416
+ // Store as pending challenge
417
+ const pendingChallenge: PendingMFAChallenge = {
418
+ ...challenge,
419
+ notified: false,
420
+ attempts: 0,
421
+ maxAttempts: this.maxMfaAttempts,
422
+ };
423
+ this.pendingChallenges.set(challenge.id, pendingChallenge);
424
+
425
+ // Set up timeout timer
426
+ this.setupTimeoutTimer(challenge.id);
427
+
428
+ // Send dashboard notification
429
+ if (this.notificationConfig.dashboardCallback) {
430
+ try {
431
+ await this.notificationConfig.dashboardCallback(challenge);
432
+ pendingChallenge.notified = true;
433
+ } catch (error) {
434
+ console.error('Failed to send dashboard notification:', error);
435
+ }
436
+ }
437
+
438
+ // Send webhook notification
439
+ if (this.notificationConfig.webhookUrl) {
440
+ try {
441
+ await this.sendWebhookNotification(challenge);
442
+ pendingChallenge.notified = true;
443
+ } catch (error) {
444
+ console.error('Failed to send webhook notification:', error);
445
+ }
446
+ }
447
+
448
+ // If no notification method configured, mark as notified anyway
449
+ // (for testing or manual code entry scenarios)
450
+ if (!this.notificationConfig.dashboardCallback && !this.notificationConfig.webhookUrl) {
451
+ pendingChallenge.notified = true;
452
+ }
453
+ }
454
+
455
+ /**
456
+ * Send webhook notification for MFA challenge.
457
+ */
458
+ private async sendWebhookNotification(challenge: MFAChallenge): Promise<void> {
459
+ if (!this.notificationConfig.webhookUrl) return;
460
+
461
+ const payload = {
462
+ type: 'mfa_challenge',
463
+ challenge: {
464
+ id: challenge.id,
465
+ sessionId: challenge.sessionId,
466
+ type: challenge.type,
467
+ prompt: challenge.prompt,
468
+ expiresAt: challenge.expiresAt,
469
+ },
470
+ timestamp: Date.now(),
471
+ };
472
+
473
+ // In a real implementation, this would use fetch
474
+ // For now, we'll simulate the webhook call
475
+ console.log('Webhook notification:', this.notificationConfig.webhookUrl, payload);
476
+ }
477
+
478
+ /**
479
+ * Set up timeout timer for MFA challenge.
480
+ *
481
+ * Requirements: 5.6
482
+ */
483
+ private setupTimeoutTimer(challengeId: string): void {
484
+ // Clear existing timer if any
485
+ this.clearTimeoutTimer(challengeId);
486
+
487
+ const challenge = this.pendingChallenges.get(challengeId);
488
+ if (!challenge) return;
489
+
490
+ const timeoutMs = challenge.expiresAt - Date.now();
491
+ if (timeoutMs <= 0) {
492
+ this.handleTimeout(challengeId);
493
+ return;
494
+ }
495
+
496
+ const timer = setTimeout(() => {
497
+ this.handleTimeout(challengeId);
498
+ }, timeoutMs);
499
+
500
+ this.timeoutTimers.set(challengeId, timer);
501
+ }
502
+
503
+ /**
504
+ * Clear timeout timer for a challenge.
505
+ */
506
+ private clearTimeoutTimer(challengeId: string): void {
507
+ const timer = this.timeoutTimers.get(challengeId);
508
+ if (timer) {
509
+ clearTimeout(timer);
510
+ this.timeoutTimers.delete(challengeId);
511
+ }
512
+ }
513
+
514
+ /**
515
+ * Handle MFA timeout.
516
+ *
517
+ * Requirements: 5.6
518
+ */
519
+ private handleTimeout(challengeId: string): void {
520
+ const challenge = this.pendingChallenges.get(challengeId);
521
+ if (!challenge) return;
522
+
523
+ // Reject the challenge with timeout error
524
+ if (challenge.reject) {
525
+ challenge.reject({
526
+ code: 'timeout',
527
+ message: 'MFA challenge timed out after 5 minutes',
528
+ });
529
+ }
530
+
531
+ // Clean up
532
+ this.pendingChallenges.delete(challengeId);
533
+ this.clearTimeoutTimer(challengeId);
534
+ }
535
+
536
+ // ===========================================================================
537
+ // Code Injection
538
+ // ===========================================================================
539
+
540
+ /**
541
+ * Inject MFA code into Shadow Session.
542
+ * Serializes successful auth state to vault.
543
+ *
544
+ * Requirements: 5.2, 5.4
545
+ */
546
+ async injectCode(
547
+ challenge: MFAChallenge,
548
+ code: string
549
+ ): Promise<AuthResult> {
550
+ const pendingChallenge = this.pendingChallenges.get(challenge.id);
551
+
552
+ // Check if challenge exists - if not, check if it was timed out
553
+ if (!pendingChallenge) {
554
+ // Check if the original challenge has expired
555
+ if (Date.now() > challenge.expiresAt) {
556
+ return {
557
+ success: false,
558
+ error: {
559
+ code: 'timeout',
560
+ message: 'MFA challenge has expired',
561
+ },
562
+ };
563
+ }
564
+ return {
565
+ success: false,
566
+ error: {
567
+ code: 'mfa_failed',
568
+ message: 'MFA challenge not found or already completed',
569
+ },
570
+ };
571
+ }
572
+
573
+ // Check if challenge has expired
574
+ if (Date.now() > pendingChallenge.expiresAt) {
575
+ this.pendingChallenges.delete(challenge.id);
576
+ this.clearTimeoutTimer(challenge.id);
577
+ return {
578
+ success: false,
579
+ error: {
580
+ code: 'timeout',
581
+ message: 'MFA challenge has expired',
582
+ },
583
+ };
584
+ }
585
+
586
+ // Check attempt count
587
+ pendingChallenge.attempts++;
588
+ if (pendingChallenge.attempts > pendingChallenge.maxAttempts) {
589
+ this.pendingChallenges.delete(challenge.id);
590
+ this.clearTimeoutTimer(challenge.id);
591
+ return {
592
+ success: false,
593
+ error: {
594
+ code: 'mfa_failed',
595
+ message: 'Maximum MFA attempts exceeded',
596
+ },
597
+ };
598
+ }
599
+
600
+ // Validate code format
601
+ if (!this.validateCodeFormat(code, challenge.type)) {
602
+ return {
603
+ success: false,
604
+ error: {
605
+ code: 'mfa_failed',
606
+ message: 'Invalid code format',
607
+ },
608
+ };
609
+ }
610
+
611
+ // In a real implementation, this would:
612
+ // 1. Get the Shadow Session from orchestrator
613
+ // 2. Find the MFA input field using Semantic Engine
614
+ // 3. Type the code into the field
615
+ // 4. Submit the form
616
+ // 5. Check if authentication succeeded
617
+ // 6. Serialize session state to vault
618
+
619
+ // For now, we simulate successful code injection
620
+ const sessionState: RawSessionData = {
621
+ cookies: JSON.stringify([
622
+ { name: 'auth_token', value: `authenticated_${challenge.sessionId}`, domain: 'example.com' },
623
+ ]),
624
+ localStorage: JSON.stringify({ authenticated: true }),
625
+ sessionStorage: JSON.stringify({ mfa_completed: true }),
626
+ };
627
+
628
+ // Store in vault if available
629
+ if (this.vault) {
630
+ // Extract domain from session (would come from orchestrator in real impl)
631
+ const domain = 'example.com'; // Placeholder
632
+ await this.vault.store(domain, sessionState);
633
+ }
634
+
635
+ // Clean up challenge
636
+ this.pendingChallenges.delete(challenge.id);
637
+ this.clearTimeoutTimer(challenge.id);
638
+
639
+ // Resolve the challenge if callback exists
640
+ if (pendingChallenge.resolve) {
641
+ pendingChallenge.resolve(code);
642
+ }
643
+
644
+ return {
645
+ success: true,
646
+ sessionState: {
647
+ cookies: this.vault?.encrypt(sessionState.cookies) ?? { data: '', iv: '', algorithm: 'AES-256-GCM' },
648
+ localStorage: this.vault?.encrypt(sessionState.localStorage) ?? { data: '', iv: '', algorithm: 'AES-256-GCM' },
649
+ sessionStorage: this.vault?.encrypt(sessionState.sessionStorage) ?? { data: '', iv: '', algorithm: 'AES-256-GCM' },
650
+ expiresAt: Date.now() + 86400000, // 24 hours
651
+ },
652
+ };
653
+ }
654
+
655
+ /**
656
+ * Validate MFA code format based on challenge type.
657
+ */
658
+ private validateCodeFormat(code: string, type: MFAChallenge['type']): boolean {
659
+ if (!code || code.trim().length === 0) {
660
+ return false;
661
+ }
662
+
663
+ switch (type) {
664
+ case 'sms':
665
+ case 'authenticator':
666
+ // Typically 6-digit codes
667
+ return /^\d{4,8}$/.test(code.trim());
668
+ case 'email':
669
+ // Could be numeric code or alphanumeric
670
+ return /^[A-Za-z0-9]{4,10}$/.test(code.trim());
671
+ case 'push':
672
+ // Push doesn't require code input
673
+ return true;
674
+ default:
675
+ return code.trim().length > 0;
676
+ }
677
+ }
678
+
679
+ // ===========================================================================
680
+ // Timeout Handling
681
+ // ===========================================================================
682
+
683
+ /**
684
+ * Wait for MFA code with timeout.
685
+ * Returns the auth result or timeout error.
686
+ *
687
+ * Requirements: 5.6
688
+ */
689
+ async waitForCode(challenge: MFAChallenge): Promise<AuthResult> {
690
+ return new Promise((resolve) => {
691
+ const pendingChallenge = this.pendingChallenges.get(challenge.id);
692
+ if (!pendingChallenge) {
693
+ resolve({
694
+ success: false,
695
+ error: {
696
+ code: 'mfa_failed',
697
+ message: 'MFA challenge not found',
698
+ },
699
+ });
700
+ return;
701
+ }
702
+
703
+ // Set up resolve/reject callbacks
704
+ pendingChallenge.resolve = () => {
705
+ resolve({
706
+ success: true,
707
+ });
708
+ };
709
+
710
+ pendingChallenge.reject = (error: AuthError) => {
711
+ resolve({
712
+ success: false,
713
+ error,
714
+ });
715
+ };
716
+
717
+ // Check if already expired
718
+ if (Date.now() > pendingChallenge.expiresAt) {
719
+ this.handleTimeout(challenge.id);
720
+ }
721
+ });
722
+ }
723
+
724
+ /**
725
+ * Check if a challenge has timed out.
726
+ *
727
+ * Requirements: 5.6
728
+ */
729
+ isTimedOut(challengeId: string): boolean {
730
+ const challenge = this.pendingChallenges.get(challengeId);
731
+ if (!challenge) return true;
732
+ return Date.now() > challenge.expiresAt;
733
+ }
734
+
735
+ /**
736
+ * Get remaining time for a challenge in milliseconds.
737
+ */
738
+ getRemainingTime(challengeId: string): number {
739
+ const challenge = this.pendingChallenges.get(challengeId);
740
+ if (!challenge) return 0;
741
+ return Math.max(0, challenge.expiresAt - Date.now());
742
+ }
743
+
744
+ // ===========================================================================
745
+ // Challenge Management
746
+ // ===========================================================================
747
+
748
+ /**
749
+ * Get a pending challenge by ID.
750
+ */
751
+ getChallenge(challengeId: string): MFAChallenge | null {
752
+ const pending = this.pendingChallenges.get(challengeId);
753
+ if (!pending) return null;
754
+
755
+ // Return public challenge (without internal callbacks)
756
+ return {
757
+ id: pending.id,
758
+ sessionId: pending.sessionId,
759
+ type: pending.type,
760
+ prompt: pending.prompt,
761
+ expiresAt: pending.expiresAt,
762
+ };
763
+ }
764
+
765
+ /**
766
+ * Get all pending challenges for a session.
767
+ */
768
+ getChallengesForSession(sessionId: string): MFAChallenge[] {
769
+ const challenges: MFAChallenge[] = [];
770
+ for (const pending of this.pendingChallenges.values()) {
771
+ if (pending.sessionId === sessionId) {
772
+ challenges.push({
773
+ id: pending.id,
774
+ sessionId: pending.sessionId,
775
+ type: pending.type,
776
+ prompt: pending.prompt,
777
+ expiresAt: pending.expiresAt,
778
+ });
779
+ }
780
+ }
781
+ return challenges;
782
+ }
783
+
784
+ /**
785
+ * Cancel a pending challenge.
786
+ */
787
+ cancelChallenge(challengeId: string): boolean {
788
+ const challenge = this.pendingChallenges.get(challengeId);
789
+ if (!challenge) return false;
790
+
791
+ if (challenge.reject) {
792
+ challenge.reject({
793
+ code: 'mfa_failed',
794
+ message: 'MFA challenge was cancelled',
795
+ });
796
+ }
797
+
798
+ this.pendingChallenges.delete(challengeId);
799
+ this.clearTimeoutTimer(challengeId);
800
+ return true;
801
+ }
802
+
803
+ /**
804
+ * Get count of pending challenges.
805
+ */
806
+ getPendingChallengeCount(): number {
807
+ return this.pendingChallenges.size;
808
+ }
809
+
810
+ /**
811
+ * Clear all pending challenges (for testing).
812
+ */
813
+ clear(): void {
814
+ // Clear all timeout timers
815
+ for (const challengeId of this.timeoutTimers.keys()) {
816
+ this.clearTimeoutTimer(challengeId);
817
+ }
818
+
819
+ // Reject all pending challenges
820
+ for (const challenge of this.pendingChallenges.values()) {
821
+ if (challenge.reject) {
822
+ challenge.reject({
823
+ code: 'mfa_failed',
824
+ message: 'Auth tunnel was cleared',
825
+ });
826
+ }
827
+ }
828
+
829
+ this.pendingChallenges.clear();
830
+ this.timeoutTimers.clear();
831
+ }
832
+ }
833
+
834
+ // =============================================================================
835
+ // Factory Function
836
+ // =============================================================================
837
+
838
+ /**
839
+ * Create a new Auth Tunnel instance.
840
+ */
841
+ export function createAuthTunnel(config?: AuthTunnelConfig): AuthTunnel {
842
+ return new AuthTunnel(config);
843
+ }