Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.**
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.60.1.0
1.60.2.0
12 changes: 12 additions & 0 deletions browse/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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 |
Expand Down
11 changes: 11 additions & 0 deletions browse/SKILL.md.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
37 changes: 36 additions & 1 deletion browse/src/browser-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions browse/src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
]);
Expand Down Expand Up @@ -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]' },
Expand Down
17 changes: 17 additions & 0 deletions browse/src/write-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>');
Expand Down
90 changes: 90 additions & 0 deletions browse/test/browser-manager-unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
});
});
58 changes: 58 additions & 0 deletions browse/test/commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
36 changes: 36 additions & 0 deletions browse/test/fixtures/webauthn.html
Original file line number Diff line number Diff line change
@@ -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>
1 change: 1 addition & 0 deletions gstack/llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Loading