diff --git a/BROWSER.md b/BROWSER.md index b024cdd4..086d2278 100644 --- a/BROWSER.md +++ b/BROWSER.md @@ -247,7 +247,7 @@ Tests spin up a local HTTP server (`browse/test/test-server.ts`) serving HTML fi | `browse/src/read-commands.ts` | Non-mutating commands: `text`, `html`, `links`, `js`, `css`, `is`, `dialog`, `forms`, etc. Exports `getCleanText()`. | | `browse/src/write-commands.ts` | Mutating commands: `goto`, `click`, `fill`, `upload`, `dialog-accept`, `useragent` (with context recreation), etc. | | `browse/src/meta-commands.ts` | Server management, chain routing, diff (DRY via `getCleanText`), snapshot delegation. | -| `browse/src/cookie-import-browser.ts` | Decrypt Chromium cookies via macOS Keychain + PBKDF2/AES-128-CBC. Auto-detects installed browsers. | +| `browse/src/cookie-import-browser.ts` | Decrypt Chromium cookies from macOS and Linux browser profiles using platform-specific safe-storage key lookup. Auto-detects installed browsers. | | `browse/src/cookie-picker-routes.ts` | HTTP routes for `/cookie-picker/*` — browser list, domain search, import, remove. | | `browse/src/cookie-picker-ui.ts` | Self-contained HTML generator for the interactive cookie picker (dark theme, no frameworks). | | `browse/src/buffers.ts` | `CircularBuffer` (O(1) ring buffer) + console/network/dialog capture with async disk flush. | diff --git a/browse/SKILL.md b/browse/SKILL.md index 2acf60b0..6fa9d734 100644 --- a/browse/SKILL.md +++ b/browse/SKILL.md @@ -416,7 +416,7 @@ Refs are invalidated on navigation — run `snapshot` again after `goto`. | `click ` | Click element | | `cookie =` | Set cookie on current page domain | | `cookie-import ` | 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 ` | Fill input | diff --git a/browse/src/commands.ts b/browse/src/commands.ts index c3509af1..81c8f61a 100644 --- a/browse/src/commands.ts +++ b/browse/src/commands.ts @@ -73,7 +73,7 @@ export const COMMAND_DESCRIPTIONS: Record' }, 'cookie': { category: 'Interaction', description: 'Set cookie on current page domain', usage: 'cookie =' }, 'cookie-import': { category: 'Interaction', description: 'Import cookies from JSON file', usage: 'cookie-import ' }, - '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 :' }, 'useragent': { category: 'Interaction', description: 'Set user agent', usage: 'useragent ' }, 'dialog-accept': { category: 'Interaction', description: 'Auto-accept next alert/confirm/prompt. Optional text is sent as the prompt response', usage: 'dialog-accept [text]' }, diff --git a/browse/src/cookie-import-browser.ts b/browse/src/cookie-import-browser.ts index 29d9db3e..14ce62dd 100644 --- a/browse/src/cookie-import-browser.ts +++ b/browse/src/cookie-import-browser.ts @@ -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 "" -w` │ - * │ → base64 password string │ + * │ 1. Resolve the cookie DB from the browser profile dir │ + * │ - macOS: ~/Library/Application Support// │ + * │ - Linux: ~/.config// │ * │ │ - * │ 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,11 @@ 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 DomainEntry { @@ -81,15 +86,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 ────────────────────────────────────────────────── @@ -104,11 +118,14 @@ const keyCache = new Map(); * Find which browsers are installed (have a cookie DB on disk). */ 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 => findBrowserMatch(browser, 'Default') !== null); +} + +export function listSupportedBrowserNames(): string[] { + const hostPlatform = getHostPlatform(); + return BROWSER_REGISTRY + .filter(browser => hostPlatform ? getDataDirForPlatform(browser, hostPlatform) !== null : true) + .map(browser => browser.name); } /** @@ -116,8 +133,8 @@ export function findInstalledBrowsers(): BrowserInfo[] { */ 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 +161,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 +184,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 +225,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 +334,40 @@ function openDbFromCopy(dbPath: string, browserName: string): Database { // ─── Internal: Keychain Access (async, 10s timeout) ───────────── -async function getDerivedKey(browser: BrowserInfo): Promise { - 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 { +async function getDerivedKeys(match: BrowserMatch): Promise> { + 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(); + 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 { // 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 +425,47 @@ async function getKeychainPassword(service: string): Promise { } } +async function getLinuxSecretPassword(browser: BrowserInfo): Promise { + 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 { + try { + const proc = Bun.spawn(cmd, { stdout: 'pipe', stderr: 'pipe' }); + const timeout = new Promise((_, 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 +481,7 @@ interface RawCookie { samesite: number; } -function decryptCookieValue(row: RawCookie, key: Buffer): string { +function decryptCookieValue(row: RawCookie, keys: Map): string { // Prefer unencrypted value if present if (row.value && row.value.length > 0) return row.value; @@ -364,16 +489,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'); } diff --git a/browse/src/write-commands.ts b/browse/src/write-commands.ts index 1bf37eb5..011df8b7 100644 --- a/browse/src/write-commands.ts +++ b/browse/src/write-commands.ts @@ -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'; @@ -333,7 +333,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`; diff --git a/browse/test/cookie-import-browser.test.ts b/browse/test/cookie-import-browser.test.ts index 1e91cf13..5e9a5b44 100644 --- a/browse/test/cookie-import-browser.test.ts +++ b/browse/test/cookie-import-browser.test.ts @@ -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 // 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( + relativeBrowserDir: string, + sourceDb: string, + run: () => Promise, + profile = 'Default', +): Promise { + 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', () => { diff --git a/setup-browser-cookies/SKILL.md b/setup-browser-cookies/SKILL.md index ac2d873c..f738a6ae 100644 --- a/setup-browser-cookies/SKILL.md +++ b/setup-browser-cookies/SKILL.md @@ -2,10 +2,10 @@ name: setup-browser-cookies version: 1.0.0 description: | - Import cookies from your real browser (Comet, Chrome, Arc, Brave, Edge) into the - headless browse session. Opens an interactive picker UI where you select which - cookie domains to import. Use before QA testing authenticated pages. Use when asked - to "import cookies", "login to the site", or "authenticate the browser". + Import cookies from your real Chromium browser into the headless browse session. + Opens an interactive picker UI where you select which cookie domains to import. + Use before QA testing authenticated pages. Use when asked to "import cookies", + "login to the site", or "authenticate the browser". allowed-tools: - Bash - Read @@ -258,7 +258,7 @@ If `NEEDS_SETUP`: $B cookie-import-browser ``` -This auto-detects installed Chromium browsers (Comet, Chrome, Arc, Brave, Edge) and opens +This auto-detects installed Chromium browsers and opens an interactive picker UI in your default browser where you can: - Switch between installed browsers - Search domains @@ -289,7 +289,8 @@ Show the user a summary of imported cookies (domain counts). ## Notes -- First import per browser may trigger a macOS Keychain dialog — click "Allow" / "Always Allow" +- On macOS, the first import per browser may trigger a Keychain dialog — click "Allow" / "Always Allow" +- On Linux, `v11` cookies may require `secret-tool`/libsecret access; `v10` cookies use Chromium's standard fallback key - Cookie picker is served on the same port as the browse server (no extra process) - Only domain names and cookie counts are shown in the UI — no cookie values are exposed - The browse session persists cookies between commands, so imported cookies work immediately diff --git a/setup-browser-cookies/SKILL.md.tmpl b/setup-browser-cookies/SKILL.md.tmpl index 4496d11c..934e0797 100644 --- a/setup-browser-cookies/SKILL.md.tmpl +++ b/setup-browser-cookies/SKILL.md.tmpl @@ -2,10 +2,10 @@ name: setup-browser-cookies version: 1.0.0 description: | - Import cookies from your real browser (Comet, Chrome, Arc, Brave, Edge) into the - headless browse session. Opens an interactive picker UI where you select which - cookie domains to import. Use before QA testing authenticated pages. Use when asked - to "import cookies", "login to the site", or "authenticate the browser". + Import cookies from your real Chromium browser into the headless browse session. + Opens an interactive picker UI where you select which cookie domains to import. + Use before QA testing authenticated pages. Use when asked to "import cookies", + "login to the site", or "authenticate the browser". allowed-tools: - Bash - Read @@ -37,7 +37,7 @@ Import logged-in sessions from your real Chromium browser into the headless brow $B cookie-import-browser ``` -This auto-detects installed Chromium browsers (Comet, Chrome, Arc, Brave, Edge) and opens +This auto-detects installed Chromium browsers and opens an interactive picker UI in your default browser where you can: - Switch between installed browsers - Search domains @@ -68,7 +68,8 @@ Show the user a summary of imported cookies (domain counts). ## Notes -- First import per browser may trigger a macOS Keychain dialog — click "Allow" / "Always Allow" +- On macOS, the first import per browser may trigger a Keychain dialog — click "Allow" / "Always Allow" +- On Linux, `v11` cookies may require `secret-tool`/libsecret access; `v10` cookies use Chromium's standard fallback key - Cookie picker is served on the same port as the browse server (no extra process) - Only domain names and cookie counts are shown in the UI — no cookie values are exposed - The browse session persists cookies between commands, so imported cookies work immediately