Files
gstack/browse/src/cookie-picker-ui.ts
T
Garry Tan f7b95329c1 feat: Phase 3.5 — cookie import, QA testing, team retro (v0.3.1) (#29)
* Phase 2: Enhanced browser — dialog handling, upload, state checks, snapshots

- CircularBuffer O(1) ring buffer for console/network/dialog (was O(n) array+shift)
- Async buffer flush with Bun.write() (was appendFileSync)
- Dialog auto-accept/dismiss with buffer + prompt text support
- File upload command (upload <sel> <file...>)
- Element state checks (is visible/hidden/enabled/disabled/checked/editable/focused)
- Annotated screenshots with ref labels overlaid (-a flag)
- Snapshot diffing against previous snapshot (-D flag)
- Cursor-interactive element scan for non-ARIA clickables (-C flag)
- Snapshot scoping depth limit (-d N flag)
- Health check with page.evaluate + 2s timeout
- Playwright error wrapping — actionable messages for AI agents
- Fix useragent — context recreation preserves cookies/storage/URLs
- wait --networkidle / --load / --domcontentloaded flags
- console --errors filter (error + warning only)
- cookie-import <json-file> with auto-fill domain from page URL
- 166 integration tests (was ~63)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Phase 2: Rewrite SKILL.md as QA playbook + command reference

Reorient SKILL.md files from raw command reference to QA-first playbook
with 10 workflow patterns (test user flows, verify deployments, dogfood
features, responsive layouts, file upload, forms, dialogs, compare pages).
Compact command reference tables at the bottom.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Phase 3: /qa skill — systematic QA testing with health scores

New /qa skill for systematic web app QA testing. Three modes:
- full: 5-10 documented issues with screenshots and repro steps
- quick: 30-second smoke test with health score
- regression: compare against saved baseline

Includes issue taxonomy (7 categories, 4 severity levels), structured
report template, health score rubric (weighted across 7 categories),
framework detection guidance (Next.js, Rails, WordPress, SPA).

Also adds browse/bin/find-browse (DRY binary discovery using git
rev-parse), .gstack/ to .gitignore, and updated TODO roadmap.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Bump to v0.3.0 — Phase 2 + Phase 3 changelog

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: cookie-import-browser — Chromium cookie decryption module + tests

Pure logic module for reading and decrypting cookies from macOS Chromium
browsers (Comet, Chrome, Arc, Brave, Edge). Supports v10 AES-128-CBC
encryption with macOS Keychain access, PBKDF2 key derivation, and
per-browser key caching. 18 unit tests with encrypted cookie fixtures.

* feat: cookie picker web UI + route handler

Two-panel dark-theme picker served from the browse server. Left panel
shows source browser domains with search and import buttons. Right panel
shows imported domains with trash buttons. No cookie values exposed.
6 API endpoints, importedDomains Set tracking, inline clearCookies.

* feat: wire cookie-import-browser into browse server

Add cookie-picker route dispatch (no auth, localhost-only), add
cookie-import-browser to WRITE_COMMANDS and CHAIN_WRITE, add serverPort
property to BrowserManager, add write command with two modes (picker UI
vs --domain direct import), update CLI help text.

* chore: /setup-browser-cookies skill + docs (Phase 3.5)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: bump version and changelog (v0.3.1)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* security: redact sensitive values from command output (PR #21)

type no longer echoes text (reports character count), cookie redacts
value with ****, header redacts Authorization/Cookie/X-API-Key/X-Auth-Token,
storage set drops value, forms redacts password fields. Prevents secrets
from persisting in LLM transcripts. 7 new tests.

Credit: fredluz (PR #21)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* security: path traversal prevention for screenshot/pdf/eval (PR #26)

Add validateOutputPath() for screenshot/pdf/responsive (restricts to
/tmp and cwd) and validateReadPath() for eval (blocks .. sequences and
absolute paths outside safe dirs). 7 new tests.

Credit: Jah-yee (PR #26)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: auto-install Playwright Chromium in setup (PR #22)

Setup now verifies Playwright can launch Chromium, and auto-installs
it via `bunx playwright install chromium` if missing. Exits non-zero
if build or Chromium launch fails.

Credit: AkbarDevop (PR #22)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* security: fix path validation bypass, CORS restriction, cookie-import path check

- startsWith('/tmp') matched '/tmpevil' — now requires trailing slash
- CORS Access-Control-Allow-Origin changed from * to http://127.0.0.1:<port>
- cookie-import now validates file paths (was missing validateReadPath)
- 3 new tests for prefix collision and cookie-import path traversal

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address review informational issues + add regression tests

- Add cookie-import to CHAIN_WRITE set for chain command routing
- Add path validation to snapshot -a -o output path
- Fix package.json version to match 0.3.1
- Use crypto.randomUUID() for temp DB paths (unpredictable filenames)
- Add regression tests for chain cookie-import and snapshot path validation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: add /qa, /setup-browser-cookies to README + update BROWSER.md

- Add /qa and /setup-browser-cookies to skills table, install/update/uninstall blurbs
- Add dedicated README sections for both new skills with usage examples
- Update demo workflow to show cookie import → QA → browse flow
- Update BROWSER.md: cookie import commands, new source files, test count (203)
- Update skill count from 6 to 8

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: team-aware /retro v2.0 — per-person praise and growth opportunities

- Identify current user via git config, orient narrative as "you" vs teammates
- Add per-author metrics: commits, LOC, focus areas, commit type mix, sessions
- New "Your Week" section with personal deep-dive for whoever runs the command
- New "Team Breakdown" with per-person praise and growth opportunities
- Track AI-assisted commits via Co-Authored-By trailers
- Personal + team shipping streaks
- Tone: praise like a 1:1, growth like investment advice, never compare negatively

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: add Conductor parallel sessions section to README

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 00:31:41 -07:00

542 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Cookie picker UI — self-contained HTML page
*
* Dark theme, two-panel layout, vanilla HTML/CSS/JS.
* Left: source browser domains with search + import buttons.
* Right: imported domains with trash buttons.
* No cookie values exposed anywhere.
*/
export function getCookiePickerHTML(serverPort: number): string {
const baseUrl = `http://127.0.0.1:${serverPort}`;
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Cookie Import — gstack browse</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
background: #0a0a0a;
color: #e0e0e0;
height: 100vh;
overflow: hidden;
}
/* ─── Header ──────────────────────────── */
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
border-bottom: 1px solid #222;
background: #0f0f0f;
}
.header h1 {
font-size: 16px;
font-weight: 600;
color: #fff;
}
.header .port {
font-size: 12px;
color: #666;
font-family: 'SF Mono', 'Fira Code', monospace;
}
/* ─── Layout ──────────────────────────── */
.container {
display: flex;
height: calc(100vh - 53px);
}
.panel {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.panel-left {
border-right: 1px solid #222;
}
.panel-header {
padding: 16px 20px 12px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #888;
}
/* ─── Browser Pills ───────────────────── */
.browser-pills {
display: flex;
gap: 8px;
padding: 0 20px 12px;
flex-wrap: wrap;
}
.pill {
padding: 6px 14px;
border-radius: 20px;
border: 1px solid #333;
background: #1a1a1a;
color: #aaa;
font-size: 13px;
cursor: pointer;
transition: all 0.15s;
display: flex;
align-items: center;
gap: 6px;
}
.pill:hover { border-color: #555; color: #ddd; }
.pill.active {
border-color: #4ade80;
background: #0a2a14;
color: #4ade80;
}
.pill .dot {
width: 6px; height: 6px;
border-radius: 50%;
background: #4ade80;
}
/* ─── Search ──────────────────────────── */
.search-wrap {
padding: 0 20px 12px;
}
.search-input {
width: 100%;
padding: 8px 12px;
border-radius: 8px;
border: 1px solid #333;
background: #141414;
color: #e0e0e0;
font-size: 13px;
outline: none;
transition: border-color 0.15s;
}
.search-input::placeholder { color: #555; }
.search-input:focus { border-color: #555; }
/* ─── Domain List ─────────────────────── */
.domain-list {
flex: 1;
overflow-y: auto;
padding: 0 12px;
}
.domain-list::-webkit-scrollbar { width: 6px; }
.domain-list::-webkit-scrollbar-track { background: transparent; }
.domain-list::-webkit-scrollbar-thumb { background: #333; border-radius: 3px; }
.domain-row {
display: flex;
align-items: center;
padding: 8px 10px;
border-radius: 6px;
transition: background 0.1s;
gap: 8px;
}
.domain-row:hover { background: #1a1a1a; }
.domain-name {
flex: 1;
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 13px;
color: #ccc;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.domain-count {
font-size: 12px;
color: #666;
font-family: 'SF Mono', 'Fira Code', monospace;
min-width: 28px;
text-align: right;
}
.btn-add, .btn-trash {
width: 28px; height: 28px;
border-radius: 6px;
border: 1px solid #333;
background: #1a1a1a;
color: #888;
font-size: 16px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
flex-shrink: 0;
}
.btn-add:hover { border-color: #4ade80; color: #4ade80; background: #0a2a14; }
.btn-trash:hover { border-color: #f87171; color: #f87171; background: #2a0a0a; }
.btn-add:disabled, .btn-trash:disabled {
opacity: 0.3;
cursor: not-allowed;
pointer-events: none;
}
.btn-add.imported {
border-color: #333;
color: #4ade80;
background: transparent;
cursor: default;
font-size: 14px;
}
/* ─── Footer ──────────────────────────── */
.panel-footer {
padding: 12px 20px;
border-top: 1px solid #222;
font-size: 12px;
color: #666;
}
/* ─── Imported Panel ──────────────────── */
.imported-empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: #444;
font-size: 13px;
padding: 20px;
text-align: center;
}
/* ─── Banner ──────────────────────────── */
.banner {
padding: 10px 20px;
font-size: 13px;
display: none;
align-items: center;
gap: 10px;
}
.banner.error {
background: #1a0a0a;
border-bottom: 1px solid #3a1111;
color: #f87171;
}
.banner.info {
background: #0a1a2a;
border-bottom: 1px solid #112233;
color: #60a5fa;
}
.banner .banner-text { flex: 1; }
.banner .banner-close, .banner .banner-retry {
background: none;
border: 1px solid currentColor;
color: inherit;
padding: 3px 10px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
}
/* ─── Spinner ─────────────────────────── */
.spinner {
display: inline-block;
width: 14px; height: 14px;
border: 2px solid #333;
border-top-color: #4ade80;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.loading-row {
display: flex;
align-items: center;
justify-content: center;
padding: 40px;
gap: 10px;
color: #666;
font-size: 13px;
}
</style>
</head>
<body>
<div class="header">
<h1>Cookie Import</h1>
<span class="port">localhost:${serverPort}</span>
</div>
<div id="banner" class="banner"></div>
<div class="container">
<!-- Left Panel: Source Browser -->
<div class="panel panel-left">
<div class="panel-header">Source Browser</div>
<div id="browser-pills" class="browser-pills"></div>
<div class="search-wrap">
<input type="text" class="search-input" id="search" placeholder="Search domains..." />
</div>
<div class="domain-list" id="source-domains">
<div class="loading-row"><span class="spinner"></span> Detecting browsers...</div>
</div>
<div class="panel-footer" id="source-footer"></div>
</div>
<!-- Right Panel: Imported -->
<div class="panel panel-right">
<div class="panel-header">Imported to Session</div>
<div class="domain-list" id="imported-domains">
<div class="imported-empty">No cookies imported yet</div>
</div>
<div class="panel-footer" id="imported-footer"></div>
</div>
</div>
<script>
(function() {
const BASE = '${baseUrl}';
let activeBrowser = null;
let allDomains = [];
let importedSet = {}; // domain → count
let inflight = {}; // domain → true (prevents double-click)
const $pills = document.getElementById('browser-pills');
const $search = document.getElementById('search');
const $sourceDomains = document.getElementById('source-domains');
const $importedDomains = document.getElementById('imported-domains');
const $sourceFooter = document.getElementById('source-footer');
const $importedFooter = document.getElementById('imported-footer');
const $banner = document.getElementById('banner');
// ─── Banner ────────────────────────────
function showBanner(msg, type, retryFn) {
$banner.className = 'banner ' + type;
$banner.style.display = 'flex';
let html = '<span class="banner-text">' + escHtml(msg) + '</span>';
if (retryFn) {
html += '<button class="banner-retry" id="banner-retry">Retry</button>';
}
html += '<button class="banner-close" id="banner-close">×</button>';
$banner.innerHTML = html;
document.getElementById('banner-close').onclick = () => { $banner.style.display = 'none'; };
if (retryFn) {
document.getElementById('banner-retry').onclick = () => {
$banner.style.display = 'none';
retryFn();
};
}
}
function escHtml(s) {
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
// ─── API ────────────────────────────────
async function api(path, opts) {
const res = await fetch(BASE + '/cookie-picker' + path, opts);
const data = await res.json();
if (!res.ok) {
const err = new Error(data.error || 'Request failed');
err.code = data.code;
err.action = data.action;
throw err;
}
return data;
}
// ─── Init ───────────────────────────────
async function init() {
try {
const [browserData, importedData] = await Promise.all([
api('/browsers'),
api('/imported'),
]);
// Populate imported state
for (const entry of importedData.domains) {
importedSet[entry.domain] = entry.count;
}
renderImported();
// Render browser pills
const browsers = browserData.browsers;
if (browsers.length === 0) {
$sourceDomains.innerHTML = '<div class="imported-empty">No Chromium browsers detected</div>';
return;
}
$pills.innerHTML = '';
browsers.forEach(b => {
const pill = document.createElement('button');
pill.className = 'pill';
pill.innerHTML = '<span class="dot"></span>' + escHtml(b.name);
pill.onclick = () => selectBrowser(b.name);
$pills.appendChild(pill);
});
// Auto-select first browser
selectBrowser(browsers[0].name);
} catch (err) {
showBanner(err.message, 'error', init);
$sourceDomains.innerHTML = '<div class="imported-empty">Failed to load</div>';
}
}
// ─── Select Browser ────────────────────
async function selectBrowser(name) {
activeBrowser = name;
// Update pills
$pills.querySelectorAll('.pill').forEach(p => {
p.classList.toggle('active', p.textContent === name);
});
$sourceDomains.innerHTML = '<div class="loading-row"><span class="spinner"></span> Loading domains...</div>';
$sourceFooter.textContent = '';
$search.value = '';
try {
const data = await api('/domains?browser=' + encodeURIComponent(name));
allDomains = data.domains;
renderSourceDomains();
} catch (err) {
showBanner(err.message, 'error', err.action === 'retry' ? () => selectBrowser(name) : null);
$sourceDomains.innerHTML = '<div class="imported-empty">Failed to load domains</div>';
}
}
// ─── Render Source Domains ─────────────
function renderSourceDomains() {
const query = $search.value.toLowerCase();
const filtered = query
? allDomains.filter(d => d.domain.toLowerCase().includes(query))
: allDomains;
if (filtered.length === 0) {
$sourceDomains.innerHTML = '<div class="imported-empty">' +
(query ? 'No matching domains' : 'No cookie domains found') + '</div>';
$sourceFooter.textContent = '';
return;
}
let html = '';
for (const d of filtered) {
const isImported = d.domain in importedSet;
const isInflight = inflight[d.domain];
html += '<div class="domain-row">';
html += '<span class="domain-name">' + escHtml(d.domain) + '</span>';
html += '<span class="domain-count">' + d.count + '</span>';
if (isInflight) {
html += '<span class="btn-add" disabled><span class="spinner" style="width:12px;height:12px;border-width:1.5px;"></span></span>';
} else if (isImported) {
html += '<span class="btn-add imported">&#10003;</span>';
} else {
html += '<button class="btn-add" data-domain="' + escHtml(d.domain) + '" title="Import">+</button>';
}
html += '</div>';
}
$sourceDomains.innerHTML = html;
// Total counts
const totalDomains = allDomains.length;
const totalCookies = allDomains.reduce((s, d) => s + d.count, 0);
$sourceFooter.textContent = totalDomains + ' domains · ' + totalCookies.toLocaleString() + ' cookies';
// Click handlers
$sourceDomains.querySelectorAll('.btn-add[data-domain]').forEach(btn => {
btn.addEventListener('click', () => importDomain(btn.dataset.domain));
});
}
// ─── Import Domain ─────────────────────
async function importDomain(domain) {
if (inflight[domain] || domain in importedSet) return;
inflight[domain] = true;
renderSourceDomains();
try {
const data = await api('/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ browser: activeBrowser, domains: [domain] }),
});
if (data.domainCounts) {
for (const [d, count] of Object.entries(data.domainCounts)) {
importedSet[d] = (importedSet[d] || 0) + count;
}
}
renderImported();
} catch (err) {
showBanner('Import failed for ' + domain + ': ' + err.message, 'error',
err.action === 'retry' ? () => importDomain(domain) : null);
} finally {
delete inflight[domain];
renderSourceDomains();
}
}
// ─── Render Imported ───────────────────
function renderImported() {
const entries = Object.entries(importedSet).sort((a, b) => b[1] - a[1]);
if (entries.length === 0) {
$importedDomains.innerHTML = '<div class="imported-empty">No cookies imported yet</div>';
$importedFooter.textContent = '';
return;
}
let html = '';
for (const [domain, count] of entries) {
const isInflight = inflight['remove:' + domain];
html += '<div class="domain-row">';
html += '<span class="domain-name">' + escHtml(domain) + '</span>';
html += '<span class="domain-count">' + count + '</span>';
if (isInflight) {
html += '<span class="btn-trash" disabled><span class="spinner" style="width:12px;height:12px;border-width:1.5px;border-top-color:#f87171;"></span></span>';
} else {
html += '<button class="btn-trash" data-domain="' + escHtml(domain) + '" title="Remove">&#128465;</button>';
}
html += '</div>';
}
$importedDomains.innerHTML = html;
const totalCookies = entries.reduce((s, e) => s + e[1], 0);
$importedFooter.textContent = entries.length + ' domains · ' + totalCookies.toLocaleString() + ' cookies imported';
// Click handlers
$importedDomains.querySelectorAll('.btn-trash[data-domain]').forEach(btn => {
btn.addEventListener('click', () => removeDomain(btn.dataset.domain));
});
}
// ─── Remove Domain ─────────────────────
async function removeDomain(domain) {
if (inflight['remove:' + domain]) return;
inflight['remove:' + domain] = true;
renderImported();
try {
await api('/remove', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ domains: [domain] }),
});
delete importedSet[domain];
renderImported();
renderSourceDomains(); // update checkmarks
} catch (err) {
showBanner('Remove failed for ' + domain + ': ' + err.message, 'error',
err.action === 'retry' ? () => removeDomain(domain) : null);
} finally {
delete inflight['remove:' + domain];
renderImported();
}
}
// ─── Search ────────────────────────────
$search.addEventListener('input', renderSourceDomains);
// ─── Start ─────────────────────────────
init();
})();
</script>
</body>
</html>`;
}