This commit is contained in:
Hakim Raja 2026-07-18 16:48:22 +02:00 committed by GitHub
commit c02201903e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
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",