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.
This commit is contained in:
Hakim Raja 2026-07-18 16:40:31 +02:00
parent a3259400a3
commit 9fa1e25390
12 changed files with 287 additions and 3 deletions

View File

@ -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.**

View File

@ -1 +1 @@
1.60.1.0
1.60.2.0

View File

@ -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 <string>` | Set user agent |
| `viewport [<WxH>] [--scale <n>]` | Set viewport size and optional deviceScaleFactor (1-3, for retina screenshots). --scale requires a context rebuild. |
| `wait <sel|--networkidle|--load>` | 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 |

View File

@ -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:

View File

@ -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<Page, any> = new WeakMap();
private webauthnAuthenticators: WeakMap<Page, string> = 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);

View File

@ -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<string, { category: string; descriptio
'useragent': { category: 'Interaction', description: 'Set user agent', usage: 'useragent <string>' },
'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 <url|@ref> [path] [--base64] [--navigate]' },
'scrape': { category: 'Extraction', description: 'Bulk download all media from page. Writes manifest.json', usage: 'scrape <images|videos|media> [--selector sel] [--dir path] [--limit N]' },

View File

@ -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 <json-file>');

View File

@ -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 });
});
});

View File

@ -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', () => {

36
browse/test/fixtures/webauthn.html vendored Normal file
View File

@ -0,0 +1,36 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Test Page - WebAuthn</title>
</head>
<body>
<h1>WebAuthn Test</h1>
<button id="create-btn">Create Passkey</button>
<p id="create-result"></p>
<script>
document.getElementById('create-btn').addEventListener('click', async () => {
const resultEl = document.getElementById('create-result');
try {
const credential = await navigator.credentials.create({
publicKey: {
rp: { name: 'browse test', id: location.hostname },
user: {
id: Uint8Array.from('test-user', c => c.charCodeAt(0)),
name: 'test-user',
displayName: 'Test User',
},
challenge: Uint8Array.from('test-challenge', c => c.charCodeAt(0)),
pubKeyCredParams: [{ type: 'public-key', alg: -7 }],
authenticatorSelection: { userVerification: 'required' },
timeout: 5000,
},
});
resultEl.textContent = credential ? 'created:' + credential.id : 'created:no-credential';
} catch (err) {
resultEl.textContent = 'error:' + err.name;
}
});
</script>
</body>
</html>

View File

@ -111,6 +111,7 @@ Run with `browse <command> [args]`. Full reference: `browse/SKILL.md`.
- `useragent <string>`: Set user agent
- `viewport [<WxH>] [--scale <n>]`: Set viewport size and optional deviceScaleFactor (1-3, for retina screenshots).
- `wait <sel|--networkidle|--load>`: 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.

View File

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