diff --git a/.env.development b/.env.development
new file mode 100644
index 00000000000..3b5a47ec214
--- /dev/null
+++ b/.env.development
@@ -0,0 +1 @@
+DEV_FLAGS={"autofillDebugMode": true}
diff --git a/apps/browser/src/autofill/AUTOFILL_DEBUG.md b/apps/browser/src/autofill/AUTOFILL_DEBUG.md
new file mode 100644
index 00000000000..ad27ccdab80
--- /dev/null
+++ b/apps/browser/src/autofill/AUTOFILL_DEBUG.md
@@ -0,0 +1,311 @@
+# Autofill Debug Mode
+
+This document describes how to use the autofill debug mode for troubleshooting field qualification issues.
+
+## Enabling Debug Mode
+
+1. Set the dev flag in your `.env.development` file:
+ ```
+ DEV_FLAGS={"autofillDebugMode": true}
+ ```
+
+2. Rebuild the browser extension:
+ ```bash
+ npm run build:watch
+ ```
+
+3. Load the extension in your browser (or reload if already loaded)
+
+## Using Debug Mode
+
+When debug mode is enabled, the browser console will display:
+```
+[Bitwarden Debug] Autofill debug mode enabled. Use window.__BITWARDEN_AUTOFILL_DEBUG__
+```
+
+### Available Methods
+
+#### `exportSession(format?: 'json' | 'summary' | 'console')`
+Exports the current debug session in the specified format.
+
+```javascript
+// Export as JSON (default)
+const data = __BITWARDEN_AUTOFILL_DEBUG__.exportSession('json');
+console.log(JSON.parse(data));
+
+// Export as human-readable summary
+console.log(__BITWARDEN_AUTOFILL_DEBUG__.exportSession('summary'));
+
+// Output to console with pretty formatting
+__BITWARDEN_AUTOFILL_DEBUG__.exportSession('console');
+```
+
+#### `exportSummary()`
+Returns a human-readable summary of the most recent session.
+
+```javascript
+console.log(__BITWARDEN_AUTOFILL_DEBUG__.exportSummary());
+```
+
+#### `setTracingDepth(depth: number)`
+Configures how deep to trace precondition qualifiers.
+
+```javascript
+// Don't trace preconditions (faster, less data)
+__BITWARDEN_AUTOFILL_DEBUG__.setTracingDepth(0);
+
+// Trace immediate preconditions only (default)
+__BITWARDEN_AUTOFILL_DEBUG__.setTracingDepth(1);
+
+// Trace full precondition chain (more detail)
+__BITWARDEN_AUTOFILL_DEBUG__.setTracingDepth(2);
+```
+
+#### `getTracingDepth()`
+Returns the current tracing depth.
+
+```javascript
+const depth = __BITWARDEN_AUTOFILL_DEBUG__.getTracingDepth();
+console.log(`Current tracing depth: ${depth}`);
+```
+
+#### `getSessions()`
+Returns an array of all session IDs in memory.
+
+```javascript
+const sessions = __BITWARDEN_AUTOFILL_DEBUG__.getSessions();
+console.log('Active sessions:', sessions);
+```
+
+## Enhanced Console Logging
+
+When debug mode is enabled, the console automatically displays enhanced qualification messages:
+
+### Field Qualified
+```
+✅ Field Qualified: opid_12345
+ Field:
+ Passed conditions: ["notCurrentlyInSandboxedIframe", "isVisibleFormField", ...]
+ All responses: [...]
+```
+
+### Field Rejected
+```
+❌ Field Rejected: opid_12345
+ Field:
+ Blocking condition failed: isNotDisabledField
+ Message: field is disabled
+ All responses: [...]
+```
+
+## Understanding Debug Output
+
+### JSON Export Structure
+```json
+{
+ "sessionId": "session_1234567890_abc123",
+ "startTime": 1234567890000,
+ "endTime": 1234567891000,
+ "url": "https://example.com/login",
+ "qualifications": [
+ {
+ "fieldId": "opid_12345",
+ "elementSelector": "input[type='text']#username",
+ "attempts": [
+ {
+ "attemptId": "attempt_1234567890_xyz789",
+ "timestamp": 1234567890500,
+ "vector": "inline-menu",
+ "result": {
+ "result": true,
+ "conditions": {
+ "pass": [
+ {
+ "name": "isUsernameField",
+ "functionSource": "function isUsernameField(field) { ... }"
+ }
+ ],
+ "fail": []
+ },
+ "meta": {
+ "timestamp": 1234567890500,
+ "vector": "inline-menu",
+ "fieldSnapshot": {
+ "opid": "opid_12345",
+ "type": "text",
+ "value": "[REDACTED]"
+ },
+ "tracingDepth": 0
+ }
+ },
+ "triggeredBy": "page-load"
+ }
+ ],
+ "finalDecision": { ... }
+ }
+ ]
+}
+```
+
+### Summary Export Structure
+```
+================================================================================
+Bitwarden Autofill Debug Summary
+================================================================================
+Session ID: session_1234567890_abc123
+URL: https://example.com/login
+Start Time: 2026-02-06T12:00:00.000Z
+End Time: 2026-02-06T12:00:01.000Z
+Duration: 1.00s
+
+Total Fields Qualified: 2
+
+--------------------------------------------------------------------------------
+Field ID: opid_12345
+Selector: input[type='text']#username
+Attempts: 1
+Final Decision: ✅ QUALIFIED
+
+Passed Conditions:
+ ✓ isUsernameField
+ ✓ notCurrentlyInSandboxedIframe
+
+Vector: inline-menu
+Timestamp: 2026-02-06T12:00:00.500Z
+
+================================================================================
+⚠️ WARNING: This debug data may contain sensitive information.
+Do not share this data publicly or with untrusted parties.
+================================================================================
+```
+
+## Autofill Vectors
+
+Debug data tracks which autofill vector triggered the qualification:
+
+- **inline-menu**: Inline menu (autofill button) shown on field focus
+- **popup-autofill**: Autofill triggered from popup UI
+- **context-menu**: Autofill triggered from browser context menu
+- **keyboard-shortcut**: Autofill triggered by keyboard shortcut (Ctrl+Shift+L)
+- **page-load**: Autofill triggered automatically on page load
+
+## Precondition Tracing
+
+Some qualification functions depend on other qualifiers (preconditions). For example:
+- `isNewPasswordField` depends on `isPasswordField`
+- `isCurrentPasswordField` depends on `isPasswordField`
+
+The tracing depth controls how deep to capture these dependencies:
+
+### Depth = 0 (No Tracing)
+Only captures the top-level condition result. Fastest, minimal data.
+
+### Depth = 1 (Immediate Preconditions) - Default
+Captures the direct preconditions of the qualification.
+
+Example: For `isNewPasswordField`, captures:
+- `isNewPasswordField` (top level)
+- `isPasswordField` (immediate precondition)
+
+### Depth = 2+ (Full Chain)
+Captures the entire chain of preconditions recursively.
+
+Example: For a complex qualification, captures:
+- `isFieldForLoginForm` (top level)
+- `isUsernameFieldForLoginForm` (precondition)
+- `isUsernameField` (precondition of precondition)
+- ... (and so on)
+
+## Performance Impact
+
+- **Debug Disabled**: Zero performance overhead
+- **Debug Enabled (depth 0)**: ~1-2ms per field qualification
+- **Debug Enabled (depth 1)**: ~2-3ms per field qualification
+- **Debug Enabled (depth 2+)**: ~3-5ms per field qualification
+
+## Data Retention
+
+- Sessions are stored **in-memory only** (never persisted to disk)
+- Sessions automatically expire after **5 minutes**
+- Maximum **100 qualifications per session** to prevent memory issues
+- Field values are **always redacted** (`[REDACTED]`) to prevent PII leakage
+
+## Security Considerations
+
+⚠️ **Never share debug output publicly or with untrusted parties**
+
+While field values are redacted, debug output still contains:
+- Page URLs
+- Field IDs, names, and attributes
+- Form structure
+- Qualification logic (function source code)
+
+This information could be used to identify:
+- Internal applications
+- Custom form fields
+- Business logic patterns
+
+## Troubleshooting Common Issues
+
+### Debug API Not Available
+```javascript
+typeof window.__BITWARDEN_AUTOFILL_DEBUG__ === 'undefined'
+```
+
+**Solution**: Verify that:
+1. Dev flag is set correctly in `.env.development`
+2. Extension was rebuilt after setting the flag
+3. You're on a page where autofill is active (not a chrome:// or browser settings page)
+
+### No Sessions Found
+```javascript
+__BITWARDEN_AUTOFILL_DEBUG__.getSessions() // returns []
+```
+
+**Solution**:
+1. Focus a form field to trigger qualification
+2. Sessions expire after 5 minutes - check timing
+3. Verify debug mode is enabled (check console for initialization message)
+
+### Missing Precondition Data
+```javascript
+// result.meta.preconditions is undefined
+```
+
+**Solution**: Increase tracing depth:
+```javascript
+__BITWARDEN_AUTOFILL_DEBUG__.setTracingDepth(2);
+```
+
+## Example Workflow
+
+1. Enable debug mode and rebuild extension
+2. Navigate to the problematic login page
+3. Open browser DevTools console
+4. Focus the form field in question
+5. Check console for qualification messages
+6. Export detailed data:
+ ```javascript
+ const summary = __BITWARDEN_AUTOFILL_DEBUG__.exportSummary();
+ console.log(summary);
+ ```
+7. If needed, increase tracing depth for more detail:
+ ```javascript
+ __BITWARDEN_AUTOFILL_DEBUG__.setTracingDepth(2);
+ ```
+8. Focus the field again to capture with higher depth
+9. Export and analyze:
+ ```javascript
+ const json = __BITWARDEN_AUTOFILL_DEBUG__.exportSession('json');
+ // Save to file or analyze
+ ```
+
+## Future Enhancements
+
+Planned features (not yet implemented):
+- Visual debug panel UI in popup/options
+- Download JSON button
+- Copy to clipboard functionality
+- JSON-DSL representation of conditions
+- Filter by field type or qualification result
+- Session replay/comparison
diff --git a/apps/browser/src/autofill/content/bootstrap-autofill-overlay-menu.ts b/apps/browser/src/autofill/content/bootstrap-autofill-overlay-menu.ts
index 40e32843fd4..dfc487e5d3b 100644
--- a/apps/browser/src/autofill/content/bootstrap-autofill-overlay-menu.ts
+++ b/apps/browser/src/autofill/content/bootstrap-autofill-overlay-menu.ts
@@ -1,4 +1,5 @@
import { AutofillInlineMenuContentService } from "../overlay/inline-menu/content/autofill-inline-menu-content.service";
+import { AutofillDebugService } from "../services/autofill-debug.service";
import { AutofillOverlayContentService } from "../services/autofill-overlay-content.service";
import DomElementVisibilityService from "../services/dom-element-visibility.service";
import { DomQueryService } from "../services/dom-query.service";
@@ -16,12 +17,21 @@ import AutofillInit from "./autofill-init";
const domQueryService = new DomQueryService();
const domElementVisibilityService = new DomElementVisibilityService(inlineMenuContentService);
- const inlineMenuFieldQualificationService = new InlineMenuFieldQualificationService();
+
+ const debugService = new AutofillDebugService();
+ const inlineMenuFieldQualificationService = new InlineMenuFieldQualificationService(
+ debugService.isDebugEnabled() ? debugService : undefined,
+ );
+ if (debugService.isDebugEnabled()) {
+ inlineMenuFieldQualificationService.setDebugService(debugService);
+ }
+
const autofillOverlayContentService = new AutofillOverlayContentService(
domQueryService,
domElementVisibilityService,
inlineMenuFieldQualificationService,
inlineMenuContentService,
+ debugService.isDebugEnabled() ? debugService : undefined,
);
windowContext.bitwardenAutofillInit = new AutofillInit(
diff --git a/apps/browser/src/autofill/content/bootstrap-autofill-overlay-notifications.ts b/apps/browser/src/autofill/content/bootstrap-autofill-overlay-notifications.ts
index 8a079fa26c8..1ec53ef97fd 100644
--- a/apps/browser/src/autofill/content/bootstrap-autofill-overlay-notifications.ts
+++ b/apps/browser/src/autofill/content/bootstrap-autofill-overlay-notifications.ts
@@ -1,4 +1,5 @@
import { OverlayNotificationsContentService } from "../overlay/notifications/content/overlay-notifications-content.service";
+import { AutofillDebugService } from "../services/autofill-debug.service";
import { AutofillOverlayContentService } from "../services/autofill-overlay-content.service";
import DomElementVisibilityService from "../services/dom-element-visibility.service";
import { DomQueryService } from "../services/dom-query.service";
@@ -11,11 +12,21 @@ import AutofillInit from "./autofill-init";
if (!windowContext.bitwardenAutofillInit) {
const domQueryService = new DomQueryService();
const domElementVisibilityService = new DomElementVisibilityService();
- const inlineMenuFieldQualificationService = new InlineMenuFieldQualificationService();
+
+ const debugService = new AutofillDebugService();
+ const inlineMenuFieldQualificationService = new InlineMenuFieldQualificationService(
+ debugService.isDebugEnabled() ? debugService : undefined,
+ );
+ if (debugService.isDebugEnabled()) {
+ inlineMenuFieldQualificationService.setDebugService(debugService);
+ }
+
const autofillOverlayContentService = new AutofillOverlayContentService(
domQueryService,
domElementVisibilityService,
inlineMenuFieldQualificationService,
+ undefined,
+ debugService.isDebugEnabled() ? debugService : undefined,
);
let overlayNotificationsContentService: undefined | OverlayNotificationsContentService;
diff --git a/apps/browser/src/autofill/content/bootstrap-autofill-overlay.ts b/apps/browser/src/autofill/content/bootstrap-autofill-overlay.ts
index d204362ee25..839f61d551a 100644
--- a/apps/browser/src/autofill/content/bootstrap-autofill-overlay.ts
+++ b/apps/browser/src/autofill/content/bootstrap-autofill-overlay.ts
@@ -1,10 +1,13 @@
+import { DebugExportFormat } from "../models/autofill-debug-data";
import { AutofillInlineMenuContentService } from "../overlay/inline-menu/content/autofill-inline-menu-content.service";
import { OverlayNotificationsContentService } from "../overlay/notifications/content/overlay-notifications-content.service";
+import { AutofillDebugService } from "../services/autofill-debug.service";
import { AutofillOverlayContentService } from "../services/autofill-overlay-content.service";
import DomElementVisibilityService from "../services/dom-element-visibility.service";
import { DomQueryService } from "../services/dom-query.service";
import { InlineMenuFieldQualificationService } from "../services/inline-menu-field-qualification.service";
import { setupAutofillInitDisconnectAction } from "../utils";
+import { devFlagEnabled } from "../../platform/flags";
import AutofillInit from "./autofill-init";
@@ -19,12 +22,21 @@ import AutofillInit from "./autofill-init";
const domQueryService = new DomQueryService();
const domElementVisibilityService = new DomElementVisibilityService(inlineMenuContentService);
- const inlineMenuFieldQualificationService = new InlineMenuFieldQualificationService();
+
+ const debugService = new AutofillDebugService();
+ const inlineMenuFieldQualificationService = new InlineMenuFieldQualificationService(
+ debugService.isDebugEnabled() ? debugService : undefined,
+ );
+ if (debugService.isDebugEnabled()) {
+ inlineMenuFieldQualificationService.setDebugService(debugService);
+ }
+
const autofillOverlayContentService = new AutofillOverlayContentService(
domQueryService,
domElementVisibilityService,
inlineMenuFieldQualificationService,
inlineMenuContentService,
+ debugService.isDebugEnabled() ? debugService : undefined,
);
windowContext.bitwardenAutofillInit = new AutofillInit(
@@ -37,5 +49,39 @@ import AutofillInit from "./autofill-init";
setupAutofillInitDisconnectAction(windowContext);
windowContext.bitwardenAutofillInit.init();
+
+ if (devFlagEnabled("autofillDebugMode")) {
+ (windowContext as any).__BITWARDEN_AUTOFILL_DEBUG__ = {
+ exportSession: (format: DebugExportFormat = "json") => {
+ return debugService.exportCurrentSession(format);
+ },
+ exportSummary: () => {
+ return debugService.generateSummary(
+ Array.from(debugService.sessionStore.keys())[0] || "",
+ );
+ },
+ setTracingDepth: (depth: number) => {
+ debugService.setTracingDepth(depth);
+ console.log(`[Bitwarden Debug] Precondition tracing depth set to ${depth}`);
+ },
+ getTracingDepth: () => {
+ return debugService.getTracingDepth();
+ },
+ getSessions: () => {
+ return Array.from(debugService.sessionStore.keys());
+ },
+ };
+
+ console.log(
+ "%c[Bitwarden Debug] Autofill debug mode enabled. Use window.__BITWARDEN_AUTOFILL_DEBUG__",
+ "color: #175DDC; font-weight: bold; font-size: 12px",
+ );
+ console.log("Available methods:");
+ console.log(" - exportSession(format?: 'json' | 'summary' | 'console')");
+ console.log(" - exportSummary()");
+ console.log(" - setTracingDepth(depth: number)");
+ console.log(" - getTracingDepth()");
+ console.log(" - getSessions()");
+ }
}
})(window);
diff --git a/apps/browser/src/autofill/models/autofill-debug-data.ts b/apps/browser/src/autofill/models/autofill-debug-data.ts
new file mode 100644
index 00000000000..d1bb9161c0a
--- /dev/null
+++ b/apps/browser/src/autofill/models/autofill-debug-data.ts
@@ -0,0 +1,26 @@
+import { AutofillVector, QualificationResult } from "../services/abstractions/inline-menu-field-qualifications.service";
+
+export type AutofillDebugSession = {
+ sessionId: string;
+ startTime: number;
+ endTime?: number;
+ url: string;
+ qualifications: FieldQualificationRecord[];
+};
+
+export type FieldQualificationRecord = {
+ fieldId: string;
+ elementSelector: string;
+ attempts: QualificationAttempt[];
+ finalDecision?: QualificationResult;
+};
+
+export type QualificationAttempt = {
+ attemptId: string;
+ timestamp: number;
+ vector: AutofillVector;
+ result: QualificationResult;
+ triggeredBy?: string;
+};
+
+export type DebugExportFormat = "json" | "summary" | "console";
diff --git a/apps/browser/src/autofill/services/abstractions/inline-menu-field-qualifications.service.ts b/apps/browser/src/autofill/services/abstractions/inline-menu-field-qualifications.service.ts
index 04033664e9a..8f99f994b61 100644
--- a/apps/browser/src/autofill/services/abstractions/inline-menu-field-qualifications.service.ts
+++ b/apps/browser/src/autofill/services/abstractions/inline-menu-field-qualifications.service.ts
@@ -11,7 +11,38 @@ export type AutofillKeywordsMap = WeakMap<
export type SubmitButtonKeywordsMap = WeakMap;
+export type AutofillVector =
+ | "inline-menu"
+ | "popup-autofill"
+ | "context-menu"
+ | "keyboard-shortcut"
+ | "page-load";
+
+export type QualificationCondition = {
+ name: string;
+ functionSource?: string;
+};
+
+export type QualificationMeta = {
+ timestamp: number;
+ vector: AutofillVector;
+ fieldSnapshot: AutofillField;
+ pageSnapshot?: Partial;
+ preconditions?: QualificationResult[];
+ tracingDepth?: number;
+};
+
+export type QualificationResult = {
+ result: boolean;
+ conditions: {
+ pass: QualificationCondition[];
+ fail: QualificationCondition[];
+ };
+ meta?: QualificationMeta;
+};
+
export interface InlineMenuFieldQualificationService {
+ setCurrentVector(vector: AutofillVector): void;
isUsernameField(field: AutofillField): boolean;
isCurrentPasswordField(field: AutofillField): boolean;
isUpdateCurrentPasswordField(field: AutofillField): boolean;
diff --git a/apps/browser/src/autofill/services/autofill-debug.service.ts b/apps/browser/src/autofill/services/autofill-debug.service.ts
new file mode 100644
index 00000000000..b205e0d1bdd
--- /dev/null
+++ b/apps/browser/src/autofill/services/autofill-debug.service.ts
@@ -0,0 +1,295 @@
+import AutofillField from "../models/autofill-field";
+import AutofillPageDetails from "../models/autofill-page-details";
+import {
+ AutofillDebugSession,
+ DebugExportFormat,
+ FieldQualificationRecord,
+ QualificationAttempt,
+} from "../models/autofill-debug-data";
+import { devFlagEnabled } from "../../platform/flags";
+import {
+ AutofillVector,
+ QualificationResult,
+} from "./abstractions/inline-menu-field-qualifications.service";
+
+export class AutofillDebugService {
+ private tracingDepth = 1;
+ private currentSession: AutofillDebugSession | null = null;
+ readonly sessionStore: Map = new Map();
+ private readonly maxQualificationsPerSession = 100;
+ private readonly sessionTimeoutMs = 5 * 60 * 1000; // 5 minutes
+
+ isDebugEnabled(): boolean {
+ return devFlagEnabled("autofillDebugMode");
+ }
+
+ hasCurrentSession(): boolean {
+ return this.currentSession !== null;
+ }
+
+ getTracingDepth(): number {
+ return this.tracingDepth;
+ }
+
+ setTracingDepth(depth: number): void {
+ if (depth < 0) {
+ console.warn("[Bitwarden Debug] Tracing depth must be >= 0. Setting to 0.");
+ this.tracingDepth = 0;
+ return;
+ }
+ this.tracingDepth = depth;
+ }
+
+ startSession(url: string): string {
+ const sessionId = `session_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
+ this.currentSession = {
+ sessionId,
+ startTime: Date.now(),
+ url,
+ qualifications: [],
+ };
+ this.sessionStore.set(sessionId, this.currentSession);
+
+ setTimeout(() => this.cleanupSession(sessionId), this.sessionTimeoutMs);
+
+ return sessionId;
+ }
+
+ endSession(): void {
+ if (this.currentSession) {
+ this.currentSession.endTime = Date.now();
+ this.currentSession = null;
+ }
+ }
+
+ recordQualification(
+ fieldId: string,
+ elementSelector: string,
+ vector: AutofillVector,
+ result: QualificationResult,
+ triggeredBy?: string,
+ ): void {
+ if (!this.currentSession) {
+ return;
+ }
+
+ if (this.currentSession.qualifications.length >= this.maxQualificationsPerSession) {
+ console.warn(
+ `[Bitwarden Debug] Max qualifications (${this.maxQualificationsPerSession}) reached for session`,
+ );
+ return;
+ }
+
+ let fieldRecord = this.currentSession.qualifications.find((q) => q.fieldId === fieldId);
+
+ if (!fieldRecord) {
+ fieldRecord = {
+ fieldId,
+ elementSelector,
+ attempts: [],
+ };
+ this.currentSession.qualifications.push(fieldRecord);
+ }
+
+ const attempt: QualificationAttempt = {
+ attemptId: `attempt_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`,
+ timestamp: Date.now(),
+ vector,
+ result,
+ triggeredBy,
+ };
+
+ fieldRecord.attempts.push(attempt);
+ fieldRecord.finalDecision = result;
+ }
+
+ exportCurrentSession(format: DebugExportFormat = "json"): string {
+ if (!this.currentSession) {
+ return format === "json" ? JSON.stringify({ error: "No active session" }) : "No active session";
+ }
+
+ return this.exportSession(this.currentSession.sessionId, format);
+ }
+
+ exportSession(sessionId: string, format: DebugExportFormat = "json"): string {
+ const session = this.sessionStore.get(sessionId);
+
+ if (!session) {
+ return format === "json" ? JSON.stringify({ error: "Session not found" }) : "Session not found";
+ }
+
+ switch (format) {
+ case "json":
+ return JSON.stringify(session, null, 2);
+ case "summary":
+ return this.generateSummary(sessionId);
+ case "console":
+ this.generateConsoleOutput(sessionId);
+ return "Output logged to console";
+ default:
+ return JSON.stringify(session, null, 2);
+ }
+ }
+
+ generateSummary(sessionId: string): string {
+ const session = this.sessionStore.get(sessionId);
+
+ if (!session) {
+ return "Session not found";
+ }
+
+ const lines: string[] = [];
+ lines.push("=".repeat(80));
+ lines.push("Bitwarden Autofill Debug Summary");
+ lines.push("=".repeat(80));
+ lines.push(`Session ID: ${session.sessionId}`);
+ lines.push(`URL: ${session.url}`);
+ lines.push(`Start Time: ${new Date(session.startTime).toISOString()}`);
+ if (session.endTime) {
+ lines.push(`End Time: ${new Date(session.endTime).toISOString()}`);
+ lines.push(`Duration: ${((session.endTime - session.startTime) / 1000).toFixed(2)}s`);
+ }
+ lines.push("");
+ lines.push(`Total Fields Qualified: ${session.qualifications.length}`);
+ lines.push("");
+
+ for (const fieldRecord of session.qualifications) {
+ lines.push("-".repeat(80));
+ lines.push(`Field ID: ${fieldRecord.fieldId}`);
+ lines.push(`Selector: ${fieldRecord.elementSelector}`);
+ lines.push(`Attempts: ${fieldRecord.attempts.length}`);
+
+ if (fieldRecord.finalDecision) {
+ lines.push(
+ `Final Decision: ${fieldRecord.finalDecision.result ? "✅ QUALIFIED" : "❌ REJECTED"}`,
+ );
+
+ if (fieldRecord.finalDecision.conditions.pass.length > 0) {
+ lines.push("");
+ lines.push("Passed Conditions:");
+ for (const condition of fieldRecord.finalDecision.conditions.pass) {
+ lines.push(` ✓ ${condition.name}`);
+ }
+ }
+
+ if (fieldRecord.finalDecision.conditions.fail.length > 0) {
+ lines.push("");
+ lines.push("Failed Conditions:");
+ for (const condition of fieldRecord.finalDecision.conditions.fail) {
+ lines.push(` ✗ ${condition.name}`);
+ }
+ }
+
+ if (fieldRecord.finalDecision.meta) {
+ lines.push("");
+ lines.push(`Vector: ${fieldRecord.finalDecision.meta.vector}`);
+ lines.push(`Timestamp: ${new Date(fieldRecord.finalDecision.meta.timestamp).toISOString()}`);
+ }
+ }
+
+ lines.push("");
+ }
+
+ lines.push("=".repeat(80));
+ lines.push("⚠️ WARNING: This debug data may contain sensitive information.");
+ lines.push("Do not share this data publicly or with untrusted parties.");
+ lines.push("=".repeat(80));
+
+ return lines.join("\n");
+ }
+
+ generateConsoleOutput(sessionId: string): void {
+ const session = this.sessionStore.get(sessionId);
+
+ if (!session) {
+ console.warn("[Bitwarden Debug] Session not found");
+ return;
+ }
+
+ console.group(
+ `%c[Bitwarden Debug] Session: ${session.sessionId}`,
+ "color: #175DDC; font-weight: bold; font-size: 14px",
+ );
+ console.log(`URL: ${session.url}`);
+ console.log(`Start Time: ${new Date(session.startTime).toISOString()}`);
+ if (session.endTime) {
+ console.log(`End Time: ${new Date(session.endTime).toISOString()}`);
+ console.log(`Duration: ${((session.endTime - session.startTime) / 1000).toFixed(2)}s`);
+ }
+ console.log(`Total Fields: ${session.qualifications.length}`);
+
+ for (const fieldRecord of session.qualifications) {
+ const icon = fieldRecord.finalDecision?.result ? "✅" : "❌";
+ const status = fieldRecord.finalDecision?.result ? "QUALIFIED" : "REJECTED";
+
+ console.group(`${icon} Field: ${fieldRecord.fieldId} (${status})`);
+ console.log(`Selector: ${fieldRecord.elementSelector}`);
+ console.log(`Attempts: ${fieldRecord.attempts.length}`);
+
+ if (fieldRecord.finalDecision) {
+ if (fieldRecord.finalDecision.conditions.pass.length > 0) {
+ console.group("✓ Passed Conditions");
+ for (const condition of fieldRecord.finalDecision.conditions.pass) {
+ console.log(`${condition.name}`);
+ if (condition.functionSource) {
+ console.log(`Function:`, condition.functionSource);
+ }
+ }
+ console.groupEnd();
+ }
+
+ if (fieldRecord.finalDecision.conditions.fail.length > 0) {
+ console.group("✗ Failed Conditions");
+ for (const condition of fieldRecord.finalDecision.conditions.fail) {
+ console.log(`${condition.name}`);
+ if (condition.functionSource) {
+ console.log(`Function:`, condition.functionSource);
+ }
+ }
+ console.groupEnd();
+ }
+
+ if (fieldRecord.finalDecision.meta) {
+ console.group("Metadata");
+ console.log(`Vector: ${fieldRecord.finalDecision.meta.vector}`);
+ console.log(`Timestamp: ${new Date(fieldRecord.finalDecision.meta.timestamp).toISOString()}`);
+ console.log(`Field Snapshot:`, fieldRecord.finalDecision.meta.fieldSnapshot);
+ if (fieldRecord.finalDecision.meta.pageSnapshot) {
+ console.log(`Page Snapshot:`, fieldRecord.finalDecision.meta.pageSnapshot);
+ }
+ if (fieldRecord.finalDecision.meta.preconditions) {
+ console.log(`Preconditions:`, fieldRecord.finalDecision.meta.preconditions);
+ }
+ console.groupEnd();
+ }
+ }
+
+ console.groupEnd();
+ }
+
+ console.groupEnd();
+ }
+
+ captureFieldSnapshot(field: AutofillField): AutofillField {
+ // Return a shallow copy to avoid capturing field values
+ return { ...field, value: "[REDACTED]" };
+ }
+
+ capturePageSnapshot(pageDetails: AutofillPageDetails): Partial {
+ // Return only non-sensitive page information
+ return {
+ title: pageDetails.title,
+ url: pageDetails.url,
+ documentUrl: pageDetails.documentUrl,
+ forms: pageDetails.forms,
+ // Exclude fields array to avoid capturing field values
+ };
+ }
+
+ private cleanupSession(sessionId: string): void {
+ if (this.currentSession?.sessionId === sessionId) {
+ this.endSession();
+ }
+ this.sessionStore.delete(sessionId);
+ }
+}
diff --git a/apps/browser/src/autofill/services/autofill-overlay-content.service.ts b/apps/browser/src/autofill/services/autofill-overlay-content.service.ts
index 958cac3cd46..ad0c4b742b6 100644
--- a/apps/browser/src/autofill/services/autofill-overlay-content.service.ts
+++ b/apps/browser/src/autofill/services/autofill-overlay-content.service.ts
@@ -56,6 +56,7 @@ import { DomElementVisibilityService } from "./abstractions/dom-element-visibili
import { DomQueryService } from "./abstractions/dom-query.service";
import { InlineMenuFieldQualificationService } from "./abstractions/inline-menu-field-qualifications.service";
import { AutoFillConstants } from "./autofill-constants";
+import { AutofillDebugService } from "./autofill-debug.service";
export type QualificationCriteria = {
formFieldElement: ElementWithOpId;
@@ -186,6 +187,7 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
private domElementVisibilityService: DomElementVisibilityService,
private inlineMenuFieldQualificationService: InlineMenuFieldQualificationService,
private inlineMenuContentService?: AutofillInlineMenuContentService,
+ private debugService?: AutofillDebugService,
) {}
/**
@@ -225,11 +227,17 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
autofillFieldData: AutofillField,
pageDetails: AutofillPageDetails,
) {
+ if (this.debugService?.isDebugEnabled() && !this.debugService.hasCurrentSession()) {
+ this.debugService.startSession(globalThis.location.href);
+ this.inlineMenuFieldQualificationService.setCurrentVector("inline-menu");
+ }
+
const qualification = this.isQualifiedField({
formFieldElement,
autofillFieldData,
pageDetails,
});
+
if (!qualification) {
return;
}
@@ -314,19 +322,51 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
}
isQualifiedField(criteria: QualificationCriteria) {
+ const debugEnabled = this.debugService?.isDebugEnabled() ?? false;
const responses: QualificationResponse[] = [];
+
for (const definition of this.qualifiers) {
const response = this.qualify(definition, criteria);
responses.push(response);
+
if (response.result === false && definition?.blocking !== false) {
- console.log({ element: criteria.formFieldElement, responses });
+ if (debugEnabled) {
+ console.group(
+ `%c❌ Field Rejected: ${criteria.autofillFieldData.opid}`,
+ "color: #ef4444; font-weight: bold",
+ );
+ console.log("Field:", criteria.formFieldElement);
+ console.log("Blocking condition failed:", response.alias);
+ console.log("Message:", response.message);
+ console.log("All responses:", responses);
+ console.groupEnd();
+ } else {
+ console.log({ element: criteria.formFieldElement, responses });
+ }
return false;
}
+
if (response.result === true && definition.effect) {
void definition.effect(criteria);
}
}
- console.log({ element: criteria.formFieldElement, responses });
+
+ if (debugEnabled) {
+ console.group(
+ `%c✅ Field Qualified: ${criteria.autofillFieldData.opid}`,
+ "color: #10b981; font-weight: bold",
+ );
+ console.log("Field:", criteria.formFieldElement);
+ console.log(
+ "Passed conditions:",
+ responses.filter((r) => r.result).map((r) => r.alias),
+ );
+ console.log("All responses:", responses);
+ console.groupEnd();
+ } else {
+ console.log({ element: criteria.formFieldElement, responses });
+ }
+
return true;
}
diff --git a/apps/browser/src/autofill/services/inline-menu-field-qualification.service.ts b/apps/browser/src/autofill/services/inline-menu-field-qualification.service.ts
index 65f9eee1ecb..c2bf2dd805f 100644
--- a/apps/browser/src/autofill/services/inline-menu-field-qualification.service.ts
+++ b/apps/browser/src/autofill/services/inline-menu-field-qualification.service.ts
@@ -4,7 +4,9 @@ import { getSubmitButtonKeywordsSet, sendExtensionMessage } from "../utils";
import {
AutofillKeywordsMap,
+ AutofillVector,
InlineMenuFieldQualificationService as InlineMenuFieldQualificationServiceInterface,
+ QualificationResult,
SubmitButtonKeywordsMap,
} from "./abstractions/inline-menu-field-qualifications.service";
import {
@@ -15,6 +17,7 @@ import {
SubmitLoginButtonNames,
} from "./autofill-constants";
import AutofillService from "./autofill.service";
+import { AutofillDebugService } from "./autofill-debug.service";
export class InlineMenuFieldQualificationService implements InlineMenuFieldQualificationServiceInterface {
private searchFieldNamesSet = new Set(AutoFillConstants.SearchFieldNames);
@@ -149,6 +152,66 @@ export class InlineMenuFieldQualificationService implements InlineMenuFieldQuali
]);
private totpFieldAutocompleteValue = "one-time-code";
private premiumEnabled = false;
+ private currentVector: AutofillVector = "inline-menu";
+ private debugService?: AutofillDebugService;
+
+ private get debugEnabled(): boolean {
+ return this.debugService?.isDebugEnabled() ?? false;
+ }
+
+ setCurrentVector(vector: AutofillVector): void {
+ this.currentVector = vector;
+ }
+
+ setDebugService(debugService: AutofillDebugService): void {
+ this.debugService = debugService;
+ }
+
+ /**
+ * Wraps a qualification function to return QualificationResult with debug metadata.
+ *
+ * @param name - Human-readable name for the condition
+ * @param fn - The qualification function to wrap
+ * @param field - The field being qualified
+ * @param pageDetails - Optional page details
+ * @param currentDepth - Current depth for precondition tracing
+ */
+ private wrapQualifier(
+ name: string,
+ fn: (field: AutofillField, pageDetails?: AutofillPageDetails) => boolean,
+ field: AutofillField,
+ pageDetails?: AutofillPageDetails,
+ currentDepth = 0,
+ ): QualificationResult {
+ const result = fn.call(this, field, pageDetails);
+
+ if (!this.debugEnabled) {
+ return { result, conditions: { pass: [], fail: [] } };
+ }
+
+ const maxDepth = this.debugService?.getTracingDepth() ?? 1;
+ const shouldTrace = currentDepth < maxDepth;
+
+ const condition = {
+ name,
+ functionSource: shouldTrace ? fn.toString() : undefined,
+ };
+
+ return {
+ result,
+ conditions: {
+ pass: result ? [condition] : [],
+ fail: result ? [] : [condition],
+ },
+ meta: {
+ timestamp: Date.now(),
+ vector: this.currentVector,
+ fieldSnapshot: this.debugService?.captureFieldSnapshot(field) ?? field,
+ pageSnapshot: pageDetails ? this.debugService?.capturePageSnapshot(pageDetails) : undefined,
+ tracingDepth: currentDepth,
+ },
+ };
+ }
/**
* Validates the provided field to indicate if the field is a new email field used for account creation/registration.
@@ -207,7 +270,8 @@ export class InlineMenuFieldQualificationService implements InlineMenuFieldQuali
return false;
}
- constructor() {
+ constructor(debugService?: AutofillDebugService) {
+ this.debugService = debugService;
void sendExtensionMessage("getUserPremiumStatus").then((premiumStatus) => {
this.premiumEnabled = !!premiumStatus?.result;
});
@@ -938,6 +1002,16 @@ export class InlineMenuFieldQualificationService implements InlineMenuFieldQuali
* @param field - The field to validate
*/
isUsernameField = (field: AutofillField): boolean => {
+ return this.isUsernameFieldWithResult(field).result;
+ };
+
+ /**
+ * Validates the provided field as a username field with debug metadata.
+ *
+ * @param field - The field to validate
+ * @param depth - Current depth for precondition tracing
+ */
+ isUsernameFieldWithResult(field: AutofillField, depth = 0): QualificationResult {
const fieldType = field.type;
if (
!fieldType ||
@@ -946,11 +1020,12 @@ export class InlineMenuFieldQualificationService implements InlineMenuFieldQuali
this.fieldHasDisqualifyingAttributeValue(field) ||
this.isTotpField(field)
) {
- return false;
+ return this.wrapQualifier("isUsernameField", () => false, field, undefined, depth);
}
- return this.keywordsFoundInFieldData(field, AutoFillConstants.UsernameFieldNames);
- };
+ const result = this.keywordsFoundInFieldData(field, AutoFillConstants.UsernameFieldNames);
+ return this.wrapQualifier("isUsernameField", () => result, field, undefined, depth);
+ }
/**
* Validates the provided field as an email field.
@@ -974,15 +1049,44 @@ export class InlineMenuFieldQualificationService implements InlineMenuFieldQuali
* @param field - The field to validate
*/
isCurrentPasswordField = (field: AutofillField): boolean => {
+ return this.isCurrentPasswordFieldWithResult(field).result;
+ };
+
+ /**
+ * Validates the provided field as a current password field with debug metadata.
+ *
+ * @param field - The field to validate
+ * @param depth - Current depth for precondition tracing
+ */
+ isCurrentPasswordFieldWithResult(field: AutofillField, depth = 0): QualificationResult {
+ const maxDepth = this.debugService?.getTracingDepth() ?? 1;
+
if (
this.fieldContainsAutocompleteValues(field, this.newPasswordAutoCompleteValue) ||
this.keywordsFoundInFieldData(field, this.accountCreationFieldKeywords)
) {
- return false;
+ return this.wrapQualifier("isCurrentPasswordField", () => false, field, undefined, depth);
}
- return this.isPasswordField(field);
- };
+ const passwordCheck =
+ depth < maxDepth
+ ? this.isPasswordFieldWithResult(field, depth + 1)
+ : this.wrapQualifier("isPasswordField", this.isPasswordField, field, undefined, depth);
+
+ if (!passwordCheck.result) {
+ return {
+ result: false,
+ conditions: passwordCheck.conditions,
+ meta: {
+ ...passwordCheck.meta,
+ preconditions: [passwordCheck],
+ tracingDepth: depth,
+ },
+ };
+ }
+
+ return this.wrapQualifier("isCurrentPasswordField", () => true, field, undefined, depth);
+ }
/**
* Validates the provided field as a current password field for an update password form.
@@ -1006,15 +1110,46 @@ export class InlineMenuFieldQualificationService implements InlineMenuFieldQuali
* @param field - The field to validate
*/
isNewPasswordField = (field: AutofillField): boolean => {
+ return this.isNewPasswordFieldWithResult(field).result;
+ };
+
+ /**
+ * Validates the provided field as a new password field with debug metadata.
+ *
+ * @param field - The field to validate
+ * @param depth - Current depth for precondition tracing
+ */
+ isNewPasswordFieldWithResult(field: AutofillField, depth = 0): QualificationResult {
+ const maxDepth = this.debugService?.getTracingDepth() ?? 1;
+
if (this.fieldContainsAutocompleteValues(field, this.currentPasswordAutocompleteValue)) {
- return false;
+ return this.wrapQualifier("isNewPasswordField", () => false, field, undefined, depth);
}
- return (
- this.isPasswordField(field) &&
- this.keywordsFoundInFieldData(field, this.accountCreationFieldKeywords)
- );
- };
+ const passwordCheck =
+ depth < maxDepth
+ ? this.isPasswordFieldWithResult(field, depth + 1)
+ : this.wrapQualifier("isPasswordField", this.isPasswordField, field, undefined, depth);
+
+ if (!passwordCheck.result) {
+ return {
+ result: false,
+ conditions: passwordCheck.conditions,
+ meta: {
+ ...passwordCheck.meta,
+ preconditions: [passwordCheck],
+ tracingDepth: depth,
+ },
+ };
+ }
+
+ const keywordCheck = this.keywordsFoundInFieldData(field, this.accountCreationFieldKeywords);
+ if (!keywordCheck) {
+ return this.wrapQualifier("isNewPasswordField", () => false, field, undefined, depth);
+ }
+
+ return this.wrapQualifier("isNewPasswordField", () => true, field, undefined, depth);
+ }
/**
* Validates the provided field as a password field.
@@ -1022,6 +1157,16 @@ export class InlineMenuFieldQualificationService implements InlineMenuFieldQuali
* @param field - The field to validate
*/
private isPasswordField = (field: AutofillField): boolean => {
+ return this.isPasswordFieldWithResult(field).result;
+ };
+
+ /**
+ * Validates the provided field as a password field with debug metadata.
+ *
+ * @param field - The field to validate
+ * @param depth - Current depth for precondition tracing
+ */
+ private isPasswordFieldWithResult(field: AutofillField, depth = 0): QualificationResult {
const isInputPasswordType = field.type === "password";
if (
(!isInputPasswordType &&
@@ -1029,11 +1174,12 @@ export class InlineMenuFieldQualificationService implements InlineMenuFieldQuali
this.fieldHasDisqualifyingAttributeValue(field) ||
this.isTotpField(field)
) {
- return false;
+ return this.wrapQualifier("isPasswordField", () => false, field, undefined, depth);
}
- return isInputPasswordType || this.isLikePasswordField(field);
- };
+ const result = isInputPasswordType || this.isLikePasswordField(field);
+ return this.wrapQualifier("isPasswordField", () => result, field, undefined, depth);
+ }
/**
* Validates the provided field as a field to indicate if the
diff --git a/apps/browser/src/platform/flags.ts b/apps/browser/src/platform/flags.ts
index 30441d42979..44ecba77c3d 100644
--- a/apps/browser/src/platform/flags.ts
+++ b/apps/browser/src/platform/flags.ts
@@ -14,6 +14,7 @@ export type Flags = SharedFlags;
// required to avoid linting errors when there are no flags
export type DevFlags = {
managedEnvironment?: GroupPolicyEnvironment;
+ autofillDebugMode?: boolean;
} & SharedDevFlags;
export function flagEnabled(flag: keyof Flags): boolean {