mirror of https://github.com/garrytan/gstack.git
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:
parent
a3259400a3
commit
9fa1e25390
22
CHANGELOG.md
22
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.**
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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]' },
|
||||
|
|
|
|||
|
|
@ -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>');
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue