From 9fa1e25390ada6caa230fa9d4d0428e42e7f5f71 Mon Sep 17 00:00:00 2001 From: Hakim Raja Date: Sat, 18 Jul 2026 16:40:31 +0200 Subject: [PATCH] feat(browse): add webauthn command for CDP virtual authenticator support navigator.credentials.create()/.get() have no platform authenticator to resolve against in headless Chromium, so passkey/WebAuthn flows hang or reject outright. `webauthn [on|off]` attaches a CDP virtual authenticator (ctap2, resident keys, automatic presence simulation) to the active tab, scoped to the page's lifetime via the existing getOrCreateCdpSession cache. Bumps VERSION to 1.60.2.0 (patch) and documents the command in browse/SKILL.md.tmpl (regenerated) and gstack/llms.txt. --- CHANGELOG.md | 22 ++++++ VERSION | 2 +- browse/SKILL.md | 12 ++++ browse/SKILL.md.tmpl | 11 +++ browse/src/browser-manager.ts | 37 +++++++++- browse/src/commands.ts | 2 + browse/src/write-commands.ts | 17 +++++ browse/test/browser-manager-unit.test.ts | 90 ++++++++++++++++++++++++ browse/test/commands.test.ts | 58 +++++++++++++++ browse/test/fixtures/webauthn.html | 36 ++++++++++ gstack/llms.txt | 1 + package.json | 2 +- 12 files changed, 287 insertions(+), 3 deletions(-) create mode 100644 browse/test/fixtures/webauthn.html diff --git a/CHANGELOG.md b/CHANGELOG.md index 351e7cabd..82cd196b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## [1.60.2.0] - 2026-07-18 + +## **`$B webauthn` unblocks passkey/WebAuthn flows in headless Chromium.** + +`navigator.credentials.create()`/`.get()` previously hung indefinitely in headless +Chromium — there's no platform authenticator to satisfy the ceremony. The new +`webauthn [on|off]` command attaches a CDP virtual authenticator (`ctap2`, +resident keys, automatic presence simulation) to the active tab, so passkey +registration and sign-in resolve automatically without any manual approval UI. +`off` removes the authenticator and its credentials. + +### Itemized changes + +#### Added +- `webauthn [on|off]` command (`browse/src/commands.ts`, `browse/src/write-commands.ts`): registers a CDP virtual authenticator per tab via the existing `getOrCreateCdpSession` cache, so its lifecycle matches the page's (detaches and evicts on close). Bare invocation defaults to `on`. +- `BrowserManager.enableWebAuthn`/`disableWebAuthn` (`browse/src/browser-manager.ts`): idempotent enable (reports `alreadyEnabled` on a repeat call) and a safe no-op disable when nothing is enabled. +- Documented under "Test WebAuthn / passkey flows" in `browse/SKILL.md.tmpl`. + +#### For contributors +- `browse/test/browser-manager-unit.test.ts`: unit coverage for `enableWebAuthn`/`disableWebAuthn` against a mocked CDP session (enable, idempotent re-enable, disable, no-op disable, re-enable after disable). +- `browse/test/commands.test.ts` + `browse/test/fixtures/webauthn.html`: integration coverage proving `navigator.credentials.create()` hangs without the authenticator and resolves once `webauthn on` is set, plus the on/off/bare-invocation/invalid-subcommand dispatch paths. + ## [1.60.1.0] - 2026-07-09 ## **The /autoplan dual-voice eval is back on the board, catching real regressions.** diff --git a/VERSION b/VERSION index c4190e004..762f17554 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.60.1.0 +1.60.2.0 diff --git a/browse/SKILL.md b/browse/SKILL.md index a8138dbbc..9298c7aff 100644 --- a/browse/SKILL.md +++ b/browse/SKILL.md @@ -725,6 +725,17 @@ are created; malformed base64 errors instead of writing corrupt bytes. Pick A wh you can (no CDP transfer at all); reach for B only when the bytes come back as a return value. +### 15. Test WebAuthn / passkey flows +```bash +$B webauthn # enable a virtual authenticator on this tab (bare = on) +$B click "#create-passkey" # navigator.credentials.create()/get() now resolves automatically +$B snapshot -D # verify the app's post-auth state +$B webauthn off # remove the authenticator and its credentials +``` +Without this, `navigator.credentials.create()`/`.get()` hang waiting for a platform +authenticator that headless Chromium doesn't have. `webauthn` attaches a CDP virtual +authenticator scoped to the tab — no OS-level passkey UI, no manual approval needed. + ## Puppeteer → browse cheatsheet Migrating from Puppeteer? Here's the 1:1 mapping for the core workflow: @@ -955,6 +966,7 @@ $B prettyscreenshot --cleanup --scroll-to ".pricing" --width 1440 ~/Desktop/hero | `useragent ` | Set user agent | | `viewport [] [--scale ]` | Set viewport size and optional deviceScaleFactor (1-3, for retina screenshots). --scale requires a context rebuild. | | `wait ` | Wait for element, network idle, or page load (timeout: 15s) | +| `webauthn [on|off]` | Enable/disable a CDP virtual authenticator on the active tab so passkey/WebAuthn navigator.credentials calls resolve automatically instead of hanging. Bare invocation means on. off removes the authenticator and its credentials. | ### Inspection | Command | Description | diff --git a/browse/SKILL.md.tmpl b/browse/SKILL.md.tmpl index 9a159e4c9..e1b32220c 100644 --- a/browse/SKILL.md.tmpl +++ b/browse/SKILL.md.tmpl @@ -180,6 +180,17 @@ are created; malformed base64 errors instead of writing corrupt bytes. Pick A wh you can (no CDP transfer at all); reach for B only when the bytes come back as a return value. +### 15. Test WebAuthn / passkey flows +```bash +$B webauthn # enable a virtual authenticator on this tab (bare = on) +$B click "#create-passkey" # navigator.credentials.create()/get() now resolves automatically +$B snapshot -D # verify the app's post-auth state +$B webauthn off # remove the authenticator and its credentials +``` +Without this, `navigator.credentials.create()`/`.get()` hang waiting for a platform +authenticator that headless Chromium doesn't have. `webauthn` attaches a CDP virtual +authenticator scoped to the tab — no OS-level passkey UI, no manual approval needed. + ## Puppeteer → browse cheatsheet Migrating from Puppeteer? Here's the 1:1 mapping for the core workflow: diff --git a/browse/src/browser-manager.ts b/browse/src/browser-manager.ts index f9f3317b5..614db5709 100644 --- a/browse/src/browser-manager.ts +++ b/browse/src/browser-manager.ts @@ -22,7 +22,7 @@ import { emitActivity } from './activity'; import { validateNavigationUrl } from './url-validation'; import { TabSession, type RefEntry } from './tab-session'; import { resolveChromiumProfile, cleanSingletonLocks } from './config'; -import { withCdpSession } from './cdp-bridge'; +import { withCdpSession, getOrCreateCdpSession } from './cdp-bridge'; import type { MemorySnapshot, MemoryStructureStats, MemoryTabSnapshot, MemoryProcess } from './memory-snapshot'; /** @@ -1196,6 +1196,41 @@ export class BrowserManager { return this.dialogPromptText; } + // ─── WebAuthn Virtual Authenticator ──────────────────────── + // Virtual authenticator state lives on the CDP session, so the session must + // stay attached for the page's lifetime — getOrCreateCdpSession detaches and + // evicts on page close, which is exactly the required lifecycle. + private webauthnSessions: WeakMap = new WeakMap(); + private webauthnAuthenticators: WeakMap = new WeakMap(); + + async enableWebAuthn(page: Page): Promise<{ alreadyEnabled: boolean }> { + if (this.webauthnAuthenticators.has(page)) return { alreadyEnabled: true }; + const session = await getOrCreateCdpSession(page, this.webauthnSessions); + await session.send('WebAuthn.enable', { enableUI: false }); + const { authenticatorId } = await session.send('WebAuthn.addVirtualAuthenticator', { + options: { + protocol: 'ctap2', + transport: 'internal', + hasResidentKey: true, + hasUserVerification: true, + isUserVerified: true, + automaticPresenceSimulation: true, + }, + }); + this.webauthnAuthenticators.set(page, authenticatorId); + return { alreadyEnabled: false }; + } + + async disableWebAuthn(page: Page): Promise<{ wasEnabled: boolean }> { + const authenticatorId = this.webauthnAuthenticators.get(page); + if (!authenticatorId) return { wasEnabled: false }; + const session = await getOrCreateCdpSession(page, this.webauthnSessions); + await session.send('WebAuthn.removeVirtualAuthenticator', { authenticatorId }); + await session.send('WebAuthn.disable'); + this.webauthnAuthenticators.delete(page); + return { wasEnabled: true }; + } + // ─── Cookie Origin Tracking ──────────────────────────────── trackCookieImportDomains(domains: string[]): void { for (const d of domains) this.cookieImportedDomains.add(d); diff --git a/browse/src/commands.ts b/browse/src/commands.ts index 73bc9ab1b..fc4f88145 100644 --- a/browse/src/commands.ts +++ b/browse/src/commands.ts @@ -25,6 +25,7 @@ export const WRITE_COMMANDS = new Set([ 'click', 'fill', 'select', 'hover', 'type', 'press', 'scroll', 'wait', 'viewport', 'cookie', 'cookie-import', 'cookie-import-browser', 'header', 'useragent', 'upload', 'dialog-accept', 'dialog-dismiss', + 'webauthn', 'style', 'cleanup', 'prettyscreenshot', 'download', 'scrape', 'archive', ]); @@ -135,6 +136,7 @@ export const COMMAND_DESCRIPTIONS: Record' }, 'dialog-accept': { category: 'Interaction', description: 'Auto-accept next alert/confirm/prompt. Optional text is sent as the prompt response', usage: 'dialog-accept [text]' }, 'dialog-dismiss': { category: 'Interaction', description: 'Auto-dismiss next dialog' }, + 'webauthn': { category: 'Interaction', description: 'Enable/disable a CDP virtual authenticator on the active tab so passkey/WebAuthn navigator.credentials calls resolve automatically instead of hanging. Bare invocation means on. off removes the authenticator and its credentials.', usage: 'webauthn [on|off]' }, // Data extraction 'download': { category: 'Extraction', description: 'Download URL or media element to disk using browser cookies. Use --navigate for URLs that trigger browser downloads (CDN redirects, Content-Disposition, anti-bot protected sites)', usage: 'download [path] [--base64] [--navigate]' }, 'scrape': { category: 'Extraction', description: 'Bulk download all media from page. Writes manifest.json', usage: 'scrape [--selector sel] [--dir path] [--limit N]' }, diff --git a/browse/src/write-commands.ts b/browse/src/write-commands.ts index 4a847141d..63902778b 100644 --- a/browse/src/write-commands.ts +++ b/browse/src/write-commands.ts @@ -639,6 +639,23 @@ export async function handleWriteCommand( return 'Dialogs will be dismissed'; } + case 'webauthn': { + const sub = (args[0] ?? 'on').toLowerCase(); + if (sub === 'on') { + const { alreadyEnabled } = await bm.enableWebAuthn(page); + return alreadyEnabled + ? 'WebAuthn virtual authenticator already enabled for this tab' + : 'WebAuthn virtual authenticator enabled — navigator.credentials.create()/get() will now resolve automatically'; + } + if (sub === 'off') { + const { wasEnabled } = await bm.disableWebAuthn(page); + return wasEnabled + ? 'WebAuthn virtual authenticator removed — the tab is back to having no platform authenticator' + : 'WebAuthn virtual authenticator is not enabled for this tab'; + } + throw new Error('Usage: browse webauthn [on|off]'); + } + case 'cookie-import': { const filePath = args[0]; if (!filePath) throw new Error('Usage: browse cookie-import '); diff --git a/browse/test/browser-manager-unit.test.ts b/browse/test/browser-manager-unit.test.ts index d0ef8d7a6..4105c766a 100644 --- a/browse/test/browser-manager-unit.test.ts +++ b/browse/test/browser-manager-unit.test.ts @@ -303,3 +303,93 @@ describe('stealth injected on every context-creation path', () => { expect(sites.length).toBeGreaterThanOrEqual(2); }); }); + +// ─── WebAuthn Virtual Authenticator ────────────────────────────── + +describe('BrowserManager.enableWebAuthn / disableWebAuthn', () => { + // Fake Page surface mirroring the pattern in cdp-session-cleanup.test.ts — + // enable/disableWebAuthn only touch page.context().newCDPSession(page) and + // the returned session's .send(), so this is enough to unit-test them + // without a real browser. + function makeFakePage() { + const sent: Array<{ method: string; params?: unknown }> = []; + let nextAuthenticatorId = 'auth-1'; + const session = { + send: async (method: string, params?: unknown) => { + sent.push({ method, params }); + if (method === 'WebAuthn.addVirtualAuthenticator') { + return { authenticatorId: nextAuthenticatorId }; + } + return {}; + }, + }; + const page = { + context: () => ({ + newCDPSession: async (_p: unknown) => session, + }), + once: () => {}, + }; + return { page: page as unknown as import('playwright').Page, sent }; + } + + it('enableWebAuthn adds a virtual authenticator and reports alreadyEnabled=false the first time', async () => { + const { BrowserManager } = await import('../src/browser-manager'); + const bm = new BrowserManager(); + const { page, sent } = makeFakePage(); + + const result = await bm.enableWebAuthn(page); + expect(result).toEqual({ alreadyEnabled: false }); + expect(sent.map((s) => s.method)).toEqual([ + 'WebAuthn.enable', + 'WebAuthn.addVirtualAuthenticator', + ]); + }); + + it('enableWebAuthn is idempotent — a second call reports alreadyEnabled=true and sends nothing new', async () => { + const { BrowserManager } = await import('../src/browser-manager'); + const bm = new BrowserManager(); + const { page, sent } = makeFakePage(); + + await bm.enableWebAuthn(page); + const second = await bm.enableWebAuthn(page); + expect(second).toEqual({ alreadyEnabled: true }); + expect(sent.length).toBe(2); // still just the first call's two sends + }); + + it('disableWebAuthn removes the authenticator when one is enabled', async () => { + const { BrowserManager } = await import('../src/browser-manager'); + const bm = new BrowserManager(); + const { page, sent } = makeFakePage(); + + await bm.enableWebAuthn(page); + const result = await bm.disableWebAuthn(page); + expect(result).toEqual({ wasEnabled: true }); + expect(sent.map((s) => s.method)).toEqual([ + 'WebAuthn.enable', + 'WebAuthn.addVirtualAuthenticator', + 'WebAuthn.removeVirtualAuthenticator', + 'WebAuthn.disable', + ]); + }); + + it('disableWebAuthn is a no-op when nothing is enabled', async () => { + const { BrowserManager } = await import('../src/browser-manager'); + const bm = new BrowserManager(); + const { page, sent } = makeFakePage(); + + const result = await bm.disableWebAuthn(page); + expect(result).toEqual({ wasEnabled: false }); + expect(sent.length).toBe(0); + }); + + it('enableWebAuthn after disableWebAuthn re-enables cleanly', async () => { + const { BrowserManager } = await import('../src/browser-manager'); + const bm = new BrowserManager(); + const { page } = makeFakePage(); + + await bm.enableWebAuthn(page); + await bm.disableWebAuthn(page); + const result = await bm.enableWebAuthn(page); + expect(result).toEqual({ alreadyEnabled: false }); + }); +}); diff --git a/browse/test/commands.test.ts b/browse/test/commands.test.ts index 9382cb27e..10ebb84c7 100644 --- a/browse/test/commands.test.ts +++ b/browse/test/commands.test.ts @@ -1068,6 +1068,64 @@ describe('Dialog handling', () => { }); }); +// ─── WebAuthn Virtual Authenticator ────────────────────────── + +describe('webauthn command', () => { + // WebAuthn rp.id must be a valid domain string, not an IP literal, so these + // tests navigate via `localhost` (which resolves to the same 127.0.0.1 test + // server) instead of the shared `baseUrl`. Computed lazily since `baseUrl` + // isn't assigned until the top-level beforeAll runs. + const webauthnUrl = (path: string) => baseUrl.replace('127.0.0.1', 'localhost') + path; + + test('navigator.credentials.create() fails without a virtual authenticator, succeeds once webauthn is on', async () => { + await handleWriteCommand('goto', [webauthnUrl('/webauthn.html')], bm); + + // Without a platform authenticator, headless Chromium rejects the + // ceremony outright (SecurityError) rather than resolving it. + await handleWriteCommand('click', ['#create-btn'], bm); + await new Promise((r) => setTimeout(r, 200)); + const withoutAuthenticator = await handleReadCommand('js', ['document.querySelector("#create-result").textContent'], bm); + expect(withoutAuthenticator).not.toContain('created:'); + + const onResult = await handleWriteCommand('webauthn', ['on'], bm); + expect(onResult).toContain('enabled'); + + await handleWriteCommand('goto', [webauthnUrl('/webauthn.html')], bm); + await handleWriteCommand('click', ['#create-btn'], bm); + await new Promise((r) => setTimeout(r, 200)); + const result = await handleReadCommand('js', ['document.querySelector("#create-result").textContent'], bm); + expect(result).toContain('created:'); + + const offResult = await handleWriteCommand('webauthn', ['off'], bm); + expect(offResult).toContain('removed'); + }); + + test('webauthn on is idempotent and off is a no-op when not enabled', async () => { + await handleWriteCommand('goto', [webauthnUrl('/webauthn.html')], bm); + + const first = await handleWriteCommand('webauthn', ['on'], bm); + expect(first).toContain('enabled'); + const second = await handleWriteCommand('webauthn', ['on'], bm); + expect(second).toContain('already enabled'); + + const off = await handleWriteCommand('webauthn', ['off'], bm); + expect(off).toContain('removed'); + const offAgain = await handleWriteCommand('webauthn', ['off'], bm); + expect(offAgain).toContain('not enabled'); + }); + + test('bare invocation defaults to on', async () => { + await handleWriteCommand('goto', [webauthnUrl('/webauthn.html')], bm); + const result = await handleWriteCommand('webauthn', [], bm); + expect(result).toContain('enabled'); + await handleWriteCommand('webauthn', ['off'], bm); + }); + + test('invalid subcommand throws a usage error', async () => { + await expect(handleWriteCommand('webauthn', ['sideways'], bm)).rejects.toThrow('Usage: browse webauthn [on|off]'); + }); +}); + // ─── Element State Checks (is) ───────────────────────────────── describe('Element state checks', () => { diff --git a/browse/test/fixtures/webauthn.html b/browse/test/fixtures/webauthn.html new file mode 100644 index 000000000..2e59987a2 --- /dev/null +++ b/browse/test/fixtures/webauthn.html @@ -0,0 +1,36 @@ + + + + + Test Page - WebAuthn + + +

WebAuthn Test

+ +

+ + + diff --git a/gstack/llms.txt b/gstack/llms.txt index efe522f90..f55186df3 100644 --- a/gstack/llms.txt +++ b/gstack/llms.txt @@ -111,6 +111,7 @@ Run with `browse [args]`. Full reference: `browse/SKILL.md`. - `useragent `: Set user agent - `viewport [] [--scale ]`: Set viewport size and optional deviceScaleFactor (1-3, for retina screenshots). - `wait `: Wait for element, network idle, or page load (timeout: 15s) +- `webauthn [on|off]`: Enable/disable a CDP virtual authenticator on the active tab so passkey/WebAuthn navigator.credentials calls resolve automatically instead of hanging. ### Meta - `chain (JSON via stdin)`: Run a sequence of commands from JSON on stdin. diff --git a/package.json b/package.json index 846438a60..78b98a86f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.60.1.0", + "version": "1.60.2.0", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module",