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
+1 -1
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/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<T>` (O(1) ring buffer) + console/network/dialog capture with async disk flush. |
+1 -1
View File
@@ -416,7 +416,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 |
+1 -1
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]' },
+172 -48
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,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<string, Buffer>();
* 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<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 +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 ────────────────────────────────
interface RawCookie {
@@ -356,7 +481,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 +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');
}
+2 -2
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';
@@ -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`;
+134 -12
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', () => {
+7 -6
View File
@@ -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
+7 -6
View File
@@ -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