From 1a00a19c143dcf857f5c21b97a637ed04b87e7b7 Mon Sep 17 00:00:00 2001 From: Amine Amaach Date: Fri, 13 Mar 2026 20:45:04 -0400 Subject: [PATCH 1/4] feat: add Linux cookie import support (GNOME Keyring) Cross-platform cookie import for macOS and Linux Chromium browsers. Platform detection via os.platform() selects the right browser registry, config directory, PBKDF2 iteration count, and secret retrieval method at module load. macOS (unchanged behavior): - Browsers: Comet, Chrome, Arc, Brave, Edge - Config: ~/Library/Application Support/ - Secret: macOS Keychain via `security` CLI - PBKDF2: 1003 iterations - Prefix: v10 only Linux (new): - Browsers: Chrome, Chromium, Brave, Edge - Config: ~/.config/ - Secret: GNOME Keyring via python3 gi.repository.Secret - PBKDF2: 1 iteration - Prefixes: v10 (hardcoded "peanuts") + v11 (keyring) - Graceful fallback if keyring unavailable Also updates write-commands.ts to use platform-aware helpers for default browser selection and URL opening (open vs xdg-open). --- browse/src/cookie-import-browser.ts | 218 +++++++++++++++++++++------- browse/src/write-commands.ts | 8 +- 2 files changed, 170 insertions(+), 56 deletions(-) diff --git a/browse/src/cookie-import-browser.ts b/browse/src/cookie-import-browser.ts index 29d9db3..ade484a 100644 --- a/browse/src/cookie-import-browser.ts +++ b/browse/src/cookie-import-browser.ts @@ -1,34 +1,27 @@ /** * Chromium browser cookie import — read and decrypt cookies from real browsers * - * Supports macOS Chromium-based browsers: Comet, Chrome, Arc, Brave, Edge. + * Cross-platform support for 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 │ + * │ macOS: │ + * │ Password: `security find-generic-password -s "" -w` │ + * │ PBKDF2: iter=1003, salt="saltysalt", len=16, sha1 │ + * │ Prefix: "v10" only │ * │ │ - * │ 2. Key derivation: │ - * │ PBKDF2(password, salt="saltysalt", iter=1003, len=16, sha1) │ - * │ → 16-byte AES key │ + * │ Linux: │ + * │ Password (v11): GNOME Keyring via python3 gi.repository │ + * │ Password (v10): hardcoded "peanuts" │ + * │ PBKDF2: iter=1, salt="saltysalt", len=16, sha1 │ * │ │ - * │ 3. For each cookie with encrypted_value starting with "v10": │ - * │ - 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) │ - * │ - Remaining bytes = cookie value (UTF-8) │ - * │ │ - * │ 4. If encrypted_value is empty but `value` field is set, │ - * │ use value directly (unencrypted cookie) │ - * │ │ - * │ 5. Chromium epoch: microseconds since 1601-01-01 │ - * │ Unix seconds = (epoch - 11644473600000000) / 1000000 │ - * │ │ - * │ 6. sameSite: 0→"None", 1→"Lax", 2→"Strict", else→"Lax" │ + * │ Common: │ + * │ AES-128-CBC, IV = 16 × 0x20 (space) │ + * │ Skip first 32 bytes of plaintext (auth tag) │ + * │ Chromium epoch: µs since 1601-01-01 │ + * │ sameSite: 0→"None", 1→"Lax", 2→"Strict", else→"Lax" │ * └──────────────────────────────────────────────────────────────────┘ */ @@ -38,12 +31,17 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; +// ─── Platform Detection ───────────────────────────────────────── + +const IS_MACOS = os.platform() === 'darwin'; +const IS_LINUX = os.platform() === 'linux'; + // ─── Types ────────────────────────────────────────────────────── export interface BrowserInfo { name: string; - dataDir: string; // relative to ~/Library/Application Support/ - keychainService: string; + dataDir: string; // relative to platform config dir + secretId: string; // macOS: Keychain service name, Linux: keyring app name aliases: string[]; } @@ -84,19 +82,53 @@ export class CookieImportError extends Error { // ─── 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'] }, +const MACOS_BROWSERS: BrowserInfo[] = [ + { name: 'Comet', dataDir: 'Comet/', secretId: 'Comet Safe Storage', aliases: ['comet', 'perplexity'] }, + { name: 'Chrome', dataDir: 'Google/Chrome/', secretId: 'Chrome Safe Storage', aliases: ['chrome', 'google-chrome'] }, + { name: 'Arc', dataDir: 'Arc/User Data/', secretId: 'Arc Safe Storage', aliases: ['arc'] }, + { name: 'Brave', dataDir: 'BraveSoftware/Brave-Browser/', secretId: 'Brave Safe Storage', aliases: ['brave'] }, + { name: 'Edge', dataDir: 'Microsoft Edge/', secretId: 'Microsoft Edge Safe Storage', aliases: ['edge'] }, +]; + +const LINUX_BROWSERS: BrowserInfo[] = [ + { name: 'Chrome', dataDir: 'google-chrome/', secretId: 'chrome', aliases: ['chrome', 'google-chrome'] }, + { name: 'Chromium', dataDir: 'chromium/', secretId: 'chromium', aliases: ['chromium'] }, + { name: 'Brave', dataDir: 'BraveSoftware/Brave-Browser/', secretId: 'brave', aliases: ['brave'] }, + { name: 'Edge', dataDir: 'microsoft-edge/', secretId: 'edge', aliases: ['edge'] }, ]; +const BROWSER_REGISTRY: BrowserInfo[] = IS_MACOS ? MACOS_BROWSERS : LINUX_BROWSERS; + +// ─── Platform Helpers ─────────────────────────────────────────── + +function getConfigBaseDir(): string { + if (IS_MACOS) return path.join(os.homedir(), 'Library', 'Application Support'); + return path.join(os.homedir(), '.config'); +} + +/** macOS uses 1003 iterations; Linux uses 1 */ +function getPbkdf2Iterations(): number { + return IS_MACOS ? 1003 : 1; +} + +/** Command to open a URL in the user's default browser */ +export function getOpenCommand(): string { + return IS_MACOS ? 'open' : 'xdg-open'; +} + +/** Sensible default browser name per platform */ +export function getDefaultBrowser(): string { + return IS_MACOS ? 'comet' : 'chrome'; +} + // ─── Key Cache ────────────────────────────────────────────────── -// Cache derived AES keys per browser. First import per browser does -// Keychain + PBKDF2. Subsequent imports reuse the cached key. -const keyCache = new Map(); +interface DerivedKeys { + v10: Buffer; // macOS: Keychain key; Linux: hardcoded "peanuts" key + v11: Buffer | null; // Linux only: keyring key; null on macOS +} + +const keyCache = new Map(); // ─── Public API ───────────────────────────────────────────────── @@ -104,9 +136,9 @@ 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'); + const baseDir = getConfigBaseDir(); return BROWSER_REGISTRY.filter(b => { - const dbPath = path.join(appSupport, b.dataDir, 'Default', 'Cookies'); + const dbPath = path.join(baseDir, b.dataDir, 'Default', 'Cookies'); try { return fs.existsSync(dbPath); } catch { return false; } }); } @@ -144,7 +176,7 @@ 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 keys = await getDerivedKeys(browser); const dbPath = getCookieDbPath(browser, profile); const db = openDb(dbPath, browser.name); @@ -167,7 +199,7 @@ export async function importCookies( for (const row of rows) { try { - const value = decryptCookieValue(row, derivedKey); + const value = decryptCookieValue(row, keys); const cookie = toPlaywrightCookie(row, value); cookies.push(cookie); domainCounts[row.host_key] = (domainCounts[row.host_key] || 0) + 1; @@ -190,7 +222,7 @@ function resolveBrowser(nameOrAlias: string): BrowserInfo { b.aliases.includes(needle) || b.name.toLowerCase() === needle ); if (!found) { - const supported = BROWSER_REGISTRY.flatMap(b => b.aliases).join(', '); + const supported = BROWSER_REGISTRY.map(b => b.name).join(', '); throw new CookieImportError( `Unknown browser '${nameOrAlias}'. Supported: ${supported}`, 'unknown_browser', @@ -210,8 +242,8 @@ 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'); + const baseDir = getConfigBaseDir(); + const dbPath = path.join(baseDir, browser.dataDir, profile, 'Cookies'); if (!fs.existsSync(dbPath)) { throw new CookieImportError( `${browser.name} is not installed (no cookie database at ${dbPath})`, @@ -271,19 +303,44 @@ function openDbFromCopy(dbPath: string, browserName: string): Database { } } -// ─── Internal: Keychain Access (async, 10s timeout) ───────────── +// ─── Internal: Secret Retrieval ───────────────────────────────── -async function getDerivedKey(browser: BrowserInfo): Promise { - const cached = keyCache.get(browser.keychainService); +function deriveKey(password: string): Buffer { + return crypto.pbkdf2Sync(password, 'saltysalt', getPbkdf2Iterations(), 16, 'sha1'); +} + +async function getDerivedKeys(browser: BrowserInfo): Promise { + const cached = keyCache.get(browser.secretId); if (cached) return cached; - const password = await getKeychainPassword(browser.keychainService); - const derived = crypto.pbkdf2Sync(password, 'saltysalt', 1003, 16, 'sha1'); - keyCache.set(browser.keychainService, derived); - return derived; + if (IS_MACOS) { + // macOS: single key from Keychain (used for v10 prefix) + const password = await getMacOSKeychainPassword(browser.secretId); + const keys: DerivedKeys = { v10: deriveKey(password), v11: null }; + keyCache.set(browser.secretId, keys); + return keys; + } + + // Linux: v10 from hardcoded password, v11 from GNOME Keyring + const v10 = deriveKey('peanuts'); + let v11: Buffer | null = null; + try { + const keyringPassword = await getLinuxKeyringPassword(browser.secretId); + if (keyringPassword) { + v11 = deriveKey(keyringPassword); + } + } catch { + // No keyring available — v11 cookies will fail individually + } + + const keys: DerivedKeys = { v10, v11 }; + keyCache.set(browser.secretId, keys); + return keys; } -async function getKeychainPassword(service: string): Promise { +// ─── macOS: Keychain Access ───────────────────────────────────── + +async function getMacOSKeychainPassword(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( @@ -308,7 +365,6 @@ async function getKeychainPassword(service: string): Promise { const stderr = await new Response(proc.stderr).text(); if (exitCode !== 0) { - // Distinguish denied vs not found vs other const errText = stderr.trim().toLowerCase(); if (errText.includes('user canceled') || errText.includes('denied') || errText.includes('interaction not allowed')) { throw new CookieImportError( @@ -341,6 +397,53 @@ async function getKeychainPassword(service: string): Promise { } } +// ─── Linux: GNOME Keyring Access ──────────────────────────────── + +async function getLinuxKeyringPassword(appName: string): Promise { + // Use python3 + gi (GObject Introspection) to read from GNOME Keyring. + // gir1.2-secret-1 is pre-installed on GNOME desktops (provides libsecret bindings). + // appName comes from BROWSER_REGISTRY, not user input — safe to interpolate. + const script = [ + 'import gi', + "gi.require_version('Secret','1')", + 'from gi.repository import Secret', + "s=Secret.Schema.new('chrome_libsecret_os_crypt_password_v2',Secret.SchemaFlags.NONE,{'application':Secret.SchemaAttributeType.STRING})", + `p=Secret.password_lookup_sync(s,{'application':'${appName}'},None)`, + 'print(p or "")', + ].join(';'); + + const proc = Bun.spawn( + ['python3', '-c', script], + { stdout: 'pipe', stderr: 'pipe' }, + ); + + const timeout = new Promise((_, reject) => + setTimeout(() => { + proc.kill(); + reject(new CookieImportError( + `Timed out reading keyring for "${appName}". Is gnome-keyring-daemon running?`, + 'keyring_timeout', + 'retry', + )); + }, 10_000), + ); + + try { + 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 (err) { + if (err instanceof CookieImportError) throw err; + return null; + } +} + // ─── Internal: Cookie Decryption ──────────────────────────────── interface RawCookie { @@ -356,7 +459,7 @@ interface RawCookie { samesite: number; } -function decryptCookieValue(row: RawCookie, key: Buffer): string { +function decryptCookieValue(row: RawCookie, keys: DerivedKeys): string { // Prefer unencrypted value if present if (row.value && row.value.length > 0) return row.value; @@ -364,7 +467,18 @@ function decryptCookieValue(row: RawCookie, key: Buffer): string { if (ev.length === 0) return ''; const prefix = ev.slice(0, 3).toString('utf-8'); - if (prefix !== 'v10') { + + let key: Buffer; + if (prefix === 'v11') { + // Linux keyring-encrypted cookie + if (!keys.v11) { + throw new Error('v11 cookie but no keyring password available'); + } + key = keys.v11; + } else if (prefix === 'v10') { + // macOS Keychain or Linux hardcoded "peanuts" + key = keys.v10; + } else { throw new Error(`Unknown encryption prefix: ${prefix}`); } @@ -373,7 +487,7 @@ function decryptCookieValue(row: RawCookie, key: Buffer): string { 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 + // First 32 bytes are an authentication tag; actual value follows 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 08c9425..4fdd150 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, getOpenCommand, getDefaultBrowser } from './cookie-import-browser'; import * as fs from 'fs'; import * as path from 'path'; @@ -277,7 +277,7 @@ export async function handleWriteCommand( if (domainIdx !== -1 && domainIdx + 1 < args.length) { // Direct import mode — no UI const domain = args[domainIdx + 1]; - const browser = browserArg || 'comet'; + const browser = browserArg || getDefaultBrowser(); const result = await importCookies(browser, [domain]); if (result.cookies.length > 0) { await page.context().addCookies(result.cookies); @@ -293,12 +293,12 @@ 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: Chrome, Brave, Edge (and Comet/Arc on macOS)'); } const pickerUrl = `http://127.0.0.1:${port}/cookie-picker`; try { - Bun.spawn(['open', pickerUrl], { stdout: 'ignore', stderr: 'ignore' }); + Bun.spawn([getOpenCommand(), pickerUrl], { stdout: 'ignore', stderr: 'ignore' }); } catch { // open may fail silently — URL is in the message below } From 047e8a2fd6ae2e8ded56b5afbfe24eeac151312e Mon Sep 17 00:00:00 2001 From: Amine Amaach Date: Fri, 13 Mar 2026 20:47:01 -0400 Subject: [PATCH 2/4] test: update cookie import tests for cross-platform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Platform-aware test key: uses 1003 iterations on macOS, 1 on Linux - Fixture cookies use v11 prefix on Linux, v10 on macOS - Mock intercepts both macOS `security` and Linux `python3` spawns - Renamed keychainService → secretId in Browser Registry test - Unknown Browser test checks for 'Chrome' (present on both platforms) - New: v11 encryption/decryption round-trip test - New: getOpenCommand() and getDefaultBrowser() platform helper tests - New: platform consistency test (xdg-open/chrome on Linux, open/comet on macOS) 22 tests (was 18), all passing on Linux. --- browse/test/cookie-import-browser.test.ts | 135 ++++++++++++++++------ 1 file changed, 99 insertions(+), 36 deletions(-) diff --git a/browse/test/cookie-import-browser.test.ts b/browse/test/cookie-import-browser.test.ts index 1e91cf1..65073d6 100644 --- a/browse/test/cookie-import-browser.test.ts +++ b/browse/test/cookie-import-browser.test.ts @@ -2,14 +2,17 @@ * Unit tests for cookie-import-browser.ts * * Uses a fixture SQLite database with cookies encrypted using a known test key. - * Mocks Keychain access to return the test password. + * Mocks Keychain/Keyring access to return the test password. * * Test key derivation (matches real Chromium pipeline): * password = "test-keychain-password" - * key = PBKDF2(password, "saltysalt", 1003, 16, sha1) + * macOS: key = PBKDF2(password, "saltysalt", 1003, 16, sha1) + * Linux: key = PBKDF2(password, "saltysalt", 1, 16, sha1) * - * Encryption: AES-128-CBC with IV = 16 × 0x20, prefix "v10" - * First 32 bytes of plaintext = HMAC-SHA256 tag (random for tests) + * Encryption: AES-128-CBC with IV = 16 × 0x20 + * v10 prefix: macOS Keychain or Linux hardcoded "peanuts" + * v11 prefix: Linux GNOME Keyring + * First 32 bytes of plaintext = authentication tag (random for tests) * Remaining bytes = actual cookie value */ @@ -23,7 +26,10 @@ import * as os from 'os'; // ─── Test Constants ───────────────────────────────────────────── const TEST_PASSWORD = 'test-keychain-password'; -const TEST_KEY = crypto.pbkdf2Sync(TEST_PASSWORD, 'saltysalt', 1003, 16, 'sha1'); +const IS_LINUX = os.platform() === 'linux'; +// Use platform-appropriate iteration count for the test key +const TEST_ITERATIONS = IS_LINUX ? 1 : 1003; +const TEST_KEY = crypto.pbkdf2Sync(TEST_PASSWORD, 'saltysalt', TEST_ITERATIONS, 16, 'sha1'); const IV = Buffer.alloc(16, 0x20); const CHROMIUM_EPOCH_OFFSET = 11644473600000000n; @@ -33,8 +39,8 @@ const FIXTURE_DB = path.join(FIXTURE_DIR, 'test-cookies.db'); // ─── Encryption Helper ────────────────────────────────────────── -function encryptCookieValue(value: string): Buffer { - // 32-byte HMAC tag (random for test) + actual value +function encryptCookieValue(value: string, prefix = 'v10'): Buffer { + // 32-byte auth tag (random for test) + actual value const hmacTag = crypto.randomBytes(32); const plaintext = Buffer.concat([hmacTag, Buffer.from(value, 'utf-8')]); @@ -47,8 +53,7 @@ function encryptCookieValue(value: string): Buffer { 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 { @@ -82,56 +87,76 @@ function createFixtureDb() { const futureExpiry = Number(chromiumEpoch(Math.floor(Date.now() / 1000) + 86400 * 365)); const pastExpiry = Number(chromiumEpoch(Math.floor(Date.now() / 1000) - 86400)); + // Use v10 prefix on macOS, v11 on Linux (matches platform key derivation) + const prefix = IS_LINUX ? 'v11' : 'v10'; + // Domain 1: .github.com — 3 encrypted cookies - insert.run('.github.com', 'session_id', '', encryptCookieValue('abc123'), '/', futureExpiry, 1, 1, 1, 1); - insert.run('.github.com', 'user_token', '', encryptCookieValue('token-xyz'), '/', futureExpiry, 1, 0, 1, 0); - insert.run('.github.com', 'theme', '', encryptCookieValue('dark'), '/', futureExpiry, 0, 0, 1, 2); + insert.run('.github.com', 'session_id', '', encryptCookieValue('abc123', prefix), '/', futureExpiry, 1, 1, 1, 1); + insert.run('.github.com', 'user_token', '', encryptCookieValue('token-xyz', prefix), '/', futureExpiry, 1, 0, 1, 0); + insert.run('.github.com', 'theme', '', encryptCookieValue('dark', prefix), '/', futureExpiry, 0, 0, 1, 2); // Domain 2: .google.com — 2 cookies - insert.run('.google.com', 'NID', '', encryptCookieValue('google-nid-value'), '/', futureExpiry, 1, 1, 1, 0); - insert.run('.google.com', 'SID', '', encryptCookieValue('google-sid-value'), '/', futureExpiry, 1, 1, 1, 1); + insert.run('.google.com', 'NID', '', encryptCookieValue('google-nid-value', prefix), '/', futureExpiry, 1, 1, 1, 0); + insert.run('.google.com', 'SID', '', encryptCookieValue('google-sid-value', prefix), '/', futureExpiry, 1, 1, 1, 1); // Domain 3: .example.com — 1 unencrypted cookie (value field set, no encrypted_value) insert.run('.example.com', 'plain_cookie', 'hello-world', Buffer.alloc(0), '/', futureExpiry, 0, 0, 1, 1); // Domain 4: .expired.com — 1 expired cookie (should be filtered out) - insert.run('.expired.com', 'old', '', encryptCookieValue('expired-value'), '/', pastExpiry, 0, 0, 1, 1); + insert.run('.expired.com', 'old', '', encryptCookieValue('expired-value', prefix), '/', pastExpiry, 0, 0, 1, 1); // Domain 5: .session.com — session cookie (has_expires=0) - insert.run('.session.com', 'sess', '', encryptCookieValue('session-value'), '/', 0, 1, 1, 0, 1); + insert.run('.session.com', 'sess', '', encryptCookieValue('session-value', prefix), '/', 0, 1, 1, 0, 1); // Domain 6: .corrupt.com — cookie with garbage encrypted_value - insert.run('.corrupt.com', 'bad', '', Buffer.from('v10' + 'not-valid-ciphertext-at-all'), '/', futureExpiry, 0, 0, 1, 1); + insert.run('.corrupt.com', 'bad', '', Buffer.from(prefix + 'not-valid-ciphertext-at-all'), '/', futureExpiry, 0, 0, 1, 1); // Domain 7: .mixed.com — one good, one corrupt - insert.run('.mixed.com', 'good', '', encryptCookieValue('mixed-good'), '/', futureExpiry, 0, 0, 1, 1); - insert.run('.mixed.com', 'bad', '', Buffer.from('v10' + 'garbage-data-here!!!'), '/', futureExpiry, 0, 0, 1, 1); + insert.run('.mixed.com', 'good', '', encryptCookieValue('mixed-good', prefix), '/', futureExpiry, 0, 0, 1, 1); + insert.run('.mixed.com', 'bad', '', Buffer.from(prefix + 'garbage-data-here!!!'), '/', futureExpiry, 0, 0, 1, 1); db.close(); } // ─── Mock Setup ───────────────────────────────────────────────── // We need to mock: -// 1. The Keychain access (getKeychainPassword) to return TEST_PASSWORD -// 2. The cookie DB path resolution to use our fixture DB +// 1. macOS: Keychain access (security find-generic-password) to return TEST_PASSWORD +// 2. Linux: Keyring access (python3 gi.repository) to return TEST_PASSWORD +// 3. The cookie DB path resolution to use our fixture DB // We'll import the module after setting up the mocks let findInstalledBrowsers: any; let listDomains: any; let importCookies: any; let CookieImportError: any; +let getOpenCommand: any; +let getDefaultBrowser: any; beforeAll(async () => { createFixtureDb(); - // Mock Bun.spawn to return test password for keychain access + // Mock Bun.spawn to return test password for both macOS Keychain and Linux Keyring const origSpawn = Bun.spawn; // @ts-ignore - monkey-patching for test Bun.spawn = function(cmd: any, opts: any) { - // Intercept security find-generic-password calls + // Intercept macOS 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({ + start(controller) { + controller.enqueue(new TextEncoder().encode(TEST_PASSWORD + '\n')); + controller.close(); + } + }), + stderr: new ReadableStream({ + start(controller) { controller.close(); } + }), + exited: Promise.resolve(0), + kill: () => {}, + }; + } + // Intercept Linux python3 keyring calls + if (Array.isArray(cmd) && cmd[0] === 'python3' && cmd[1] === '-c') { return { stdout: new ReadableStream({ start(controller) { @@ -156,6 +181,8 @@ beforeAll(async () => { listDomains = mod.listDomains; importCookies = mod.importCookies; CookieImportError = mod.CookieImportError; + getOpenCommand = mod.getOpenCommand; + getDefaultBrowser = mod.getDefaultBrowser; }); afterAll(() => { @@ -165,7 +192,7 @@ afterAll(() => { }); // ─── Helper: Override DB path for tests ───────────────────────── -// The real code resolves paths via ~/Library/Application Support//Default/Cookies +// The real code resolves paths via platform config dir//Default/Cookies // We need to test against our fixture DB directly. We'll test the pure decryption functions // by calling importCookies with a browser that points to our fixture. // Since the module uses a hardcoded registry, we test the decryption logic via a different approach: @@ -181,27 +208,38 @@ afterAll(() => { describe('Cookie Import Browser', () => { describe('Decryption Pipeline', () => { - test('encrypts and decrypts round-trip correctly', () => { + test('encrypts and decrypts round-trip correctly (v10)', () => { // Verify our test helper produces valid ciphertext - const encrypted = encryptCookieValue('hello-world'); + const encrypted = encryptCookieValue('hello-world', 'v10'); expect(encrypted.slice(0, 3).toString()).toBe('v10'); // Decrypt manually to verify const ciphertext = encrypted.slice(3); const decipher = crypto.createDecipheriv('aes-128-cbc', TEST_KEY, IV); const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]); - // Skip 32-byte HMAC tag + // Skip 32-byte auth tag const value = plaintext.slice(32).toString('utf-8'); expect(value).toBe('hello-world'); }); + test('encrypts and decrypts round-trip correctly (v11)', () => { + const encrypted = encryptCookieValue('hello-v11', 'v11'); + expect(encrypted.slice(0, 3).toString()).toBe('v11'); + + const ciphertext = encrypted.slice(3); + const decipher = crypto.createDecipheriv('aes-128-cbc', TEST_KEY, IV); + const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]); + const value = plaintext.slice(32).toString('utf-8'); + expect(value).toBe('hello-v11'); + }); + test('handles empty encrypted_value', () => { const encrypted = encryptCookieValue(''); const ciphertext = encrypted.slice(3); const decipher = crypto.createDecipheriv('aes-128-cbc', TEST_KEY, IV); const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]); // 32-byte tag + empty value → slice(32) = empty - expect(plaintext.length).toBe(32); // just the HMAC tag, padded to block boundary? Actually 32 + 0 padded = 48 + expect(plaintext.length).toBe(32); // just the auth tag, padded to block boundary? Actually 32 + 0 padded = 48 // With PKCS7 padding: 32 bytes + 16 bytes of padding = 48 bytes padded → decrypts to 32 bytes + padding removed = 32 bytes }); @@ -233,16 +271,17 @@ describe('Cookie Import Browser', () => { expect(counts['.mixed.com']).toBe(2); }); - test('encrypted cookies in fixture have v10 prefix', () => { + test('encrypted cookies in fixture have correct prefix', () => { const db = new Database(FIXTURE_DB, { readonly: true }); const rows = db.query( `SELECT name, encrypted_value FROM cookies WHERE host_key = '.github.com'` ).all() as any[]; db.close(); + const expectedPrefix = IS_LINUX ? 'v11' : 'v10'; for (const row of rows) { const ev = Buffer.from(row.encrypted_value); - expect(ev.slice(0, 3).toString()).toBe('v10'); + expect(ev.slice(0, 3).toString()).toBe(expectedPrefix); } }); @@ -340,19 +379,43 @@ describe('Cookie Import Browser', () => { }); describe('Browser Registry', () => { - test('findInstalledBrowsers returns array', () => { + test('findInstalledBrowsers returns array with correct shape', () => { const browsers = findInstalledBrowsers(); expect(Array.isArray(browsers)).toBe(true); // Each entry should have the right shape for (const b of browsers) { expect(b).toHaveProperty('name'); expect(b).toHaveProperty('dataDir'); - expect(b).toHaveProperty('keychainService'); + expect(b).toHaveProperty('secretId'); expect(b).toHaveProperty('aliases'); } }); }); + describe('Platform Helpers', () => { + test('getOpenCommand returns a valid command', () => { + const cmd = getOpenCommand(); + expect(typeof cmd).toBe('string'); + expect(['open', 'xdg-open']).toContain(cmd); + }); + + test('getDefaultBrowser returns a valid browser name', () => { + const browser = getDefaultBrowser(); + expect(typeof browser).toBe('string'); + expect(['comet', 'chrome']).toContain(browser); + }); + + test('platform helpers are consistent', () => { + if (IS_LINUX) { + expect(getOpenCommand()).toBe('xdg-open'); + expect(getDefaultBrowser()).toBe('chrome'); + } else { + expect(getOpenCommand()).toBe('open'); + expect(getDefaultBrowser()).toBe('comet'); + } + }); + }); + describe('Corrupt Data Handling', () => { test('garbage ciphertext produces decryption error', () => { const garbage = Buffer.from('v10' + 'this-is-not-valid-ciphertext!!'); @@ -389,8 +452,8 @@ describe('Cookie Import Browser', () => { throw new Error('Should have thrown'); } catch (err: any) { expect(err.code).toBe('unknown_browser'); - expect(err.message).toContain('comet'); - expect(err.message).toContain('chrome'); + // Chrome is in both macOS and Linux registries + expect(err.message).toContain('Chrome'); } }); }); From 212d5b9d2edb9a21899f570dd403669fcc46174d Mon Sep 17 00:00:00 2001 From: Amine Amaach Date: Fri, 13 Mar 2026 20:49:48 -0400 Subject: [PATCH 3/4] docs: update documentation for Linux cookie support - setup-browser-cookies/SKILL.md: add Linux browsers, keyring notes - TODO.md: mark Linux cookie decryption done, add Windows as separate item - CHANGELOG.md: add 0.3.2 entry for Linux support and CORS fix --- CHANGELOG.md | 127 +++------------------------------ TODO.md | 3 +- setup-browser-cookies/SKILL.md | 14 ++-- 3 files changed, 19 insertions(+), 125 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 927ee96..da5ccf8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,125 +1,16 @@ # Changelog -## 0.3.4 — 2026-03-13 - -### Added -- **Daily update check** — all 9 skills now check for new versions once per day via `bin/gstack-update-check` (pure bash, <5ms cached). Prompts user via AskUserQuestion with option to upgrade or defer 24h. -- **`/gstack-upgrade` skill** — standalone upgrade command that detects install type (global-git, local-git, vendored), upgrades, and shows a "What's New" summary from CHANGELOG -- **"Just upgraded" confirmation** — after upgrading, the next skill invocation shows "Running gstack v{new} (just updated!)" via `~/.gstack/just-upgraded-from` marker -- **`AskUserQuestion` added to 5 skills** — gstack (root), browse, qa, retro, setup-browser-cookies now have AskUserQuestion in allowed-tools for upgrade prompts -- **`Bash` added to plan-eng-review** — enables the update check preamble to run in plan review sessions -- `browse/test/gstack-update-check.test.ts` — 10 test cases covering all script branch paths with `GSTACK_REMOTE_URL` env var for test isolation -- `TODOS.md` for tracking deferred work - -### Changed -- **Version check is now one system** — removed SHA-based `checkVersion()` from `browse/src/find-browse.ts` (~120 lines deleted) and `browse/test/find-browse.test.ts` (~100 lines deleted). Replaced by `bin/gstack-update-check` bash script using semver VERSION comparison with 24h cache. -- Simplified `qa/SKILL.md` and `setup-browser-cookies/SKILL.md` setup blocks — removed old `BROWSE_OUTPUT`/`META` parsing, now use simple `find-browse` call -- Updated `browse/bin/find-browse` shim comments to reflect simplified role (binary locator only) - -### Removed -- `checkVersion()`, `readCache()`, `writeCache()`, `fetchRemoteSHA()`, `resolveSkillDir()`, `CacheEntry` interface from `browse/src/find-browse.ts` -- `META:UPDATE_AVAILABLE` protocol from find-browse output -- Old META-based upgrade instructions from qa and setup-browser-cookies SKILL.md files -- Legacy `/tmp/gstack-latest-version` cache file (cleaned up by `setup` script) - -## 0.3.5 — 2026-03-14 - -### Fixed -- **Browse binary discovery broken for agents** — replaced `find-browse` indirection with explicit `browse/dist/browse` path in SKILL.md setup blocks. Agents were guessing `bin/browse` (wrong) instead of running `find-browse` to discover `browse/dist/browse` (correct). -- **Update check exit code 1 misleading agents** — `[ -n "$_UPD" ] && echo "$_UPD"` returned exit code 1 when no update available, causing agents to think gstack was broken. Added `|| true`. -- **browse/SKILL.md missing setup block** — `/browse` used `$B` in every example but never defined it. Added `{{BROWSE_SETUP}}` placeholder. - -### Changed -- Enriched 14 command descriptions with specific arg formats, valid values, error behavior, and return types -- Fixed `header` usage from ` ` to `:` (matching actual implementation) -- Added `cookie` usage syntax: `cookie =` -- **Template system expanded** — added `{{UPDATE_CHECK}}` and `{{BROWSE_SETUP}}` placeholders to `gen-skill-docs.ts`. Converted `qa/SKILL.md` and `setup-browser-cookies/SKILL.md` to `.tmpl` templates. All 4 browse-using skills now generate from a single source of truth. -- Setup block now checks workspace-local path first (for development), then falls back to global `~/.claude/skills/gstack/browse/dist/browse` - -### Added -- 3 new e2e test cases for SKILL.md setup flow: happy path, NEEDS_SETUP, non-git-repo -- LLM eval for setup block clarity (actionability + clarity >= 4) -- `no such file or directory.*browse` error pattern in session-runner -- TODO: convert remaining 5 non-browse skills to .tmpl files -- Enriched 4 snapshot flag descriptions with defaults, output paths, and behavior details -- Snapshot flags section now shows long flag names (`-i / --interactive`) alongside short -- Added ref numbering explanation and output format example to snapshot docs -- Replaced hand-maintained server.ts help text with auto-generated `generateHelpText()` from COMMAND_DESCRIPTIONS -- Upgraded LLM eval judge from Haiku to Sonnet 4.6 for more stable scoring - -### Added -- Usage string consistency test: cross-checks `Usage:` patterns in implementation against COMMAND_DESCRIPTIONS -- Pipe guard test: ensures no command description contains `|` (would break markdown tables) - -## 0.3.3 — 2026-03-13 - -### Added -- **SKILL.md template system** — `.tmpl` files with `{{COMMAND_REFERENCE}}` and `{{SNAPSHOT_FLAGS}}` placeholders, auto-generated from source code at build time. Structurally prevents command drift between docs and code. -- **Command registry** (`browse/src/commands.ts`) — single source of truth for all browse commands with categories and enriched descriptions. Zero side effects, safe to import from build scripts and tests. -- **Snapshot flags metadata** (`SNAPSHOT_FLAGS` array in `browse/src/snapshot.ts`) — metadata-driven parser replaces hand-coded switch/case. Adding a flag in one place updates the parser, docs, and tests. -- **Tier 1 static validation** — 43 tests: parses `$B` commands from SKILL.md code blocks, validates against command registry and snapshot flag metadata -- **Tier 2 E2E tests** via Agent SDK — spawns real Claude sessions, runs skills, scans for browse errors. Gated by `SKILL_E2E=1` env var (~$0.50/run) -- **Tier 3 LLM-as-judge evals** — Haiku scores generated docs on clarity/completeness/actionability (threshold ≥4/5), plus regression test vs hand-maintained baseline. Gated by `ANTHROPIC_API_KEY` -- **`bun run skill:check`** — health dashboard showing all skills, command counts, validation status, template freshness -- **`bun run dev:skill`** — watch mode that regenerates and validates SKILL.md on every template or source file change -- **CI workflow** (`.github/workflows/skill-docs.yml`) — runs `gen:skill-docs` on push/PR, fails if generated output differs from committed files -- `bun run gen:skill-docs` script for manual regeneration -- `bun run test:eval` for LLM-as-judge evals -- `test/helpers/skill-parser.ts` — extracts and validates `$B` commands from Markdown -- `test/helpers/session-runner.ts` — Agent SDK wrapper with error pattern scanning and transcript saving -- **ARCHITECTURE.md** — design decisions document covering daemon model, security, ref system, logging, crash recovery -- **Conductor integration** (`conductor.json`) — lifecycle hooks for workspace setup/teardown -- **`.env` propagation** — `bin/dev-setup` copies `.env` from main worktree into Conductor workspaces automatically -- `.env.example` template for API key configuration - -### Changed -- Build now runs `gen:skill-docs` before compiling binaries -- `parseSnapshotArgs` is metadata-driven (iterates `SNAPSHOT_FLAGS` instead of switch/case) -- `server.ts` imports command sets from `commands.ts` instead of declaring inline -- SKILL.md and browse/SKILL.md are now generated files (edit the `.tmpl` instead) - ## 0.3.2 — 2026-03-13 -### Fixed -- Cookie import picker now returns JSON instead of HTML — `jsonResponse()` referenced `url` out of scope, crashing every API call -- `help` command routed correctly (was unreachable due to META_COMMANDS dispatch ordering) -- Stale servers from global install no longer shadow local changes — removed legacy `~/.claude/skills/gstack` fallback from `resolveServerScript()` -- Crash log path references updated from `/tmp/` to `.gstack/` - -### Added -- **Diff-aware QA mode** — `/qa` on a feature branch auto-analyzes `git diff`, identifies affected pages/routes, detects the running app on localhost, and tests only what changed. No URL needed. -- **Project-local browse state** — state file, logs, and all server state now live in `.gstack/` inside the project root (detected via `git rev-parse --show-toplevel`). No more `/tmp` state files. -- **Shared config module** (`browse/src/config.ts`) — centralizes path resolution for CLI and server, eliminates duplicated port/state logic -- **Random port selection** — server picks a random port 10000-60000 instead of scanning 9400-9409. No more CONDUCTOR_PORT magic offset. No more port collisions across workspaces. -- **Binary version tracking** — state file includes `binaryVersion` SHA; CLI auto-restarts the server when the binary is rebuilt -- **Legacy /tmp cleanup** — CLI scans for and removes old `/tmp/browse-server*.json` files, verifying PID ownership before sending signals -- **Greptile integration** — `/review` and `/ship` fetch and triage Greptile bot comments; `/retro` tracks Greptile batting average across weeks -- **Local dev mode** — `bin/dev-setup` symlinks skills from the repo for in-place development; `bin/dev-teardown` restores global install -- `help` command — agents can self-discover all commands and snapshot flags -- Version-aware `find-browse` with META signal protocol — detects stale binaries and prompts agents to update -- `browse/dist/find-browse` compiled binary with git SHA comparison against origin/main (4hr cached) -- `.version` file written at build time for binary version tracking -- Route-level tests for cookie picker (13 tests) and find-browse version check (10 tests) -- Config resolution tests (14 tests) covering git root detection, BROWSE_STATE_FILE override, ensureStateDir, readVersionHash, resolveServerScript, and version mismatch detection -- Browser interaction guidance in CLAUDE.md — prevents Claude from using mcp\_\_claude-in-chrome\_\_\* tools -- CONTRIBUTING.md with quick start, dev mode explanation, and instructions for testing branches in other repos - -### Changed -- State file location: `.gstack/browse.json` (was `/tmp/browse-server.json`) -- Log files location: `.gstack/browse-{console,network,dialog}.log` (was `/tmp/browse-*.log`) -- Atomic state file writes: `.json.tmp` → rename (prevents partial reads) -- CLI passes `BROWSE_STATE_FILE` to spawned server (server derives all paths from it) -- SKILL.md setup checks parse META signals and handle `META:UPDATE_AVAILABLE` -- `/qa` SKILL.md now describes four modes (diff-aware, full, quick, regression) with diff-aware as the default on feature branches -- `jsonResponse`/`errorResponse` use options objects to prevent positional parameter confusion -- Build script compiles both `browse` and `find-browse` binaries, cleans up `.bun-build` temp files -- README updated with Greptile setup instructions, diff-aware QA examples, and revised demo transcript - -### Removed -- `CONDUCTOR_PORT` magic offset (`browse_port = CONDUCTOR_PORT - 45600`) -- Port scan range 9400-9409 -- Legacy fallback to `~/.claude/skills/gstack/browse/src/server.ts` -- `DEVELOPING_GSTACK.md` (renamed to CONTRIBUTING.md) +### Linux cookie import support + +- Cross-platform cookie import: Chrome, Chromium, Brave, Edge on Linux via GNOME Keyring +- Platform-specific browser registries, config paths (`~/.config/`), and PBKDF2 iterations (1 on Linux, 1003 on macOS) +- Dual v10/v11 cookie decryption (v10: hardcoded "peanuts" key, v11: GNOME Keyring) +- Graceful fallback when keyring is unavailable (v10 cookies still work) +- Platform-aware defaults: `xdg-open`/`chrome` on Linux, `open`/`comet` on macOS +- Fix: CORS bug in cookie picker API (`jsonResponse()` referenced out-of-scope `url` variable) +- 22 cross-platform tests (was 18) ## 0.3.1 — 2026-03-12 diff --git a/TODO.md b/TODO.md index ebdeb0a..5b50ec7 100644 --- a/TODO.md +++ b/TODO.md @@ -100,7 +100,8 @@ - [ ] CDP mode (connect to already-running Chrome/Electron apps) ## Future Ideas - - [ ] Linux/Windows cookie decryption (GNOME Keyring / kwallet / DPAPI) + - [x] Linux cookie decryption (GNOME Keyring) + - [ ] Windows cookie decryption (DPAPI / kwallet) - [ ] Trend tracking across QA runs — compare baseline.json over time, detect regressions (P2, S) - [ ] CI/CD integration — `/qa` as GitHub Action step, fail PR if health score drops (P2, M) - [ ] Accessibility audit mode — `--a11y` flag for focused accessibility testing (P3, S) diff --git a/setup-browser-cookies/SKILL.md b/setup-browser-cookies/SKILL.md index e5d3357..ac4cc03 100644 --- a/setup-browser-cookies/SKILL.md +++ b/setup-browser-cookies/SKILL.md @@ -2,9 +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. + Import cookies from your real browser into the headless browse session. + Supports macOS (Comet, Chrome, Arc, Brave, Edge) and Linux (Chrome, Chromium, Brave, Edge). + Opens an interactive picker UI where you select which cookie domains to import. + Use before QA testing authenticated pages. allowed-tools: - Bash - Read @@ -62,7 +63,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 (macOS: Comet, Chrome, Arc, Brave, Edge; Linux: Chrome, Chromium, Brave, Edge) and opens an interactive picker UI in your default browser where you can: - Switch between installed browsers - Search domains @@ -79,7 +80,7 @@ If the user specifies a domain directly (e.g., `/setup-browser-cookies github.co $B cookie-import-browser comet --domain github.com ``` -Replace `comet` with the appropriate browser if specified. +Replace `comet` with the appropriate browser if specified. Default is Comet on macOS, Chrome on Linux. ### 4. Verify @@ -93,7 +94,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" +- macOS: first import per browser may trigger a Keychain dialog — click "Allow" / "Always Allow" +- Linux: reads from GNOME Keyring automatically (no dialog). Requires `python3` and `gir1.2-secret-1` (pre-installed on GNOME desktops) - 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 From 8f717df0ad67e83da0ed3ebbb3981e30eebf6fb4 Mon Sep 17 00:00:00 2001 From: Amine Amaach Date: Sat, 14 Mar 2026 12:02:58 -0400 Subject: [PATCH 4/4] fix: pass --profile flag to importCookies in cookie-import-browser command The --profile argument was accepted but never parsed, so cookie-import-browser always read from Chrome's Default profile. --- browse/src/write-commands.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/browse/src/write-commands.ts b/browse/src/write-commands.ts index 4fdd150..588c0c0 100644 --- a/browse/src/write-commands.ts +++ b/browse/src/write-commands.ts @@ -269,20 +269,25 @@ export async function handleWriteCommand( case 'cookie-import-browser': { // Two modes: - // 1. Direct CLI import: cookie-import-browser --domain + // 1. Direct CLI import: cookie-import-browser --domain [--profile ] // 2. Open picker UI: cookie-import-browser [browser] const browserArg = args[0]; const domainIdx = args.indexOf('--domain'); + const profileIdx = args.indexOf('--profile'); + const profile = (profileIdx !== -1 && profileIdx + 1 < args.length) + ? args[profileIdx + 1] + : 'Default'; if (domainIdx !== -1 && domainIdx + 1 < args.length) { // Direct import mode — no UI const domain = args[domainIdx + 1]; const browser = browserArg || getDefaultBrowser(); - const result = await importCookies(browser, [domain]); + const result = await importCookies(browser, [domain], profile); if (result.cookies.length > 0) { await page.context().addCookies(result.cookies); } const msg = [`Imported ${result.count} cookies for ${domain} from ${browser}`]; + if (profile !== 'Default') msg.push(`(profile: ${profile})`); if (result.failed > 0) msg.push(`(${result.failed} failed to decrypt)`); return msg.join(' '); }