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",