fix: support Linux Chromium cookie import

This commit is contained in:
AliFozooni
2026-03-20 23:14:43 -07:00
parent 1f4b6fd7a2
commit 97c8084df2
8 changed files with 325 additions and 77 deletions

View File

@@ -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/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/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/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-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/cookie-picker-ui.ts` | Self-contained HTML generator for the interactive cookie picker (dark theme, no frameworks). |
| `browse/src/buffers.ts` | `CircularBuffer<T>` (O(1) ring buffer) + console/network/dialog capture with async disk flush. | | `browse/src/buffers.ts` | `CircularBuffer<T>` (O(1) ring buffer) + console/network/dialog capture with async disk flush. |

View File

@@ -416,7 +416,7 @@ Refs are invalidated on navigation — run `snapshot` again after `goto`.
| `click <sel>` | Click element | | `click <sel>` | Click element |
| `cookie <name>=<value>` | Set cookie on current page domain | | `cookie <name>=<value>` | Set cookie on current page domain |
| `cookie-import <json>` | Import cookies from JSON file | | `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-accept [text]` | Auto-accept next alert/confirm/prompt. Optional text is sent as the prompt response |
| `dialog-dismiss` | Auto-dismiss next dialog | | `dialog-dismiss` | Auto-dismiss next dialog |
| `fill <sel> <val>` | Fill input | | `fill <sel> <val>` | Fill input |

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>' }, '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': { 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': { 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>' }, '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>' }, 'useragent': { category: 'Interaction', description: 'Set user agent', usage: 'useragent <string>' },
'dialog-accept': { category: 'Interaction', description: 'Auto-accept next alert/confirm/prompt. Optional text is sent as the prompt response', usage: 'dialog-accept [text]' }, 'dialog-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 * 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. * 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` * │ 1. Resolve the cookie DB from the browser profile dir
* │ → base64 password string * │ - macOS: ~/Library/Application Support/<browser>/<profile>
* │ - Linux: ~/.config/<browser>/<profile> │
* │ │ * │ │
* │ 2. Key derivation: * │ 2. Derive the AES key
* │ PBKDF2(password, salt="saltysalt", iter=1003, len=16, sha1) * │ - macOS v10: Keychain password, PBKDF2(..., iter=1003)
* │ → 16-byte AES key * │ - 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:] │ * │ - Ciphertext = encrypted_value[3:] │
* │ - IV = 16 bytes of 0x20 (space character) │ * │ - IV = 16 bytes of 0x20 (space character) │
* │ - Plaintext = AES-128-CBC-decrypt(key, iv, ciphertext) │ * │ - Plaintext = AES-128-CBC-decrypt(key, iv, ciphertext) │
* │ - Remove PKCS7 padding │ * │ - 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) │ * │ - Remaining bytes = cookie value (UTF-8) │
* │ │ * │ │
* │ 4. If encrypted_value is empty but `value` field is set, │ * │ 4. If encrypted_value is empty but `value` field is set, │
@@ -42,9 +45,11 @@ import * as os from 'os';
export interface BrowserInfo { export interface BrowserInfo {
name: string; name: string;
dataDir: string; // relative to ~/Library/Application Support/ dataDir: string; // primary storage dir (retained for compatibility with existing callers/tests)
keychainService: string; keychainService: string;
aliases: string[]; aliases: string[];
linuxDataDir?: string;
linuxApplication?: string;
} }
export interface DomainEntry { 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 ─────────────────────────────────────────── // ─── Browser Registry ───────────────────────────────────────────
// Hardcoded — NEVER interpolate user input into shell commands. // Hardcoded — NEVER interpolate user input into shell commands.
const BROWSER_REGISTRY: BrowserInfo[] = [ const BROWSER_REGISTRY: BrowserInfo[] = [
{ name: 'Comet', dataDir: 'Comet/', keychainService: 'Comet Safe Storage', aliases: ['comet', 'perplexity'] }, { 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: 'Chrome', dataDir: 'Google/Chrome/', keychainService: 'Chrome Safe Storage', aliases: ['chrome', 'google-chrome', 'google-chrome-stable'], linuxDataDir: 'google-chrome/', linuxApplication: 'chrome' },
{ name: 'Arc', dataDir: 'Arc/User Data/', keychainService: 'Arc Safe Storage', aliases: ['arc'] }, { name: 'Chromium', dataDir: 'chromium/', keychainService: 'Chromium Safe Storage', aliases: ['chromium'], linuxDataDir: 'chromium/', linuxApplication: 'chromium' },
{ name: 'Brave', dataDir: 'BraveSoftware/Brave-Browser/', keychainService: 'Brave Safe Storage', aliases: ['brave'] }, { name: 'Arc', dataDir: 'Arc/User Data/', keychainService: 'Arc Safe Storage', aliases: ['arc'] },
{ name: 'Edge', dataDir: 'Microsoft Edge/', keychainService: 'Microsoft Edge Safe Storage', aliases: ['edge'] }, { 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 ────────────────────────────────────────────────── // ─── Key Cache ──────────────────────────────────────────────────
@@ -104,11 +118,14 @@ const keyCache = new Map<string, Buffer>();
* Find which browsers are installed (have a cookie DB on disk). * Find which browsers are installed (have a cookie DB on disk).
*/ */
export function findInstalledBrowsers(): BrowserInfo[] { export function findInstalledBrowsers(): BrowserInfo[] {
const appSupport = path.join(os.homedir(), 'Library', 'Application Support'); return BROWSER_REGISTRY.filter(browser => findBrowserMatch(browser, 'Default') !== null);
return BROWSER_REGISTRY.filter(b => { }
const dbPath = path.join(appSupport, b.dataDir, 'Default', 'Cookies');
try { return fs.existsSync(dbPath); } 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);
} }
/** /**
@@ -116,8 +133,8 @@ export function findInstalledBrowsers(): BrowserInfo[] {
*/ */
export function listDomains(browserName: string, profile = 'Default'): { domains: DomainEntry[]; browser: string } { export function listDomains(browserName: string, profile = 'Default'): { domains: DomainEntry[]; browser: string } {
const browser = resolveBrowser(browserName); const browser = resolveBrowser(browserName);
const dbPath = getCookieDbPath(browser, profile); const match = getBrowserMatch(browser, profile);
const db = openDb(dbPath, browser.name); const db = openDb(match.dbPath, browser.name);
try { try {
const now = chromiumNow(); const now = chromiumNow();
const rows = db.query( const rows = db.query(
@@ -144,9 +161,9 @@ export async function importCookies(
if (domains.length === 0) return { cookies: [], count: 0, failed: 0, domainCounts: {} }; if (domains.length === 0) return { cookies: [], count: 0, failed: 0, domainCounts: {} };
const browser = resolveBrowser(browserName); const browser = resolveBrowser(browserName);
const derivedKey = await getDerivedKey(browser); const match = getBrowserMatch(browser, profile);
const dbPath = getCookieDbPath(browser, profile); const derivedKeys = await getDerivedKeys(match);
const db = openDb(dbPath, browser.name); const db = openDb(match.dbPath, browser.name);
try { try {
const now = chromiumNow(); const now = chromiumNow();
@@ -167,7 +184,7 @@ export async function importCookies(
for (const row of rows) { for (const row of rows) {
try { try {
const value = decryptCookieValue(row, derivedKey); const value = decryptCookieValue(row, derivedKeys);
const cookie = toPlaywrightCookie(row, value); const cookie = toPlaywrightCookie(row, value);
cookies.push(cookie); cookies.push(cookie);
domainCounts[row.host_key] = (domainCounts[row.host_key] || 0) + 1; 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 { function getHostPlatform(): BrowserPlatform | null {
validateProfile(profile); if (process.platform === 'darwin' || process.platform === 'linux') return process.platform;
const appSupport = path.join(os.homedir(), 'Library', 'Application Support'); return null;
const dbPath = path.join(appSupport, browser.dataDir, profile, 'Cookies'); }
if (!fs.existsSync(dbPath)) {
throw new CookieImportError( function getSearchPlatforms(): BrowserPlatform[] {
`${browser.name} is not installed (no cookie database at ${dbPath})`, const current = getHostPlatform();
'not_installed', 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 ──────────────────────────────────── // ─── Internal: SQLite Access ────────────────────────────────────
@@ -273,17 +334,40 @@ function openDbFromCopy(dbPath: string, browserName: string): Database {
// ─── Internal: Keychain Access (async, 10s timeout) ───────────── // ─── Internal: Keychain Access (async, 10s timeout) ─────────────
async function getDerivedKey(browser: BrowserInfo): Promise<Buffer> { function deriveKey(password: string, iterations: number): Buffer {
const cached = keyCache.get(browser.keychainService); return crypto.pbkdf2Sync(password, 'saltysalt', iterations, 16, 'sha1');
if (cached) return cached; }
const password = await getKeychainPassword(browser.keychainService); function getCachedDerivedKey(cacheKey: string, password: string, iterations: number): Buffer {
const derived = crypto.pbkdf2Sync(password, 'saltysalt', 1003, 16, 'sha1'); const cached = keyCache.get(cacheKey);
keyCache.set(browser.keychainService, derived); if (cached) return cached;
const derived = deriveKey(password, iterations);
keyCache.set(cacheKey, derived);
return 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. // 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. // macOS may show an Allow/Deny dialog that blocks until the user responds.
const proc = Bun.spawn( const proc = Bun.spawn(
@@ -341,6 +425,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 ──────────────────────────────── // ─── Internal: Cookie Decryption ────────────────────────────────
interface RawCookie { interface RawCookie {
@@ -356,7 +481,7 @@ interface RawCookie {
samesite: number; samesite: number;
} }
function decryptCookieValue(row: RawCookie, key: Buffer): string { function decryptCookieValue(row: RawCookie, keys: Map<string, Buffer>): string {
// Prefer unencrypted value if present // Prefer unencrypted value if present
if (row.value && row.value.length > 0) return row.value; 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 ''; if (ev.length === 0) return '';
const prefix = ev.slice(0, 3).toString('utf-8'); const prefix = ev.slice(0, 3).toString('utf-8');
if (prefix !== 'v10') { const key = keys.get(prefix);
throw new Error(`Unknown encryption prefix: ${prefix}`); if (!key) throw new Error(`No decryption key available for ${prefix} cookies`);
}
const ciphertext = ev.slice(3); const ciphertext = ev.slice(3);
const iv = Buffer.alloc(16, 0x20); // 16 space characters const iv = Buffer.alloc(16, 0x20); // 16 space characters
const decipher = crypto.createDecipheriv('aes-128-cbc', key, iv); const decipher = crypto.createDecipheriv('aes-128-cbc', key, iv);
const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]); 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 ''; if (plaintext.length <= 32) return '';
return plaintext.slice(32).toString('utf-8'); return plaintext.slice(32).toString('utf-8');
} }

View File

@@ -6,7 +6,7 @@
*/ */
import type { BrowserManager } from './browser-manager'; 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 { validateNavigationUrl } from './url-validation';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
@@ -333,7 +333,7 @@ export async function handleWriteCommand(
const browsers = findInstalledBrowsers(); const browsers = findInstalledBrowsers();
if (browsers.length === 0) { 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`; const pickerUrl = `http://127.0.0.1:${port}/cookie-picker`;

View File

@@ -13,7 +13,7 @@
* Remaining bytes = actual cookie value * 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 { Database } from 'bun:sqlite';
import * as crypto from 'crypto'; import * as crypto from 'crypto';
import * as fs from 'fs'; import * as fs from 'fs';
@@ -24,16 +24,26 @@ import * as os from 'os';
const TEST_PASSWORD = 'test-keychain-password'; const TEST_PASSWORD = 'test-keychain-password';
const TEST_KEY = crypto.pbkdf2Sync(TEST_PASSWORD, 'saltysalt', 1003, 16, 'sha1'); 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 IV = Buffer.alloc(16, 0x20);
const CHROMIUM_EPOCH_OFFSET = 11644473600000000n; const CHROMIUM_EPOCH_OFFSET = 11644473600000000n;
// Fixture DB path // Fixture DB path
const FIXTURE_DIR = path.join(import.meta.dir, 'fixtures'); const FIXTURE_DIR = path.join(import.meta.dir, 'fixtures');
const FIXTURE_DB = path.join(FIXTURE_DIR, 'test-cookies.db'); const FIXTURE_DB = path.join(FIXTURE_DIR, 'test-cookies.db');
const LINUX_FIXTURE_DB = path.join(FIXTURE_DIR, 'test-cookies-linux.db');
// ─── Encryption Helper ────────────────────────────────────────── // ─── 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 // 32-byte HMAC tag (random for test) + actual value
const hmacTag = crypto.randomBytes(32); const hmacTag = crypto.randomBytes(32);
const plaintext = Buffer.concat([hmacTag, Buffer.from(value, 'utf-8')]); 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 padLen = blockSize - (plaintext.length % blockSize);
const padded = Buffer.concat([plaintext, Buffer.alloc(padLen, padLen)]); 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 cipher.setAutoPadding(false); // We padded manually
const encrypted = Buffer.concat([cipher.update(padded), cipher.final()]); const encrypted = Buffer.concat([cipher.update(padded), cipher.final()]);
// Prefix with "v10" return Buffer.concat([Buffer.from(prefix), encrypted]);
return Buffer.concat([Buffer.from('v10'), encrypted]);
} }
function chromiumEpoch(unixSeconds: number): bigint { function chromiumEpoch(unixSeconds: number): bigint {
@@ -57,11 +66,11 @@ function chromiumEpoch(unixSeconds: number): bigint {
// ─── Create Fixture Database ──────────────────────────────────── // ─── Create Fixture Database ────────────────────────────────────
function createFixtureDb() { function createFixtureDb(dbPath: string): Database {
fs.mkdirSync(FIXTURE_DIR, { recursive: true }); 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 ( db.run(`CREATE TABLE cookies (
host_key TEXT NOT NULL, host_key TEXT NOT NULL,
name TEXT NOT NULL, name TEXT NOT NULL,
@@ -74,7 +83,11 @@ function createFixtureDb() {
has_expires INTEGER NOT NULL DEFAULT 0, has_expires INTEGER NOT NULL DEFAULT 0,
samesite INTEGER NOT NULL DEFAULT 1 samesite INTEGER NOT NULL DEFAULT 1
)`); )`);
return db;
}
function createMacFixtureDb() {
const db = createFixtureDb(FIXTURE_DB);
const insert = db.prepare(`INSERT INTO cookies const insert = db.prepare(`INSERT INTO cookies
(host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, has_expires, samesite) (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, has_expires, samesite)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`); VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
@@ -110,6 +123,21 @@ function createFixtureDb() {
db.close(); 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 ───────────────────────────────────────────────── // ─── Mock Setup ─────────────────────────────────────────────────
// We need to mock: // We need to mock:
// 1. The Keychain access (getKeychainPassword) to return TEST_PASSWORD // 1. The Keychain access (getKeychainPassword) to return TEST_PASSWORD
@@ -120,17 +148,18 @@ let findInstalledBrowsers: any;
let listDomains: any; let listDomains: any;
let importCookies: any; let importCookies: any;
let CookieImportError: any; let CookieImportError: any;
let originalSpawn: typeof Bun.spawn;
beforeAll(async () => { beforeAll(async () => {
createFixtureDb(); createMacFixtureDb();
createLinuxFixtureDb();
// Mock Bun.spawn to return test password for keychain access // Mock Bun.spawn to return test password for keychain access
const origSpawn = Bun.spawn; originalSpawn = Bun.spawn;
// @ts-ignore - monkey-patching for test // @ts-ignore - monkey-patching for test
Bun.spawn = function(cmd: any, opts: any) { Bun.spawn = function(cmd: any, opts: any) {
// Intercept security find-generic-password calls // Intercept security find-generic-password calls
if (Array.isArray(cmd) && cmd[0] === 'security' && cmd[1] === 'find-generic-password') { 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 test password for any known test service
return { return {
stdout: new ReadableStream({ stdout: new ReadableStream({
@@ -146,8 +175,23 @@ beforeAll(async () => {
kill: () => {}, 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 // Pass through other spawn calls
return origSpawn(cmd, opts); return originalSpawn(cmd, opts);
}; };
// Import the module (uses our mocked Bun.spawn) // Import the module (uses our mocked Bun.spawn)
@@ -159,8 +203,12 @@ beforeAll(async () => {
}); });
afterAll(() => { afterAll(() => {
// Restore Bun.spawn
// @ts-ignore - monkey-patching for test
Bun.spawn = originalSpawn;
// Clean up fixture DB // Clean up fixture DB
try { fs.unlinkSync(FIXTURE_DB); } catch {} try { fs.unlinkSync(FIXTURE_DB); } catch {}
try { fs.unlinkSync(LINUX_FIXTURE_DB); } catch {}
try { fs.rmdirSync(FIXTURE_DIR); } catch {} try { fs.rmdirSync(FIXTURE_DIR); } catch {}
}); });
@@ -176,6 +224,35 @@ afterAll(() => {
// 2. Decrypting them with the module's decryption logic // 2. Decrypting them with the module's decryption logic
// The actual DB path resolution is tested separately. // 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 ────────────────────────────────────────────────────── // ─── Tests ──────────────────────────────────────────────────────
describe('Cookie Import Browser', () => { describe('Cookie Import Browser', () => {
@@ -351,6 +428,51 @@ describe('Cookie Import Browser', () => {
expect(b).toHaveProperty('aliases'); 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', () => { describe('Corrupt Data Handling', () => {

View File

@@ -2,10 +2,10 @@
name: setup-browser-cookies name: setup-browser-cookies
version: 1.0.0 version: 1.0.0
description: | description: |
Import cookies from your real browser (Comet, Chrome, Arc, Brave, Edge) into the Import cookies from your real Chromium browser into the headless browse session.
headless browse session. Opens an interactive picker UI where you select which Opens an interactive picker UI where you select which cookie domains to import.
cookie domains to import. Use before QA testing authenticated pages. Use when asked Use before QA testing authenticated pages. Use when asked to "import cookies",
to "import cookies", "login to the site", or "authenticate the browser". "login to the site", or "authenticate the browser".
allowed-tools: allowed-tools:
- Bash - Bash
- Read - Read
@@ -258,7 +258,7 @@ If `NEEDS_SETUP`:
$B cookie-import-browser $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: an interactive picker UI in your default browser where you can:
- Switch between installed browsers - Switch between installed browsers
- Search domains - Search domains
@@ -289,7 +289,8 @@ Show the user a summary of imported cookies (domain counts).
## Notes ## 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) - 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 - 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 - The browse session persists cookies between commands, so imported cookies work immediately

View File

@@ -2,10 +2,10 @@
name: setup-browser-cookies name: setup-browser-cookies
version: 1.0.0 version: 1.0.0
description: | description: |
Import cookies from your real browser (Comet, Chrome, Arc, Brave, Edge) into the Import cookies from your real Chromium browser into the headless browse session.
headless browse session. Opens an interactive picker UI where you select which Opens an interactive picker UI where you select which cookie domains to import.
cookie domains to import. Use before QA testing authenticated pages. Use when asked Use before QA testing authenticated pages. Use when asked to "import cookies",
to "import cookies", "login to the site", or "authenticate the browser". "login to the site", or "authenticate the browser".
allowed-tools: allowed-tools:
- Bash - Bash
- Read - Read
@@ -37,7 +37,7 @@ Import logged-in sessions from your real Chromium browser into the headless brow
$B cookie-import-browser $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: an interactive picker UI in your default browser where you can:
- Switch between installed browsers - Switch between installed browsers
- Search domains - Search domains
@@ -68,7 +68,8 @@ Show the user a summary of imported cookies (domain counts).
## Notes ## 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) - 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 - 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 - The browse session persists cookies between commands, so imported cookies work immediately