mirror of https://github.com/garrytan/gstack.git
Merge 9fa1e25390 into a3259400a3
This commit is contained in:
commit
c02201903e
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