Merge remote-tracking branch 'origin/main' into garrytan/elegance

# Conflicts:
#	package.json
#	scripts/gen-skill-docs.ts
This commit is contained in:
Garry Tan
2026-03-23 22:17:12 -07:00
70 changed files with 1000 additions and 200 deletions

View File

@@ -3,6 +3,7 @@ name: browse
preamble-tier: 1
version: 1.1.0
description: |
MANUAL TRIGGER ONLY: invoke only when user types /browse.
Fast headless browser for QA testing and site dogfooding. Navigate any URL, interact with
elements, verify page state, diff before/after actions, take annotated screenshots, check
responsive layouts, test forms and uploads, handle dialogs, and assert element states.
@@ -401,7 +402,7 @@ Refs are invalidated on navigation — run `snapshot` again after `goto`.
| `click <sel>` | Click element |
| `cookie <name>=<value>` | Set cookie on current page domain |
| `cookie-import <json>` | Import cookies from JSON file |
| `cookie-import-browser [browser] [--domain d]` | Import cookies from Comet, Chrome, Arc, Brave, or Edge (opens picker, or use --domain for direct import) |
| `cookie-import-browser [browser] [--domain d]` | Import cookies from installed Chromium browsers (opens picker, or use --domain for direct import) |
| `dialog-accept [text]` | Auto-accept next alert/confirm/prompt. Optional text is sent as the prompt response |
| `dialog-dismiss` | Auto-dismiss next dialog |
| `fill <sel> <val>` | Fill input |

BIN
browse/dist/browse vendored Executable file

Binary file not shown.

BIN
browse/dist/find-browse vendored Executable file

Binary file not shown.

View File

@@ -62,7 +62,35 @@ export class BrowserManager {
private consecutiveFailures: number = 0;
async launch() {
this.browser = await chromium.launch({ headless: true });
// ─── Extension Support ────────────────────────────────────
// BROWSE_EXTENSIONS_DIR points to an unpacked Chrome extension directory.
// Extensions only work in headed mode, so we use an off-screen window.
const extensionsDir = process.env.BROWSE_EXTENSIONS_DIR;
const launchArgs: string[] = [];
let useHeadless = true;
// Docker/CI: Chromium sandbox requires unprivileged user namespaces which
// are typically disabled in containers. Detect container environment and
// add --no-sandbox automatically.
if (process.env.CI || process.env.CONTAINER) {
launchArgs.push('--no-sandbox');
}
if (extensionsDir) {
launchArgs.push(
`--disable-extensions-except=${extensionsDir}`,
`--load-extension=${extensionsDir}`,
'--window-position=-9999,-9999',
'--window-size=1,1',
);
useHeadless = false; // extensions require headed mode; off-screen window simulates headless
console.log(`[browse] Extensions loaded from: ${extensionsDir}`);
}
this.browser = await chromium.launch({
headless: useHeadless,
...(launchArgs.length > 0 ? { args: launchArgs } : {}),
});
// Chromium crash → exit with clear message
this.browser.on('disconnected', () => {

View File

@@ -15,7 +15,7 @@ import { resolveConfig, ensureStateDir, readVersionHash } from './config';
const config = resolveConfig();
const IS_WINDOWS = process.platform === 'win32';
const MAX_START_WAIT = IS_WINDOWS ? 15000 : 8000; // Node+Chromium takes longer on Windows
const MAX_START_WAIT = IS_WINDOWS ? 15000 : (process.env.CI ? 30000 : 8000); // Node+Chromium takes longer on Windows
export function resolveServerScript(
env: Record<string, string | undefined> = process.env,
@@ -262,6 +262,9 @@ async function ensureServer(): Promise<ServerState> {
}
}
// Ensure state directory exists before lock acquisition (lock file lives there)
ensureStateDir(config);
// Acquire lock to prevent concurrent restart races (TOCTOU)
const releaseLock = acquireServerLock();
if (!releaseLock) {

View File

@@ -73,7 +73,7 @@ export const COMMAND_DESCRIPTIONS: Record<string, { category: string; descriptio
'viewport':{ category: 'Interaction', description: 'Set viewport size', usage: 'viewport <WxH>' },
'cookie': { category: 'Interaction', description: 'Set cookie on current page domain', usage: 'cookie <name>=<value>' },
'cookie-import': { category: 'Interaction', description: 'Import cookies from JSON file', usage: 'cookie-import <json>' },
'cookie-import-browser': { category: 'Interaction', description: 'Import cookies from Comet, Chrome, Arc, Brave, or Edge (opens picker, or use --domain for direct import)', usage: 'cookie-import-browser [browser] [--domain d]' },
'cookie-import-browser': { category: 'Interaction', description: 'Import cookies from installed Chromium browsers (opens picker, or use --domain for direct import)', usage: 'cookie-import-browser [browser] [--domain d]' },
'header': { category: 'Interaction', description: 'Set custom request header (colon-separated, sensitive values auto-redacted)', usage: 'header <name>:<value>' },
'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]' },

View File

@@ -1,25 +1,28 @@
/**
* Chromium browser cookie import — read and decrypt cookies from real browsers
*
* Supports macOS Chromium-based browsers: Comet, Chrome, Arc, Brave, Edge.
* Supports macOS and Linux Chromium-based browsers.
* Pure logic module — no Playwright dependency, no HTTP concerns.
*
* Decryption pipeline (Chromium macOS "v10" format):
* Decryption pipeline:
*
* ┌──────────────────────────────────────────────────────────────────┐
* │ 1. Keychain: `security find-generic-password -s "<svc>" -w`
* │ → base64 password string
* │ 1. Resolve the cookie DB from the browser profile dir
* │ - macOS: ~/Library/Application Support/<browser>/<profile>
* │ - Linux: ~/.config/<browser>/<profile> │
* │ │
* │ 2. Key derivation:
* │ PBKDF2(password, salt="saltysalt", iter=1003, len=16, sha1)
* │ → 16-byte AES key
* │ 2. Derive the AES key
* │ - macOS v10: Keychain password, PBKDF2(..., iter=1003)
* │ - Linux v10: "peanuts", PBKDF2(..., iter=1)
* │ - Linux v11: libsecret/secret-tool password, iter=1 │
* │ │
* │ 3. For each cookie with encrypted_value starting with "v10":
* │ 3. For each cookie with encrypted_value starting with "v10"/
* │ "v11": │
* │ - Ciphertext = encrypted_value[3:] │
* │ - IV = 16 bytes of 0x20 (space character) │
* │ - Plaintext = AES-128-CBC-decrypt(key, iv, ciphertext) │
* │ - Remove PKCS7 padding │
* │ - Skip first 32 bytes (HMAC-SHA256 authentication tag)
* │ - Skip first 32 bytes of Chromium cookie metadata
* │ - Remaining bytes = cookie value (UTF-8) │
* │ │
* │ 4. If encrypted_value is empty but `value` field is set, │
@@ -42,9 +45,16 @@ import * as os from 'os';
export interface BrowserInfo {
name: string;
dataDir: string; // relative to ~/Library/Application Support/
dataDir: string; // primary storage dir (retained for compatibility with existing callers/tests)
keychainService: string;
aliases: string[];
linuxDataDir?: string;
linuxApplication?: string;
}
export interface ProfileEntry {
name: string; // e.g. "Default", "Profile 1", "Profile 3"
displayName: string; // human-friendly name from Preferences, or falls back to dir name
}
export interface DomainEntry {
@@ -81,15 +91,24 @@ export class CookieImportError extends Error {
}
}
type BrowserPlatform = 'darwin' | 'linux';
interface BrowserMatch {
browser: BrowserInfo;
platform: BrowserPlatform;
dbPath: string;
}
// ─── Browser Registry ───────────────────────────────────────────
// Hardcoded — NEVER interpolate user input into shell commands.
const BROWSER_REGISTRY: BrowserInfo[] = [
{ name: 'Comet', dataDir: 'Comet/', keychainService: 'Comet Safe Storage', aliases: ['comet', 'perplexity'] },
{ name: 'Chrome', dataDir: 'Google/Chrome/', keychainService: 'Chrome Safe Storage', aliases: ['chrome', 'google-chrome'] },
{ name: 'Arc', dataDir: 'Arc/User Data/', keychainService: 'Arc Safe Storage', aliases: ['arc'] },
{ name: 'Brave', dataDir: 'BraveSoftware/Brave-Browser/', keychainService: 'Brave Safe Storage', aliases: ['brave'] },
{ name: 'Edge', dataDir: 'Microsoft Edge/', keychainService: 'Microsoft Edge Safe Storage', aliases: ['edge'] },
{ name: 'Comet', dataDir: 'Comet/', keychainService: 'Comet Safe Storage', aliases: ['comet', 'perplexity'] },
{ name: 'Chrome', dataDir: 'Google/Chrome/', keychainService: 'Chrome Safe Storage', aliases: ['chrome', 'google-chrome', 'google-chrome-stable'], linuxDataDir: 'google-chrome/', linuxApplication: 'chrome' },
{ name: 'Chromium', dataDir: 'chromium/', keychainService: 'Chromium Safe Storage', aliases: ['chromium'], linuxDataDir: 'chromium/', linuxApplication: 'chromium' },
{ name: 'Arc', dataDir: 'Arc/User Data/', keychainService: 'Arc Safe Storage', aliases: ['arc'] },
{ name: 'Brave', dataDir: 'BraveSoftware/Brave-Browser/', keychainService: 'Brave Safe Storage', aliases: ['brave'], linuxDataDir: 'BraveSoftware/Brave-Browser/', linuxApplication: 'brave' },
{ name: 'Edge', dataDir: 'Microsoft Edge/', keychainService: 'Microsoft Edge Safe Storage', aliases: ['edge'], linuxDataDir: 'microsoft-edge/', linuxApplication: 'microsoft-edge' },
];
// ─── Key Cache ──────────────────────────────────────────────────
@@ -101,23 +120,105 @@ const keyCache = new Map<string, Buffer>();
// ─── Public API ─────────────────────────────────────────────────
/**
* Find which browsers are installed (have a cookie DB on disk).
* Find which browsers are installed (have a cookie DB on disk in any profile).
*/
export function findInstalledBrowsers(): BrowserInfo[] {
const appSupport = path.join(os.homedir(), 'Library', 'Application Support');
return BROWSER_REGISTRY.filter(b => {
const dbPath = path.join(appSupport, b.dataDir, 'Default', 'Cookies');
try { return fs.existsSync(dbPath); } catch { return false; }
return BROWSER_REGISTRY.filter(browser => {
// Check Default profile on any platform
if (findBrowserMatch(browser, 'Default') !== null) return true;
// Check numbered profiles (Profile 1, Profile 2, etc.)
for (const platform of getSearchPlatforms()) {
const dataDir = getDataDirForPlatform(browser, platform);
if (!dataDir) continue;
const browserDir = path.join(getBaseDir(platform), dataDir);
try {
const entries = fs.readdirSync(browserDir, { withFileTypes: true });
if (entries.some(e =>
e.isDirectory() && e.name.startsWith('Profile ') &&
fs.existsSync(path.join(browserDir, e.name, 'Cookies'))
)) return true;
} catch {}
}
return false;
});
}
export function listSupportedBrowserNames(): string[] {
const hostPlatform = getHostPlatform();
return BROWSER_REGISTRY
.filter(browser => hostPlatform ? getDataDirForPlatform(browser, hostPlatform) !== null : true)
.map(browser => browser.name);
}
/**
* List available profiles for a browser.
*/
export function listProfiles(browserName: string): ProfileEntry[] {
const browser = resolveBrowser(browserName);
const profiles: ProfileEntry[] = [];
// Scan each supported platform for profile directories
for (const platform of getSearchPlatforms()) {
const dataDir = getDataDirForPlatform(browser, platform);
if (!dataDir) continue;
const browserDir = path.join(getBaseDir(platform), dataDir);
if (!fs.existsSync(browserDir)) continue;
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(browserDir, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (entry.name !== 'Default' && !entry.name.startsWith('Profile ')) continue;
const cookiePath = path.join(browserDir, entry.name, 'Cookies');
if (!fs.existsSync(cookiePath)) continue;
// Avoid duplicates if the same profile appears on multiple platforms
if (profiles.some(p => p.name === entry.name)) continue;
// Try to read display name from Preferences.
// Prefer account email — signed-in Chrome profiles often have generic
// names like "Person 2" while the email is far more readable.
let displayName = entry.name;
try {
const prefsPath = path.join(browserDir, entry.name, 'Preferences');
if (fs.existsSync(prefsPath)) {
const prefs = JSON.parse(fs.readFileSync(prefsPath, 'utf-8'));
const email = prefs?.account_info?.[0]?.email;
if (email && typeof email === 'string') {
displayName = email;
} else {
const profileName = prefs?.profile?.name;
if (profileName && typeof profileName === 'string') {
displayName = profileName;
}
}
}
} catch {
// Ignore — fall back to directory name
}
profiles.push({ name: entry.name, displayName });
}
// Found profiles on this platform — no need to check others
if (profiles.length > 0) break;
}
return profiles;
}
/**
* List unique cookie domains + counts from a browser's DB. No decryption.
*/
export function listDomains(browserName: string, profile = 'Default'): { domains: DomainEntry[]; browser: string } {
const browser = resolveBrowser(browserName);
const dbPath = getCookieDbPath(browser, profile);
const db = openDb(dbPath, browser.name);
const match = getBrowserMatch(browser, profile);
const db = openDb(match.dbPath, browser.name);
try {
const now = chromiumNow();
const rows = db.query(
@@ -144,9 +245,9 @@ export async function importCookies(
if (domains.length === 0) return { cookies: [], count: 0, failed: 0, domainCounts: {} };
const browser = resolveBrowser(browserName);
const derivedKey = await getDerivedKey(browser);
const dbPath = getCookieDbPath(browser, profile);
const db = openDb(dbPath, browser.name);
const match = getBrowserMatch(browser, profile);
const derivedKeys = await getDerivedKeys(match);
const db = openDb(match.dbPath, browser.name);
try {
const now = chromiumNow();
@@ -167,7 +268,7 @@ export async function importCookies(
for (const row of rows) {
try {
const value = decryptCookieValue(row, derivedKey);
const value = decryptCookieValue(row, derivedKeys);
const cookie = toPlaywrightCookie(row, value);
cookies.push(cookie);
domainCounts[row.host_key] = (domainCounts[row.host_key] || 0) + 1;
@@ -208,17 +309,61 @@ function validateProfile(profile: string): void {
}
}
function getCookieDbPath(browser: BrowserInfo, profile: string): string {
validateProfile(profile);
const appSupport = path.join(os.homedir(), 'Library', 'Application Support');
const dbPath = path.join(appSupport, browser.dataDir, profile, 'Cookies');
if (!fs.existsSync(dbPath)) {
throw new CookieImportError(
`${browser.name} is not installed (no cookie database at ${dbPath})`,
'not_installed',
);
function getHostPlatform(): BrowserPlatform | null {
if (process.platform === 'darwin' || process.platform === 'linux') return process.platform;
return null;
}
function getSearchPlatforms(): BrowserPlatform[] {
const current = getHostPlatform();
const order: BrowserPlatform[] = [];
if (current) order.push(current);
for (const platform of ['darwin', 'linux'] as BrowserPlatform[]) {
if (!order.includes(platform)) order.push(platform);
}
return dbPath;
return order;
}
function getDataDirForPlatform(browser: BrowserInfo, platform: BrowserPlatform): string | null {
return platform === 'darwin' ? browser.dataDir : browser.linuxDataDir || null;
}
function getBaseDir(platform: BrowserPlatform): string {
return platform === 'darwin'
? path.join(os.homedir(), 'Library', 'Application Support')
: path.join(os.homedir(), '.config');
}
function findBrowserMatch(browser: BrowserInfo, profile: string): BrowserMatch | null {
validateProfile(profile);
for (const platform of getSearchPlatforms()) {
const dataDir = getDataDirForPlatform(browser, platform);
if (!dataDir) continue;
const dbPath = path.join(getBaseDir(platform), dataDir, profile, 'Cookies');
try {
if (fs.existsSync(dbPath)) {
return { browser, platform, dbPath };
}
} catch {}
}
return null;
}
function getBrowserMatch(browser: BrowserInfo, profile: string): BrowserMatch {
const match = findBrowserMatch(browser, profile);
if (match) return match;
const attempted = getSearchPlatforms()
.map(platform => {
const dataDir = getDataDirForPlatform(browser, platform);
return dataDir ? path.join(getBaseDir(platform), dataDir, profile, 'Cookies') : null;
})
.filter((entry): entry is string => entry !== null);
throw new CookieImportError(
`${browser.name} is not installed (no cookie database at ${attempted.join(' or ')})`,
'not_installed',
);
}
// ─── Internal: SQLite Access ────────────────────────────────────
@@ -273,17 +418,40 @@ function openDbFromCopy(dbPath: string, browserName: string): Database {
// ─── Internal: Keychain Access (async, 10s timeout) ─────────────
async function getDerivedKey(browser: BrowserInfo): Promise<Buffer> {
const cached = keyCache.get(browser.keychainService);
if (cached) return cached;
function deriveKey(password: string, iterations: number): Buffer {
return crypto.pbkdf2Sync(password, 'saltysalt', iterations, 16, 'sha1');
}
const password = await getKeychainPassword(browser.keychainService);
const derived = crypto.pbkdf2Sync(password, 'saltysalt', 1003, 16, 'sha1');
keyCache.set(browser.keychainService, derived);
function getCachedDerivedKey(cacheKey: string, password: string, iterations: number): Buffer {
const cached = keyCache.get(cacheKey);
if (cached) return cached;
const derived = deriveKey(password, iterations);
keyCache.set(cacheKey, derived);
return derived;
}
async function getKeychainPassword(service: string): Promise<string> {
async function getDerivedKeys(match: BrowserMatch): Promise<Map<string, Buffer>> {
if (match.platform === 'darwin') {
const password = await getMacKeychainPassword(match.browser.keychainService);
return new Map([
['v10', getCachedDerivedKey(`darwin:${match.browser.keychainService}:v10`, password, 1003)],
]);
}
const keys = new Map<string, Buffer>();
keys.set('v10', getCachedDerivedKey('linux:v10', 'peanuts', 1));
const linuxPassword = await getLinuxSecretPassword(match.browser);
if (linuxPassword) {
keys.set(
'v11',
getCachedDerivedKey(`linux:${match.browser.keychainService}:v11`, linuxPassword, 1),
);
}
return keys;
}
async function getMacKeychainPassword(service: string): Promise<string> {
// Use async Bun.spawn with timeout to avoid blocking the event loop.
// macOS may show an Allow/Deny dialog that blocks until the user responds.
const proc = Bun.spawn(
@@ -341,6 +509,47 @@ async function getKeychainPassword(service: string): Promise<string> {
}
}
async function getLinuxSecretPassword(browser: BrowserInfo): Promise<string | null> {
const attempts: string[][] = [
['secret-tool', 'lookup', 'Title', browser.keychainService],
];
if (browser.linuxApplication) {
attempts.push(
['secret-tool', 'lookup', 'xdg:schema', 'chrome_libsecret_os_crypt_password_v2', 'application', browser.linuxApplication],
['secret-tool', 'lookup', 'xdg:schema', 'chrome_libsecret_os_crypt_password', 'application', browser.linuxApplication],
);
}
for (const cmd of attempts) {
const password = await runPasswordLookup(cmd, 3_000);
if (password) return password;
}
return null;
}
async function runPasswordLookup(cmd: string[], timeoutMs: number): Promise<string | null> {
try {
const proc = Bun.spawn(cmd, { stdout: 'pipe', stderr: 'pipe' });
const timeout = new Promise<never>((_, reject) =>
setTimeout(() => {
proc.kill();
reject(new Error('timeout'));
}, timeoutMs),
);
const exitCode = await Promise.race([proc.exited, timeout]);
const stdout = await new Response(proc.stdout).text();
if (exitCode !== 0) return null;
const password = stdout.trim();
return password.length > 0 ? password : null;
} catch {
return null;
}
}
// ─── Internal: Cookie Decryption ────────────────────────────────
interface RawCookie {
@@ -356,7 +565,7 @@ interface RawCookie {
samesite: number;
}
function decryptCookieValue(row: RawCookie, key: Buffer): string {
function decryptCookieValue(row: RawCookie, keys: Map<string, Buffer>): string {
// Prefer unencrypted value if present
if (row.value && row.value.length > 0) return row.value;
@@ -364,16 +573,15 @@ function decryptCookieValue(row: RawCookie, key: Buffer): string {
if (ev.length === 0) return '';
const prefix = ev.slice(0, 3).toString('utf-8');
if (prefix !== 'v10') {
throw new Error(`Unknown encryption prefix: ${prefix}`);
}
const key = keys.get(prefix);
if (!key) throw new Error(`No decryption key available for ${prefix} cookies`);
const ciphertext = ev.slice(3);
const iv = Buffer.alloc(16, 0x20); // 16 space characters
const decipher = crypto.createDecipheriv('aes-128-cbc', key, iv);
const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
// First 32 bytes are HMAC-SHA256 authentication tag; actual value follows
// Chromium prefixes encrypted cookie payloads with 32 bytes of metadata.
if (plaintext.length <= 32) return '';
return plaintext.slice(32).toString('utf-8');
}

View File

@@ -14,7 +14,7 @@
*/
import type { BrowserManager } from './browser-manager';
import { findInstalledBrowsers, listDomains, importCookies, CookieImportError, type PlaywrightCookie } from './cookie-import-browser';
import { findInstalledBrowsers, listProfiles, listDomains, importCookies, CookieImportError, type PlaywrightCookie } from './cookie-import-browser';
import { getCookiePickerHTML } from './cookie-picker-ui';
// ─── State ──────────────────────────────────────────────────────
@@ -90,13 +90,24 @@ export async function handleCookiePickerRoute(
}, { port });
}
// GET /cookie-picker/domains?browser=<name> — list domains + counts
// GET /cookie-picker/profiles?browser=<name> — list profiles for a browser
if (pathname === '/cookie-picker/profiles' && req.method === 'GET') {
const browserName = url.searchParams.get('browser');
if (!browserName) {
return errorResponse("Missing 'browser' parameter", 'missing_param', { port });
}
const profiles = listProfiles(browserName);
return jsonResponse({ profiles }, { port });
}
// GET /cookie-picker/domains?browser=<name>&profile=<profile> — list domains + counts
if (pathname === '/cookie-picker/domains' && req.method === 'GET') {
const browserName = url.searchParams.get('browser');
if (!browserName) {
return errorResponse("Missing 'browser' parameter", 'missing_param', { port });
}
const result = listDomains(browserName);
const profile = url.searchParams.get('profile') || 'Default';
const result = listDomains(browserName, profile);
return jsonResponse({
browser: result.browser,
domains: result.domains,
@@ -112,14 +123,14 @@ export async function handleCookiePickerRoute(
return errorResponse('Invalid JSON body', 'bad_request', { port });
}
const { browser, domains } = body;
const { browser, domains, profile } = body;
if (!browser) return errorResponse("Missing 'browser' field", 'missing_param', { port });
if (!domains || !Array.isArray(domains) || domains.length === 0) {
return errorResponse("Missing or empty 'domains' array", 'missing_param', { port });
}
// Decrypt cookies from the browser DB
const result = await importCookies(browser, domains);
const result = await importCookies(browser, domains, profile || 'Default');
if (result.cookies.length === 0) {
return jsonResponse({

View File

@@ -101,6 +101,30 @@ export function getCookiePickerHTML(serverPort: number): string {
background: #4ade80;
}
/* ─── Profile Pills ─────────────────── */
.profile-pills {
display: flex;
gap: 6px;
padding: 0 20px 12px;
flex-wrap: wrap;
}
.profile-pill {
padding: 4px 10px;
border-radius: 14px;
border: 1px solid #2a2a2a;
background: #141414;
color: #888;
font-size: 12px;
cursor: pointer;
transition: all 0.15s;
}
.profile-pill:hover { border-color: #444; color: #bbb; }
.profile-pill.active {
border-color: #60a5fa;
background: #0a1a2a;
color: #60a5fa;
}
/* ─── Search ──────────────────────────── */
.search-wrap {
padding: 0 20px 12px;
@@ -189,7 +213,22 @@ export function getCookiePickerHTML(serverPort: number): string {
border-top: 1px solid #222;
font-size: 12px;
color: #666;
display: flex;
align-items: center;
justify-content: space-between;
}
.btn-import-all {
padding: 4px 12px;
border-radius: 6px;
border: 1px solid #333;
background: #1a1a1a;
color: #4ade80;
font-size: 12px;
cursor: pointer;
transition: all 0.15s;
}
.btn-import-all:hover { border-color: #4ade80; background: #0a2a14; }
.btn-import-all:disabled { opacity: 0.3; cursor: not-allowed; pointer-events: none; }
/* ─── Imported Panel ──────────────────── */
.imported-empty {
@@ -268,13 +307,14 @@ export function getCookiePickerHTML(serverPort: number): string {
<div class="panel panel-left">
<div class="panel-header">Source Browser</div>
<div id="browser-pills" class="browser-pills"></div>
<div id="profile-pills" class="profile-pills" style="display:none"></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 class="panel-footer" id="source-footer"><span id="source-footer-text"></span><button class="btn-import-all" id="btn-import-all" style="display:none">Import All</button></div>
</div>
<!-- Right Panel: Imported -->
@@ -291,15 +331,19 @@ export function getCookiePickerHTML(serverPort: number): string {
(function() {
const BASE = '${baseUrl}';
let activeBrowser = null;
let activeProfile = 'Default';
let allProfiles = [];
let allDomains = [];
let importedSet = {}; // domain → count
let inflight = {}; // domain → true (prevents double-click)
const $pills = document.getElementById('browser-pills');
const $profilePills = document.getElementById('profile-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 $sourceFooter = document.getElementById('source-footer-text');
const $btnImportAll = document.getElementById('btn-import-all');
const $importedFooter = document.getElementById('imported-footer');
const $banner = document.getElementById('banner');
@@ -380,22 +424,76 @@ export function getCookiePickerHTML(serverPort: number): string {
// ─── Select Browser ────────────────────
async function selectBrowser(name) {
activeBrowser = name;
activeProfile = 'Default';
// 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>';
$sourceDomains.innerHTML = '<div class="loading-row"><span class="spinner"></span> Loading...</div>';
$sourceFooter.textContent = '';
$search.value = '';
try {
const data = await api('/domains?browser=' + encodeURIComponent(name));
// Fetch profiles for this browser
const profileData = await api('/profiles?browser=' + encodeURIComponent(name));
allProfiles = profileData.profiles || [];
if (allProfiles.length > 1) {
// Show profile pills when multiple profiles exist
$profilePills.style.display = 'flex';
renderProfilePills();
// Auto-select profile with the most recent/largest cookie DB, or Default
activeProfile = allProfiles[0].name;
} else {
$profilePills.style.display = 'none';
activeProfile = allProfiles.length === 1 ? allProfiles[0].name : 'Default';
}
await loadDomains();
} catch (err) {
showBanner(err.message, 'error', err.action === 'retry' ? () => selectBrowser(name) : null);
$sourceDomains.innerHTML = '<div class="imported-empty">Failed to load</div>';
$profilePills.style.display = 'none';
}
}
// ─── Render Profile Pills ─────────────
function renderProfilePills() {
let html = '';
for (const p of allProfiles) {
const isActive = p.name === activeProfile;
const label = p.displayName || p.name;
html += '<button class="profile-pill' + (isActive ? ' active' : '') + '" data-profile="' + escHtml(p.name) + '">' + escHtml(label) + '</button>';
}
$profilePills.innerHTML = html;
$profilePills.querySelectorAll('.profile-pill').forEach(btn => {
btn.addEventListener('click', () => selectProfile(btn.dataset.profile));
});
}
// ─── Select Profile ───────────────────
async function selectProfile(profileName) {
activeProfile = profileName;
renderProfilePills();
$sourceDomains.innerHTML = '<div class="loading-row"><span class="spinner"></span> Loading domains...</div>';
$sourceFooter.textContent = '';
$search.value = '';
await loadDomains();
}
// ─── Load Domains ─────────────────────
async function loadDomains() {
try {
const data = await api('/domains?browser=' + encodeURIComponent(activeBrowser) + '&profile=' + encodeURIComponent(activeProfile));
allDomains = data.domains;
renderSourceDomains();
} catch (err) {
showBanner(err.message, 'error', err.action === 'retry' ? () => selectBrowser(name) : null);
showBanner(err.message, 'error', err.action === 'retry' ? () => loadDomains() : null);
$sourceDomains.innerHTML = '<div class="imported-empty">Failed to load domains</div>';
}
}
@@ -437,6 +535,16 @@ export function getCookiePickerHTML(serverPort: number): string {
const totalCookies = allDomains.reduce((s, d) => s + d.count, 0);
$sourceFooter.textContent = totalDomains + ' domains · ' + totalCookies.toLocaleString() + ' cookies';
// Show/hide Import All button
const unimported = filtered.filter(d => !(d.domain in importedSet) && !inflight[d.domain]);
if (unimported.length > 0) {
$btnImportAll.style.display = '';
$btnImportAll.disabled = false;
$btnImportAll.textContent = 'Import All (' + unimported.length + ')';
} else {
$btnImportAll.style.display = 'none';
}
// Click handlers
$sourceDomains.querySelectorAll('.btn-add[data-domain]').forEach(btn => {
btn.addEventListener('click', () => importDomain(btn.dataset.domain));
@@ -453,7 +561,7 @@ export function getCookiePickerHTML(serverPort: number): string {
const data = await api('/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ browser: activeBrowser, domains: [domain] }),
body: JSON.stringify({ browser: activeBrowser, domains: [domain], profile: activeProfile }),
});
if (data.domainCounts) {
@@ -471,6 +579,42 @@ export function getCookiePickerHTML(serverPort: number): string {
}
}
// ─── Import All ───────────────────────
async function importAll() {
const query = $search.value.toLowerCase();
const filtered = query
? allDomains.filter(d => d.domain.toLowerCase().includes(query))
: allDomains;
const toImport = filtered.filter(d => !(d.domain in importedSet) && !inflight[d.domain]);
if (toImport.length === 0) return;
$btnImportAll.disabled = true;
$btnImportAll.textContent = 'Importing...';
const domains = toImport.map(d => d.domain);
try {
const data = await api('/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ browser: activeBrowser, domains: domains, profile: activeProfile }),
});
if (data.domainCounts) {
for (const [d, count] of Object.entries(data.domainCounts)) {
importedSet[d] = (importedSet[d] || 0) + count;
}
}
renderImported();
} catch (err) {
showBanner('Import all failed: ' + err.message, 'error',
err.action === 'retry' ? () => importAll() : null);
} finally {
renderSourceDomains();
}
}
$btnImportAll.addEventListener('click', importAll);
// ─── Render Imported ───────────────────
function renderImported() {
const entries = Object.entries(importedSet).sort((a, b) => b[1] - a[1]);

View File

@@ -82,8 +82,12 @@ export async function validateNavigationUrl(url: string): Promise<void> {
);
}
// DNS rebinding protection: resolve hostname and check if it points to metadata IPs
if (await resolvesToBlockedIp(hostname)) {
// DNS rebinding protection: resolve hostname and check if it points to metadata IPs.
// Skip for loopback/private IPs — they can't be DNS-rebinded and the async DNS
// resolution adds latency that breaks concurrent E2E tests under load.
const isLoopback = hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1';
const isPrivateNet = /^(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.)/.test(hostname);
if (!isLoopback && !isPrivateNet && await resolvesToBlockedIp(hostname)) {
throw new Error(
`Blocked: ${parsed.hostname} resolves to a cloud metadata IP. Possible DNS rebinding attack.`
);

View File

@@ -6,7 +6,7 @@
*/
import type { BrowserManager } from './browser-manager';
import { findInstalledBrowsers, importCookies } from './cookie-import-browser';
import { findInstalledBrowsers, importCookies, listSupportedBrowserNames } from './cookie-import-browser';
import { validateNavigationUrl } from './url-validation';
import * as fs from 'fs';
import * as path from 'path';
@@ -309,16 +309,18 @@ export async function handleWriteCommand(
case 'cookie-import-browser': {
// Two modes:
// 1. Direct CLI import: cookie-import-browser <browser> --domain <domain>
// 1. Direct CLI import: cookie-import-browser <browser> --domain <domain> [--profile <profile>]
// 2. Open picker UI: cookie-import-browser [browser]
const browserArg = args[0];
const domainIdx = args.indexOf('--domain');
const profileIdx = args.indexOf('--profile');
const profile = (profileIdx !== -1 && profileIdx + 1 < args.length) ? args[profileIdx + 1] : 'Default';
if (domainIdx !== -1 && domainIdx + 1 < args.length) {
// Direct import mode — no UI
const domain = args[domainIdx + 1];
const browser = browserArg || 'comet';
const result = await importCookies(browser, [domain]);
const result = await importCookies(browser, [domain], profile);
if (result.cookies.length > 0) {
await page.context().addCookies(result.cookies);
}
@@ -333,7 +335,7 @@ export async function handleWriteCommand(
const browsers = findInstalledBrowsers();
if (browsers.length === 0) {
throw new Error('No Chromium browsers found. Supported: Comet, Chrome, Arc, Brave, Edge');
throw new Error(`No Chromium browsers found. Supported: ${listSupportedBrowserNames().join(', ')}`);
}
const pickerUrl = `http://127.0.0.1:${port}/cookie-picker`;

View File

@@ -13,7 +13,7 @@
* Remaining bytes = actual cookie value
*/
import { describe, test, expect, beforeAll, afterAll, mock } from 'bun:test';
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { Database } from 'bun:sqlite';
import * as crypto from 'crypto';
import * as fs from 'fs';
@@ -24,16 +24,26 @@ import * as os from 'os';
const TEST_PASSWORD = 'test-keychain-password';
const TEST_KEY = crypto.pbkdf2Sync(TEST_PASSWORD, 'saltysalt', 1003, 16, 'sha1');
const LINUX_V10_PASSWORD = 'peanuts';
const LINUX_V10_KEY = crypto.pbkdf2Sync(LINUX_V10_PASSWORD, 'saltysalt', 1, 16, 'sha1');
const LINUX_V11_PASSWORD = 'test-linux-secret';
const LINUX_V11_KEY = crypto.pbkdf2Sync(LINUX_V11_PASSWORD, 'saltysalt', 1, 16, 'sha1');
const IV = Buffer.alloc(16, 0x20);
const CHROMIUM_EPOCH_OFFSET = 11644473600000000n;
// Fixture DB path
const FIXTURE_DIR = path.join(import.meta.dir, 'fixtures');
const FIXTURE_DB = path.join(FIXTURE_DIR, 'test-cookies.db');
const LINUX_FIXTURE_DB = path.join(FIXTURE_DIR, 'test-cookies-linux.db');
// ─── Encryption Helper ──────────────────────────────────────────
function encryptCookieValue(value: string): Buffer {
function encryptCookieValue(
value: string,
options?: { key?: Buffer; prefix?: 'v10' | 'v11' },
): Buffer {
const key = options?.key ?? TEST_KEY;
const prefix = options?.prefix ?? 'v10';
// 32-byte HMAC tag (random for test) + actual value
const hmacTag = crypto.randomBytes(32);
const plaintext = Buffer.concat([hmacTag, Buffer.from(value, 'utf-8')]);
@@ -43,12 +53,11 @@ function encryptCookieValue(value: string): Buffer {
const padLen = blockSize - (plaintext.length % blockSize);
const padded = Buffer.concat([plaintext, Buffer.alloc(padLen, padLen)]);
const cipher = crypto.createCipheriv('aes-128-cbc', TEST_KEY, IV);
const cipher = crypto.createCipheriv('aes-128-cbc', key, IV);
cipher.setAutoPadding(false); // We padded manually
const encrypted = Buffer.concat([cipher.update(padded), cipher.final()]);
// Prefix with "v10"
return Buffer.concat([Buffer.from('v10'), encrypted]);
return Buffer.concat([Buffer.from(prefix), encrypted]);
}
function chromiumEpoch(unixSeconds: number): bigint {
@@ -57,11 +66,11 @@ function chromiumEpoch(unixSeconds: number): bigint {
// ─── Create Fixture Database ────────────────────────────────────
function createFixtureDb() {
function createFixtureDb(dbPath: string): Database {
fs.mkdirSync(FIXTURE_DIR, { recursive: true });
if (fs.existsSync(FIXTURE_DB)) fs.unlinkSync(FIXTURE_DB);
if (fs.existsSync(dbPath)) fs.unlinkSync(dbPath);
const db = new Database(FIXTURE_DB);
const db = new Database(dbPath);
db.run(`CREATE TABLE cookies (
host_key TEXT NOT NULL,
name TEXT NOT NULL,
@@ -74,7 +83,11 @@ function createFixtureDb() {
has_expires INTEGER NOT NULL DEFAULT 0,
samesite INTEGER NOT NULL DEFAULT 1
)`);
return db;
}
function createMacFixtureDb() {
const db = createFixtureDb(FIXTURE_DB);
const insert = db.prepare(`INSERT INTO cookies
(host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, has_expires, samesite)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
@@ -110,6 +123,21 @@ function createFixtureDb() {
db.close();
}
function createLinuxFixtureDb() {
const db = createFixtureDb(LINUX_FIXTURE_DB);
const insert = db.prepare(`INSERT INTO cookies
(host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, has_expires, samesite)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
const futureExpiry = Number(chromiumEpoch(Math.floor(Date.now() / 1000) + 86400 * 365));
insert.run('.linux-v10.com', 'sid', '', encryptCookieValue('linux-v10-value', { key: LINUX_V10_KEY, prefix: 'v10' }), '/', futureExpiry, 1, 1, 1, 1);
insert.run('.linux-v11.com', 'auth', '', encryptCookieValue('linux-v11-value', { key: LINUX_V11_KEY, prefix: 'v11' }), '/', futureExpiry, 1, 1, 1, 1);
insert.run('.linux-plain.com', 'plain', 'plain-linux', Buffer.alloc(0), '/', futureExpiry, 0, 0, 1, 1);
db.close();
}
// ─── Mock Setup ─────────────────────────────────────────────────
// We need to mock:
// 1. The Keychain access (getKeychainPassword) to return TEST_PASSWORD
@@ -120,17 +148,18 @@ let findInstalledBrowsers: any;
let listDomains: any;
let importCookies: any;
let CookieImportError: any;
let originalSpawn: typeof Bun.spawn;
beforeAll(async () => {
createFixtureDb();
createMacFixtureDb();
createLinuxFixtureDb();
// Mock Bun.spawn to return test password for keychain access
const origSpawn = Bun.spawn;
originalSpawn = Bun.spawn;
// @ts-ignore - monkey-patching for test
Bun.spawn = function(cmd: any, opts: any) {
// Intercept security find-generic-password calls
if (Array.isArray(cmd) && cmd[0] === 'security' && cmd[1] === 'find-generic-password') {
const service = cmd[3]; // -s <service>
// Return test password for any known test service
return {
stdout: new ReadableStream({
@@ -146,8 +175,23 @@ beforeAll(async () => {
kill: () => {},
};
}
if (Array.isArray(cmd) && cmd[0] === 'secret-tool' && cmd[1] === 'lookup') {
return {
stdout: new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(LINUX_V11_PASSWORD + '\n'));
controller.close();
}
}),
stderr: new ReadableStream({
start(controller) { controller.close(); }
}),
exited: Promise.resolve(0),
kill: () => {},
};
}
// Pass through other spawn calls
return origSpawn(cmd, opts);
return originalSpawn(cmd, opts);
};
// Import the module (uses our mocked Bun.spawn)
@@ -159,8 +203,12 @@ beforeAll(async () => {
});
afterAll(() => {
// Restore Bun.spawn
// @ts-ignore - monkey-patching for test
Bun.spawn = originalSpawn;
// Clean up fixture DB
try { fs.unlinkSync(FIXTURE_DB); } catch {}
try { fs.unlinkSync(LINUX_FIXTURE_DB); } catch {}
try { fs.rmdirSync(FIXTURE_DIR); } catch {}
});
@@ -176,6 +224,35 @@ afterAll(() => {
// 2. Decrypting them with the module's decryption logic
// The actual DB path resolution is tested separately.
async function withInstalledProfile<T>(
relativeBrowserDir: string,
sourceDb: string,
run: () => Promise<T>,
profile = 'Default',
): Promise<T> {
const homeDir = os.homedir();
const profileDir = path.join(homeDir, relativeBrowserDir, profile);
const cookiesPath = path.join(profileDir, 'Cookies');
const backupPath = path.join(profileDir, `Cookies.backup-${crypto.randomUUID()}`);
const hadOriginal = fs.existsSync(cookiesPath);
fs.mkdirSync(profileDir, { recursive: true });
if (hadOriginal) fs.copyFileSync(cookiesPath, backupPath);
fs.copyFileSync(sourceDb, cookiesPath);
try {
return await run();
} finally {
if (hadOriginal) {
fs.copyFileSync(backupPath, cookiesPath);
fs.unlinkSync(backupPath);
} else {
try { fs.unlinkSync(cookiesPath); } catch {}
try { fs.rmdirSync(profileDir); } catch {}
}
}
}
// ─── Tests ──────────────────────────────────────────────────────
describe('Cookie Import Browser', () => {
@@ -351,6 +428,51 @@ describe('Cookie Import Browser', () => {
expect(b).toHaveProperty('aliases');
}
});
test('detects linux-style Chromium profiles under ~/.config', async () => {
await withInstalledProfile('.config/chromium', LINUX_FIXTURE_DB, async () => {
const browsers = findInstalledBrowsers();
const names = browsers.map((browser: any) => browser.name);
expect(names).toContain('Chromium');
});
});
});
describe('Real Profile Imports', () => {
test('imports Linux v10 cookies from ~/.config/chromium', async () => {
await withInstalledProfile('.config/chromium', LINUX_FIXTURE_DB, async () => {
const result = await importCookies('chromium', ['.linux-v10.com'], 'GstackLinuxV10');
expect(result.count).toBe(1);
expect(result.failed).toBe(0);
expect(result.cookies[0].name).toBe('sid');
expect(result.cookies[0].value).toBe('linux-v10-value');
}, 'GstackLinuxV10');
});
test('imports Linux v11 cookies when secret-tool returns a key', async () => {
await withInstalledProfile('.config/chromium', LINUX_FIXTURE_DB, async () => {
const result = await importCookies('chromium', ['.linux-v11.com'], 'GstackLinuxV11');
expect(result.count).toBe(1);
expect(result.failed).toBe(0);
expect(result.cookies[0].name).toBe('auth');
expect(result.cookies[0].value).toBe('linux-v11-value');
}, 'GstackLinuxV11');
});
test('lists domains from Linux Chromium profiles', async () => {
await withInstalledProfile('.config/chromium', LINUX_FIXTURE_DB, async () => {
const result = listDomains('chromium', 'GstackLinuxDomains');
const domains = result.domains.map((entry: any) => entry.domain);
expect(result.browser).toBe('Chromium');
expect(domains).toContain('.linux-v10.com');
expect(domains).toContain('.linux-v11.com');
expect(domains).toContain('.linux-plain.com');
}, 'GstackLinuxDomains');
});
});
describe('Corrupt Data Handling', () => {

View File

@@ -447,6 +447,24 @@ describe('gstack-update-check', () => {
expect(cache).toContain('UP_TO_DATE');
});
test('--force clears snooze so user can upgrade after snoozing', () => {
writeFileSync(join(gstackDir, 'VERSION'), '0.3.3\n');
writeFileSync(join(gstackDir, 'REMOTE_VERSION'), '0.4.0\n');
writeSnooze('0.4.0', 1, nowEpoch() - 60); // snoozed 1 min ago (within 24h)
// Without --force: snoozed, silent
const snoozed = run();
expect(snoozed.exitCode).toBe(0);
expect(snoozed.stdout).toBe('');
// With --force: snooze cleared, outputs upgrade
const forced = run({}, ['--force']);
expect(forced.exitCode).toBe(0);
expect(forced.stdout).toBe('UPGRADE_AVAILABLE 0.3.3 0.4.0');
// Snooze file should be deleted
expect(existsSync(join(stateDir, 'update-snoozed'))).toBe(false);
});
// ─── Split TTL tests ─────────────────────────────────────────
test('UP_TO_DATE cache expires after 60 min (not 720)', () => {