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/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..588c0c0 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'; @@ -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 || 'comet'; - const result = await importCookies(browser, [domain]); + const browser = browserArg || getDefaultBrowser(); + 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(' '); } @@ -293,12 +298,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 } 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'); } }); }); 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