mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-06 13:45:35 +02:00
1211b6b40b
* fix: extend tilde-in-assignment fix to design resolver + 4 skill templates PR #993 fixed the Claude Code permission prompt for `scripts/resolvers/browse.ts` and `gstack-upgrade/SKILL.md.tmpl`. Same bug lives in three more places that weren't on the contributor's branch: - `scripts/resolvers/design.ts` (3 spots: D=, B=, and _DESIGN_DIR=) - `design-shotgun/SKILL.md.tmpl` (_DESIGN_DIR=) - `plan-design-review/SKILL.md.tmpl` (_DESIGN_DIR=) - `design-consultation/SKILL.md.tmpl` (_DESIGN_DIR=) - `design-review/SKILL.md.tmpl` (REPORT_DIR=) Replaces bare `~/` with quoted `"$HOME/..."` in the source-of-truth files, then regenerates. `grep -rEn '^[A-Za-z_]+=~/' --include="SKILL.md" .` now returns zero hits across all hosts (claude, codex, cursor, gbrain, hermes). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(openclaw): make native skills codex-friendly (#864) Normalizes YAML frontmatter on the 4 hand-authored OpenClaw skills so stricter parsers like Codex can load them. Codex CLI was rejecting these files with "mapping values are not allowed in this context" on colons inside unquoted description scalars. - Drops non-standard `version` and `metadata` fields - Rewrites descriptions into simple "Use when..." form (no inline colons) - Adds a regression test enforcing strict frontmatter (name + description only) Verified live: Codex CLI now loads the skills without errors. Observed during /codex outside-voice run on the eval-community-prs plan review — Codex stderr tripped on these exact files, which was real-world confirmation the fix is needed. Dropped the connect-chrome changes from the original PR (the symlink removal is out of scope for this fix; keeping connect-chrome -> open-gstack-browser). Co-Authored-By: Cathryn Lavery <cathrynlavery@users.noreply.github.com> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(browse): server persists across Claude Code Bash calls The browse server was dying between Bash tool invocations in Claude Code because: 1. SIGTERM: The Claude Code sandbox sends SIGTERM to all child processes when a Bash command completes. The server received this and called shutdown(), deleting the state file and exiting. 2. Parent watchdog: The server polls BROWSE_PARENT_PID every 15s. When the parent Bash shell exits (killed by sandbox), the watchdog detected it and called shutdown(). Both mechanisms made it impossible to use the browse tool across multiple Bash calls — every new `$B` invocation started a fresh server with no cookies, no page state, and no tabs. Fix: - SIGTERM handler: log and ignore instead of shutdown. Explicit shutdown is still available via the /stop command or SIGINT (Ctrl+C). - Parent watchdog: log once and continue instead of shutdown. The existing idle timeout (30 min) handles eventual cleanup. The /stop command and SIGINT still work for intentional shutdown. Windows behavior is unchanged (uses taskkill /F which bypasses signal handlers). Tested: browse server survives across 5+ separate Bash tool calls in Claude Code, maintaining cookies, page state, and navigation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(browse): gate #994 SIGTERM-ignore to normal mode only PR #994 made browse persist across Claude Code Bash calls by ignoring SIGTERM and parent-PID death, relying on the 30-min idle timeout for eventual cleanup. Codex outside-voice review caught that the idle timeout doesn't apply in two modes: headed mode (/open-gstack-browser) and tunnel mode (/pair-agent). Both early-return from idleCheckInterval. Combined with #994's ignore-SIGTERM, those sessions would leak forever after the user disconnects — a real resource leak on shared machines where multiple /pair-agent sessions come and go. Fix: gate SIGTERM-ignore and parent-PID-watchdog-ignore to normal (headless) mode only. Headed + tunnel modes respect both signals and shutdown cleanly. Idle timeout behavior unchanged. Also documents the deliberate contract change for future contributors — don't re-add global SIGTERM shutdown thinking it's missing; it's intentionally scoped. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: keep cookie picker alive after cli exits Fixes garrytan/gstack#985 * fix: add opencode setup support * feat(browse): add Windows browser path detection and DPAPI cookie decryption - Extend BrowserPlatform to include win32 - Add windowsDataDir to BrowserInfo; populate for Chrome, Edge, Brave, Chromium - getBaseDir('win32') → ~/AppData/Local - findBrowserMatch checks Network/Cookies first on Windows (Chrome 80+) - Add getWindowsAesKey() reading os_crypt.encrypted_key from Local State JSON - Add dpapiDecrypt() via PowerShell ProtectedData.Unprotect (stdin/stdout) - decryptCookieValue branches on platform: AES-256-GCM (Windows) vs AES-128-CBC (mac/linux) - Fix hardcoded /tmp → TEMP_DIR from platform.ts in openDbFromCopy Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(browse): Windows cookie import — profile discovery, v20 detection, CDP fallback Three bugs fixed in cookie-import-browser.ts: - listProfiles() and findInstalledBrowsers() now check Network/Cookies on Windows (Chrome 80+ moved cookies from profile/Cookies to profile/Network/Cookies) - openDb() always uses copy-then-read on Windows (Chrome holds exclusive locks) - decryptCookieValue() detects v20 App-Bound Encryption with specific error code Added CDP-based extraction fallback (importCookiesViaCdp) for v20 cookies: - Launches Chrome headless with --remote-debugging-port on the real profile - Extracts cookies via Network.getAllCookies over CDP WebSocket - Requires Chrome to be closed (v20 keys are path-bound to user-data-dir) - Both cookie picker UI and CLI direct-import paths auto-fall back to CDP Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(browse): document CDP debug port security + log Chrome version on v20 fallback Follow-up to #892 per Codex outside-voice review. Two small additions to the Windows v20 App-Bound Encryption CDP fallback: 1. Inline comment documenting the deliberate security posture of the --remote-debugging-port. Chrome binds it to 127.0.0.1 by default, so the threat model is local-user-only (which is no worse than baseline — local attackers can already read the cookie DB). Random port 9222-9321 is for collision avoidance, not security. Chrome is always killed in finally. 2. One-time Chrome version log on CDP entry via /json/version. When Chrome inevitably changes v20 key format or /json/list shape in a future major version, logs will show exactly which version users are hitting. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: v0.18.1.0 — community wave (6 PRs + hardening) VERSION bump + users-first CHANGELOG entry for the wave: - #993 tilde-in-assignment fix (byliu-labs) - #994 browse server persists across Bash calls (joelgreen) - #996 cookie picker alive after cli exits (voidborne-d) - #864 OpenClaw skills codex-friendly (cathrynlavery) - #982 OpenCode native setup (breakneo) - #892 Windows cookie import + DPAPI + v20 CDP fallback (msr-hickory) Plus 3 follow-up hardening commits we own: - Extended tilde fix to design resolver + 4 more skill templates - Gated #994 SIGTERM-ignore to normal mode only (headed/tunnel preserve shutdown) - Documented CDP debug port security + log Chrome version on v20 fallback Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: review pass — package.json version, import dedup, error context, stale help Findings from /review on the wave PR: - [P1] package.json version was 0.18.0.1 but VERSION is 0.18.1.0, failing test/gen-skill-docs.test.ts:177 "package.json version matches VERSION file". Bumped package.json to 0.18.1.0. - [P2] Duplicate import of cookie-picker-routes in browse/src/server.ts (handleCookiePickerRoute at line 20 + hasActivePicker at line 792). Merged into single import at top. - [P2] cookie-import-browser.ts:494 generic rethrow loses underlying error. Now preserves the message so "ENOENT" vs "JSON parse error" vs "permission denied" are distinguishable in user output. - [P3] setup:46 "Missing value for --host" error message listed an incomplete set of hosts (missing factory, openclaw, hermes, gbrain). Aligned with the "Unknown value" error on line 94. Kept as-is (not real issues): - cookie-import-browser.ts:869 empty catch on Chrome version fetch is the correct pattern for best-effort diagnostics (per slop-scan philosophy in CLAUDE.md — fire-and-forget failures shouldn't throw). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(watchdog): invert test 3 to match merged #994 behavior main #1025 added browse/test/watchdog.test.ts with test 3 expecting the old "watchdog kills server when parent dies" behavior. The merge with this branch's #994 inverted that semantic — the server now STAYS ALIVE on parent death in normal headless mode (multi-step QA across Claude Code Bash calls depends on this). Changes: - Renamed test 3 from "watchdog fires when parent dies" to "server STAYS ALIVE when parent dies (#994)". - Replaced 25s shutdown poll with 20s observation window asserting the server remains alive after the watchdog tick. - Updated docstring to document all 3 watchdog invariants (env-var disable, headed-mode disable, headless persists) and note tunnel-mode coverage gap. Verification: bun test browse/test/watchdog.test.ts → 3 pass, 0 fail (22.7s). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(ci): switch apt mirror to Hetzner to bypass Ubicloud → archive.ubuntu.com timeouts Both build attempts of `.github/docker/Dockerfile.ci` failed at `apt-get update` with persistent connection timeouts to archive.ubuntu.com:80 and security.ubuntu.com:80 — 90+ seconds of "connection timed out" against every Ubuntu IP. Not a transient blip; this PR doesn't touch the Dockerfile, and a re-run reproduced the same failure across all 9 mirror IPs. Root cause: Ubicloud runners (Hetzner FSN1-DC21 per runner output) have unreliable HTTP-port-80 routing to Ubuntu's official archive endpoints. Fix: - Rewrite /etc/apt/sources.list.d/ubuntu.sources (deb822 format in 24.04) to use https://mirror.hetzner.com/ubuntu/packages instead. Hetzner's mirror is publicly accessible from any cloud (not Hetzner-only despite the name) and route-local for Ubicloud's actual host. Solves both reliability and latency. - Add a 3-attempt retry loop around both `apt-get update` calls as belt-and-suspenders. Even Hetzner's mirror can have brief blips, and the retry costs nothing when the first attempt succeeds. Verification: the workflow will rebuild on push. Local `docker build` not practical for a 12-step image with bun + claude + playwright deps + a 10-min cold install. Trusting CI. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(ci): use HTTP for Hetzner apt mirror (base image lacks ca-certificates) Previous commit switched to https://mirror.hetzner.com/... which proved the mirror is reachable and routes correctly (no more 90s timeouts), but exposed a chicken-and-egg: ubuntu:24.04 ships without ca-certificates, and that's exactly the package we're installing. Result: "No system certificates available. Try installing ca-certificates." Fix: use http:// for the Hetzner mirror. Apt's security model verifies package integrity via GPG-signed Release files, not TLS, so HTTP here is no weaker than the upstream defaults (Ubuntu's official sources also default to HTTP for the same reason). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Cathryn Lavery <cathrynlavery@users.noreply.github.com> Co-authored-by: Joel Green <thejoelgreen@gmail.com> Co-authored-by: d 🔹 <258577966+voidborne-d@users.noreply.github.com> Co-authored-by: Break <breakneo@gmail.com> Co-authored-by: Michael Spitzer-Rubenstein <msr.ext@hickory.ai>
1033 lines
38 KiB
TypeScript
1033 lines
38 KiB
TypeScript
/**
|
|
* Chromium browser cookie import — read and decrypt cookies from real browsers
|
|
*
|
|
* Supports macOS, Linux, and Windows Chromium-based browsers.
|
|
* Pure logic module — no Playwright dependency, no HTTP concerns.
|
|
*
|
|
* Decryption pipeline:
|
|
*
|
|
* ┌──────────────────────────────────────────────────────────────────┐
|
|
* │ 1. Resolve the cookie DB from the browser profile dir │
|
|
* │ - macOS: ~/Library/Application Support/<browser>/<profile> │
|
|
* │ - Linux: ~/.config/<browser>/<profile> │
|
|
* │ │
|
|
* │ 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"/ │
|
|
* │ "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 of Chromium cookie metadata │
|
|
* │ - 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" │
|
|
* └──────────────────────────────────────────────────────────────────┘
|
|
*/
|
|
|
|
import { Database } from 'bun:sqlite';
|
|
import * as crypto from 'crypto';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import * as os from 'os';
|
|
import { TEMP_DIR } from './platform';
|
|
|
|
// ─── Types ──────────────────────────────────────────────────────
|
|
|
|
export interface BrowserInfo {
|
|
name: string;
|
|
dataDir: string; // primary storage dir (retained for compatibility with existing callers/tests)
|
|
keychainService: string;
|
|
aliases: string[];
|
|
linuxDataDir?: string;
|
|
linuxApplication?: string;
|
|
windowsDataDir?: string;
|
|
}
|
|
|
|
export interface ProfileEntry {
|
|
name: string; // e.g. "Default", "Profile 1", "Profile 3"
|
|
displayName: string; // human-friendly name from Preferences, or falls back to dir name
|
|
}
|
|
|
|
export interface DomainEntry {
|
|
domain: string;
|
|
count: number;
|
|
}
|
|
|
|
export interface ImportResult {
|
|
cookies: PlaywrightCookie[];
|
|
count: number;
|
|
failed: number;
|
|
domainCounts: Record<string, number>;
|
|
}
|
|
|
|
export interface PlaywrightCookie {
|
|
name: string;
|
|
value: string;
|
|
domain: string;
|
|
path: string;
|
|
expires: number;
|
|
secure: boolean;
|
|
httpOnly: boolean;
|
|
sameSite: 'Strict' | 'Lax' | 'None';
|
|
}
|
|
|
|
export class CookieImportError extends Error {
|
|
constructor(
|
|
message: string,
|
|
public code: string,
|
|
public action?: 'retry',
|
|
) {
|
|
super(message);
|
|
this.name = 'CookieImportError';
|
|
}
|
|
}
|
|
|
|
type BrowserPlatform = 'darwin' | 'linux' | 'win32';
|
|
|
|
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', 'google-chrome-stable'], linuxDataDir: 'google-chrome/', linuxApplication: 'chrome', windowsDataDir: 'Google/Chrome/User Data/' },
|
|
{ name: 'Chromium', dataDir: 'chromium/', keychainService: 'Chromium Safe Storage', aliases: ['chromium'], linuxDataDir: 'chromium/', linuxApplication: 'chromium', windowsDataDir: 'Chromium/User Data/' },
|
|
{ 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', windowsDataDir: 'BraveSoftware/Brave-Browser/User Data/' },
|
|
{ name: 'Edge', dataDir: 'Microsoft Edge/', keychainService: 'Microsoft Edge Safe Storage', aliases: ['edge'], linuxDataDir: 'microsoft-edge/', linuxApplication: 'microsoft-edge', windowsDataDir: 'Microsoft/Edge/User Data/' },
|
|
];
|
|
|
|
// ─── 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<string, Buffer>();
|
|
|
|
// ─── Public API ─────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Find which browsers are installed (have a cookie DB on disk in any profile).
|
|
*/
|
|
export function findInstalledBrowsers(): BrowserInfo[] {
|
|
return BROWSER_REGISTRY.filter(browser => {
|
|
// Check Default profile on any platform
|
|
if (findBrowserMatch(browser, 'Default') !== null) return true;
|
|
// Check numbered profiles (Profile 1, Profile 2, etc.)
|
|
for (const platform of getSearchPlatforms()) {
|
|
const dataDir = getDataDirForPlatform(browser, platform);
|
|
if (!dataDir) continue;
|
|
const browserDir = path.join(getBaseDir(platform), dataDir);
|
|
try {
|
|
const entries = fs.readdirSync(browserDir, { withFileTypes: true });
|
|
if (entries.some(e => {
|
|
if (!e.isDirectory() || !e.name.startsWith('Profile ')) return false;
|
|
const profileDir = path.join(browserDir, e.name);
|
|
return fs.existsSync(path.join(profileDir, 'Cookies'))
|
|
|| (platform === 'win32' && fs.existsSync(path.join(profileDir, 'Network', 'Cookies')));
|
|
})) return true;
|
|
} catch {}
|
|
}
|
|
return false;
|
|
});
|
|
}
|
|
|
|
export function listSupportedBrowserNames(): string[] {
|
|
const hostPlatform = getHostPlatform();
|
|
return BROWSER_REGISTRY
|
|
.filter(browser => hostPlatform ? getDataDirForPlatform(browser, hostPlatform) !== null : true)
|
|
.map(browser => browser.name);
|
|
}
|
|
|
|
/**
|
|
* List available profiles for a browser.
|
|
*/
|
|
export function listProfiles(browserName: string): ProfileEntry[] {
|
|
const browser = resolveBrowser(browserName);
|
|
const profiles: ProfileEntry[] = [];
|
|
|
|
// Scan each supported platform for profile directories
|
|
for (const platform of getSearchPlatforms()) {
|
|
const dataDir = getDataDirForPlatform(browser, platform);
|
|
if (!dataDir) continue;
|
|
const browserDir = path.join(getBaseDir(platform), dataDir);
|
|
if (!fs.existsSync(browserDir)) continue;
|
|
|
|
let entries: fs.Dirent[];
|
|
try {
|
|
entries = fs.readdirSync(browserDir, { withFileTypes: true });
|
|
} catch {
|
|
continue;
|
|
}
|
|
|
|
for (const entry of entries) {
|
|
if (!entry.isDirectory()) continue;
|
|
if (entry.name !== 'Default' && !entry.name.startsWith('Profile ')) continue;
|
|
// Chrome 80+ on Windows stores cookies under Network/Cookies
|
|
const cookieCandidates = platform === 'win32'
|
|
? [path.join(browserDir, entry.name, 'Network', 'Cookies'), path.join(browserDir, entry.name, 'Cookies')]
|
|
: [path.join(browserDir, entry.name, 'Cookies')];
|
|
if (!cookieCandidates.some(p => fs.existsSync(p))) continue;
|
|
|
|
// Avoid duplicates if the same profile appears on multiple platforms
|
|
if (profiles.some(p => p.name === entry.name)) continue;
|
|
|
|
// Try to read display name from Preferences.
|
|
// Prefer account email — signed-in Chrome profiles often have generic
|
|
// names like "Person 2" while the email is far more readable.
|
|
let displayName = entry.name;
|
|
try {
|
|
const prefsPath = path.join(browserDir, entry.name, 'Preferences');
|
|
if (fs.existsSync(prefsPath)) {
|
|
const prefs = JSON.parse(fs.readFileSync(prefsPath, 'utf-8'));
|
|
const email = prefs?.account_info?.[0]?.email;
|
|
if (email && typeof email === 'string') {
|
|
displayName = email;
|
|
} else {
|
|
const profileName = prefs?.profile?.name;
|
|
if (profileName && typeof profileName === 'string') {
|
|
displayName = profileName;
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
// Ignore — fall back to directory name
|
|
}
|
|
|
|
profiles.push({ name: entry.name, displayName });
|
|
}
|
|
|
|
// Found profiles on this platform — no need to check others
|
|
if (profiles.length > 0) break;
|
|
}
|
|
|
|
return profiles;
|
|
}
|
|
|
|
/**
|
|
* List unique cookie domains + counts from a browser's DB. No decryption.
|
|
*/
|
|
export function listDomains(browserName: string, profile = 'Default'): { domains: DomainEntry[]; browser: string } {
|
|
const browser = resolveBrowser(browserName);
|
|
const match = getBrowserMatch(browser, profile);
|
|
const db = openDb(match.dbPath, browser.name);
|
|
try {
|
|
const now = chromiumNow();
|
|
const rows = db.query(
|
|
`SELECT host_key AS domain, COUNT(*) AS count
|
|
FROM cookies
|
|
WHERE has_expires = 0 OR expires_utc > ?
|
|
GROUP BY host_key
|
|
ORDER BY count DESC`
|
|
).all(now) as DomainEntry[];
|
|
return { domains: rows, browser: browser.name };
|
|
} finally {
|
|
db.close();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Decrypt and return Playwright-compatible cookies for specific domains.
|
|
*/
|
|
export async function importCookies(
|
|
browserName: string,
|
|
domains: string[],
|
|
profile = 'Default',
|
|
): Promise<ImportResult> {
|
|
if (domains.length === 0) return { cookies: [], count: 0, failed: 0, domainCounts: {} };
|
|
|
|
const browser = resolveBrowser(browserName);
|
|
const match = getBrowserMatch(browser, profile);
|
|
const derivedKeys = await getDerivedKeys(match);
|
|
const db = openDb(match.dbPath, browser.name);
|
|
|
|
try {
|
|
const now = chromiumNow();
|
|
// Parameterized query — no SQL injection
|
|
const placeholders = domains.map(() => '?').join(',');
|
|
const rows = db.query(
|
|
`SELECT host_key, name, value, encrypted_value, path, expires_utc,
|
|
is_secure, is_httponly, has_expires, samesite
|
|
FROM cookies
|
|
WHERE host_key IN (${placeholders})
|
|
AND (has_expires = 0 OR expires_utc > ?)
|
|
ORDER BY host_key, name`
|
|
).all(...domains, now) as RawCookie[];
|
|
|
|
const cookies: PlaywrightCookie[] = [];
|
|
let failed = 0;
|
|
const domainCounts: Record<string, number> = {};
|
|
|
|
for (const row of rows) {
|
|
try {
|
|
const value = decryptCookieValue(row, derivedKeys, match.platform);
|
|
const cookie = toPlaywrightCookie(row, value);
|
|
cookies.push(cookie);
|
|
domainCounts[row.host_key] = (domainCounts[row.host_key] || 0) + 1;
|
|
} catch {
|
|
failed++;
|
|
}
|
|
}
|
|
|
|
return { cookies, count: cookies.length, failed, domainCounts };
|
|
} finally {
|
|
db.close();
|
|
}
|
|
}
|
|
|
|
// ─── Internal: Browser Resolution ───────────────────────────────
|
|
|
|
function resolveBrowser(nameOrAlias: string): BrowserInfo {
|
|
const needle = nameOrAlias.toLowerCase().trim();
|
|
const found = BROWSER_REGISTRY.find(b =>
|
|
b.aliases.includes(needle) || b.name.toLowerCase() === needle
|
|
);
|
|
if (!found) {
|
|
const supported = BROWSER_REGISTRY.flatMap(b => b.aliases).join(', ');
|
|
throw new CookieImportError(
|
|
`Unknown browser '${nameOrAlias}'. Supported: ${supported}`,
|
|
'unknown_browser',
|
|
);
|
|
}
|
|
return found;
|
|
}
|
|
|
|
function validateProfile(profile: string): void {
|
|
if (/[/\\]|\.\./.test(profile) || /[\x00-\x1f]/.test(profile)) {
|
|
throw new CookieImportError(
|
|
`Invalid profile name: '${profile}'`,
|
|
'bad_request',
|
|
);
|
|
}
|
|
}
|
|
|
|
function getHostPlatform(): BrowserPlatform | null {
|
|
const p = process.platform;
|
|
if (p === 'darwin' || p === 'linux' || p === 'win32') return p as BrowserPlatform;
|
|
return null;
|
|
}
|
|
|
|
function getSearchPlatforms(): BrowserPlatform[] {
|
|
const current = getHostPlatform();
|
|
const order: BrowserPlatform[] = [];
|
|
if (current) order.push(current);
|
|
for (const platform of ['darwin', 'linux', 'win32'] as BrowserPlatform[]) {
|
|
if (!order.includes(platform)) order.push(platform);
|
|
}
|
|
return order;
|
|
}
|
|
|
|
function getDataDirForPlatform(browser: BrowserInfo, platform: BrowserPlatform): string | null {
|
|
if (platform === 'darwin') return browser.dataDir;
|
|
if (platform === 'linux') return browser.linuxDataDir || null;
|
|
return browser.windowsDataDir || null;
|
|
}
|
|
|
|
function getBaseDir(platform: BrowserPlatform): string {
|
|
if (platform === 'darwin') return path.join(os.homedir(), 'Library', 'Application Support');
|
|
if (platform === 'win32') return path.join(os.homedir(), 'AppData', 'Local');
|
|
return 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 baseProfile = path.join(getBaseDir(platform), dataDir, profile);
|
|
// Chrome 80+ on Windows stores cookies under Network/Cookies; fall back to Cookies
|
|
const candidates = platform === 'win32'
|
|
? [path.join(baseProfile, 'Network', 'Cookies'), path.join(baseProfile, 'Cookies')]
|
|
: [path.join(baseProfile, 'Cookies')];
|
|
for (const dbPath of candidates) {
|
|
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 ────────────────────────────────────
|
|
|
|
function openDb(dbPath: string, browserName: string): Database {
|
|
// On Windows, Chrome holds exclusive WAL locks even when we open readonly.
|
|
// The readonly open may "succeed" but return empty results because the WAL
|
|
// (where all actual data lives) can't be replayed. Always use the copy
|
|
// approach on Windows so we can open read-write and process the WAL.
|
|
if (process.platform === 'win32') {
|
|
return openDbFromCopy(dbPath, browserName);
|
|
}
|
|
try {
|
|
return new Database(dbPath, { readonly: true });
|
|
} catch (err: any) {
|
|
if (err.message?.includes('SQLITE_BUSY') || err.message?.includes('database is locked')) {
|
|
return openDbFromCopy(dbPath, browserName);
|
|
}
|
|
if (err.message?.includes('SQLITE_CORRUPT') || err.message?.includes('malformed')) {
|
|
throw new CookieImportError(
|
|
`Cookie database for ${browserName} is corrupt`,
|
|
'db_corrupt',
|
|
);
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
function openDbFromCopy(dbPath: string, browserName: string): Database {
|
|
// Use os.tmpdir() instead of hardcoded /tmp for cross-platform support (#708)
|
|
const tmpPath = path.join(os.tmpdir(), `browse-cookies-${browserName.toLowerCase()}-${crypto.randomUUID()}.db`);
|
|
try {
|
|
fs.copyFileSync(dbPath, tmpPath);
|
|
// Also copy WAL and SHM if they exist (for consistent reads)
|
|
const walPath = dbPath + '-wal';
|
|
const shmPath = dbPath + '-shm';
|
|
if (fs.existsSync(walPath)) fs.copyFileSync(walPath, tmpPath + '-wal');
|
|
if (fs.existsSync(shmPath)) fs.copyFileSync(shmPath, tmpPath + '-shm');
|
|
|
|
const db = new Database(tmpPath, { readonly: true });
|
|
// Schedule cleanup after the DB is closed
|
|
const origClose = db.close.bind(db);
|
|
db.close = () => {
|
|
origClose();
|
|
try { fs.unlinkSync(tmpPath); } catch {}
|
|
try { fs.unlinkSync(tmpPath + '-wal'); } catch {}
|
|
try { fs.unlinkSync(tmpPath + '-shm'); } catch {}
|
|
};
|
|
return db;
|
|
} catch {
|
|
// Clean up on failure
|
|
try { fs.unlinkSync(tmpPath); } catch {}
|
|
throw new CookieImportError(
|
|
`Cookie database is locked (${browserName} may be running). Try closing ${browserName} first.`,
|
|
'db_locked',
|
|
'retry',
|
|
);
|
|
}
|
|
}
|
|
|
|
// ─── Internal: Keychain Access (async, 10s timeout) ─────────────
|
|
|
|
function deriveKey(password: string, iterations: number): Buffer {
|
|
return crypto.pbkdf2Sync(password, 'saltysalt', iterations, 16, 'sha1');
|
|
}
|
|
|
|
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 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)],
|
|
]);
|
|
}
|
|
|
|
if (match.platform === 'win32') {
|
|
const key = await getWindowsAesKey(match.browser);
|
|
return new Map([['v10', key]]);
|
|
}
|
|
|
|
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 getWindowsAesKey(browser: BrowserInfo): Promise<Buffer> {
|
|
const cacheKey = `win32:${browser.keychainService}`;
|
|
const cached = keyCache.get(cacheKey);
|
|
if (cached) return cached;
|
|
|
|
const platform = 'win32' as const;
|
|
const dataDir = getDataDirForPlatform(browser, platform);
|
|
if (!dataDir) throw new CookieImportError(`No Windows data dir for ${browser.name}`, 'not_installed');
|
|
|
|
const localStatePath = path.join(getBaseDir(platform), dataDir, 'Local State');
|
|
let localState: any;
|
|
try {
|
|
localState = JSON.parse(fs.readFileSync(localStatePath, 'utf-8'));
|
|
} catch (err) {
|
|
const reason = err instanceof Error ? `: ${err.message}` : '';
|
|
throw new CookieImportError(
|
|
`Cannot read Local State for ${browser.name} at ${localStatePath}${reason}`,
|
|
'keychain_error',
|
|
);
|
|
}
|
|
|
|
const encryptedKeyB64: string = localState?.os_crypt?.encrypted_key;
|
|
if (!encryptedKeyB64) {
|
|
throw new CookieImportError(
|
|
`No encrypted key in Local State for ${browser.name}`,
|
|
'keychain_not_found',
|
|
);
|
|
}
|
|
|
|
// The stored value is base64(b"DPAPI" + dpapi_encrypted_bytes) — strip the 5-byte prefix
|
|
const encryptedKey = Buffer.from(encryptedKeyB64, 'base64').slice(5);
|
|
const key = await dpapiDecrypt(encryptedKey);
|
|
keyCache.set(cacheKey, key);
|
|
return key;
|
|
}
|
|
|
|
async function dpapiDecrypt(encryptedBytes: Buffer): Promise<Buffer> {
|
|
const script = [
|
|
'Add-Type -AssemblyName System.Security',
|
|
'$stdin = [Console]::In.ReadToEnd().Trim()',
|
|
'$bytes = [System.Convert]::FromBase64String($stdin)',
|
|
'$dec = [System.Security.Cryptography.ProtectedData]::Unprotect($bytes, $null, [System.Security.Cryptography.DataProtectionScope]::CurrentUser)',
|
|
'Write-Output ([System.Convert]::ToBase64String($dec))',
|
|
].join('; ');
|
|
|
|
const proc = Bun.spawn(['powershell', '-NoProfile', '-Command', script], {
|
|
stdin: 'pipe',
|
|
stdout: 'pipe',
|
|
stderr: 'pipe',
|
|
});
|
|
|
|
proc.stdin.write(encryptedBytes.toString('base64'));
|
|
proc.stdin.end();
|
|
|
|
const timeout = new Promise<never>((_, reject) =>
|
|
setTimeout(() => {
|
|
proc.kill();
|
|
reject(new CookieImportError('DPAPI decryption timed out', 'keychain_timeout', 'retry'));
|
|
}, 10_000),
|
|
);
|
|
|
|
try {
|
|
const exitCode = await Promise.race([proc.exited, timeout]);
|
|
const stdout = await new Response(proc.stdout).text();
|
|
if (exitCode !== 0) {
|
|
const stderr = await new Response(proc.stderr).text();
|
|
throw new CookieImportError(`DPAPI decryption failed: ${stderr.trim()}`, 'keychain_error');
|
|
}
|
|
return Buffer.from(stdout.trim(), 'base64');
|
|
} catch (err) {
|
|
if (err instanceof CookieImportError) throw err;
|
|
throw new CookieImportError(
|
|
`DPAPI decryption failed: ${(err as Error).message}`,
|
|
'keychain_error',
|
|
);
|
|
}
|
|
}
|
|
|
|
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(
|
|
['security', 'find-generic-password', '-s', service, '-w'],
|
|
{ stdout: 'pipe', stderr: 'pipe' },
|
|
);
|
|
|
|
const timeout = new Promise<never>((_, reject) =>
|
|
setTimeout(() => {
|
|
proc.kill();
|
|
reject(new CookieImportError(
|
|
`macOS is waiting for Keychain permission. Look for a dialog asking to allow access to "${service}".`,
|
|
'keychain_timeout',
|
|
'retry',
|
|
));
|
|
}, 10_000),
|
|
);
|
|
|
|
try {
|
|
const exitCode = await Promise.race([proc.exited, timeout]);
|
|
const stdout = await new Response(proc.stdout).text();
|
|
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(
|
|
`Keychain access denied. Click "Allow" in the macOS dialog for "${service}".`,
|
|
'keychain_denied',
|
|
'retry',
|
|
);
|
|
}
|
|
if (errText.includes('could not be found') || errText.includes('not found')) {
|
|
throw new CookieImportError(
|
|
`No Keychain entry for "${service}". Is this a Chromium-based browser?`,
|
|
'keychain_not_found',
|
|
);
|
|
}
|
|
throw new CookieImportError(
|
|
`Could not read Keychain: ${stderr.trim()}`,
|
|
'keychain_error',
|
|
'retry',
|
|
);
|
|
}
|
|
|
|
return stdout.trim();
|
|
} catch (err) {
|
|
if (err instanceof CookieImportError) throw err;
|
|
throw new CookieImportError(
|
|
`Could not read Keychain: ${(err as Error).message}`,
|
|
'keychain_error',
|
|
'retry',
|
|
);
|
|
}
|
|
}
|
|
|
|
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 {
|
|
host_key: string;
|
|
name: string;
|
|
value: string;
|
|
encrypted_value: Buffer | Uint8Array;
|
|
path: string;
|
|
expires_utc: number | bigint;
|
|
is_secure: number;
|
|
is_httponly: number;
|
|
has_expires: number;
|
|
samesite: number;
|
|
}
|
|
|
|
function decryptCookieValue(row: RawCookie, keys: Map<string, Buffer>, platform: BrowserPlatform): string {
|
|
// Prefer unencrypted value if present
|
|
if (row.value && row.value.length > 0) return row.value;
|
|
|
|
const ev = Buffer.from(row.encrypted_value);
|
|
if (ev.length === 0) return '';
|
|
|
|
const prefix = ev.slice(0, 3).toString('utf-8');
|
|
|
|
// Chrome 127+ on Windows uses App-Bound Encryption (v20) — cannot be decrypted
|
|
// outside the Chrome process. Caller should fall back to CDP extraction.
|
|
if (prefix === 'v20') throw new CookieImportError(
|
|
'Cookie uses App-Bound Encryption (v20). Use CDP extraction instead.',
|
|
'v20_encryption',
|
|
);
|
|
|
|
const key = keys.get(prefix);
|
|
if (!key) throw new Error(`No decryption key available for ${prefix} cookies`);
|
|
|
|
if (platform === 'win32' && prefix === 'v10') {
|
|
// Windows: AES-256-GCM — structure: v10(3) + nonce(12) + ciphertext + tag(16)
|
|
const nonce = ev.slice(3, 15);
|
|
const tag = ev.slice(ev.length - 16);
|
|
const ciphertext = ev.slice(15, ev.length - 16);
|
|
const decipher = crypto.createDecipheriv('aes-256-gcm', key, nonce) as crypto.DecipherGCM;
|
|
decipher.setAuthTag(tag);
|
|
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf-8');
|
|
}
|
|
|
|
// macOS / Linux: AES-128-CBC — structure: v10/v11(3) + ciphertext
|
|
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()]);
|
|
|
|
// Chromium prefixes encrypted cookie payloads with 32 bytes of metadata.
|
|
if (plaintext.length <= 32) return '';
|
|
return plaintext.slice(32).toString('utf-8');
|
|
}
|
|
|
|
function toPlaywrightCookie(row: RawCookie, value: string): PlaywrightCookie {
|
|
return {
|
|
name: row.name,
|
|
value,
|
|
domain: row.host_key,
|
|
path: row.path || '/',
|
|
expires: chromiumEpochToUnix(row.expires_utc, row.has_expires),
|
|
secure: row.is_secure === 1,
|
|
httpOnly: row.is_httponly === 1,
|
|
sameSite: mapSameSite(row.samesite),
|
|
};
|
|
}
|
|
|
|
// ─── Internal: Chromium Epoch Conversion ────────────────────────
|
|
|
|
const CHROMIUM_EPOCH_OFFSET = 11644473600000000n;
|
|
|
|
function chromiumNow(): bigint {
|
|
// Current time in Chromium epoch (microseconds since 1601-01-01)
|
|
return BigInt(Date.now()) * 1000n + CHROMIUM_EPOCH_OFFSET;
|
|
}
|
|
|
|
function chromiumEpochToUnix(epoch: number | bigint, hasExpires: number): number {
|
|
if (hasExpires === 0 || epoch === 0 || epoch === 0n) return -1; // session cookie
|
|
const epochBig = BigInt(epoch);
|
|
const unixMicro = epochBig - CHROMIUM_EPOCH_OFFSET;
|
|
return Number(unixMicro / 1000000n);
|
|
}
|
|
|
|
function mapSameSite(value: number): 'Strict' | 'Lax' | 'None' {
|
|
switch (value) {
|
|
case 0: return 'None';
|
|
case 1: return 'Lax';
|
|
case 2: return 'Strict';
|
|
default: return 'Lax';
|
|
}
|
|
}
|
|
|
|
|
|
// ─── CDP-based Cookie Extraction (Windows v20 fallback) ────────
|
|
// When App-Bound Encryption (v20) is detected, we launch Chrome headless
|
|
// with remote debugging and extract cookies via the DevTools Protocol.
|
|
// This only works when Chrome is NOT already running (profile lock).
|
|
|
|
const CHROME_PATHS_WIN = [
|
|
path.join(process.env.PROGRAMFILES || 'C:\\Program Files', 'Google', 'Chrome', 'Application', 'chrome.exe'),
|
|
path.join(process.env['PROGRAMFILES(X86)'] || 'C:\\Program Files (x86)', 'Google', 'Chrome', 'Application', 'chrome.exe'),
|
|
];
|
|
|
|
const EDGE_PATHS_WIN = [
|
|
path.join(process.env['PROGRAMFILES(X86)'] || 'C:\\Program Files (x86)', 'Microsoft', 'Edge', 'Application', 'msedge.exe'),
|
|
path.join(process.env.PROGRAMFILES || 'C:\\Program Files', 'Microsoft', 'Edge', 'Application', 'msedge.exe'),
|
|
];
|
|
|
|
function findBrowserExe(browserName: string): string | null {
|
|
const candidates = browserName.toLowerCase().includes('edge') ? EDGE_PATHS_WIN : CHROME_PATHS_WIN;
|
|
for (const p of candidates) {
|
|
if (fs.existsSync(p)) return p;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function isBrowserRunning(browserName: string): Promise<boolean> {
|
|
const exe = browserName.toLowerCase().includes('edge') ? 'msedge.exe' : 'chrome.exe';
|
|
return new Promise((resolve) => {
|
|
const proc = Bun.spawn(['tasklist', '/FI', `IMAGENAME eq ${exe}`, '/NH'], {
|
|
stdout: 'pipe', stderr: 'pipe',
|
|
});
|
|
proc.exited.then(async () => {
|
|
const out = await new Response(proc.stdout).text();
|
|
resolve(out.toLowerCase().includes(exe));
|
|
}).catch(() => resolve(false));
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Extract cookies via Chrome DevTools Protocol. Launches Chrome headless with
|
|
* remote debugging on the user's real profile directory. Requires Chrome to be
|
|
* closed first (profile lock).
|
|
*
|
|
* v20 App-Bound Encryption binds decryption keys to the original user-data-dir
|
|
* path, so a temp copy of the profile won't work — Chrome silently discards
|
|
* cookies it can't decrypt. We must use the real profile.
|
|
*/
|
|
export async function importCookiesViaCdp(
|
|
browserName: string,
|
|
domains: string[],
|
|
profile = 'Default',
|
|
): Promise<ImportResult> {
|
|
if (domains.length === 0) return { cookies: [], count: 0, failed: 0, domainCounts: {} };
|
|
if (process.platform !== 'win32') {
|
|
throw new CookieImportError('CDP extraction is only needed on Windows', 'not_supported');
|
|
}
|
|
|
|
const browser = resolveBrowser(browserName);
|
|
const exePath = findBrowserExe(browser.name);
|
|
if (!exePath) {
|
|
throw new CookieImportError(
|
|
`Cannot find ${browser.name} executable. Install it or use /connect-chrome.`,
|
|
'not_installed',
|
|
);
|
|
}
|
|
|
|
if (await isBrowserRunning(browser.name)) {
|
|
throw new CookieImportError(
|
|
`${browser.name} is running. Close it first so we can launch headless with your profile, or use /connect-chrome to control your real browser directly.`,
|
|
'browser_running',
|
|
'retry',
|
|
);
|
|
}
|
|
|
|
// Must use the real user data dir — v20 ABE keys are path-bound
|
|
const dataDir = getDataDirForPlatform(browser, 'win32');
|
|
if (!dataDir) throw new CookieImportError(`No Windows data dir for ${browser.name}`, 'not_installed');
|
|
const userDataDir = path.join(getBaseDir('win32'), dataDir);
|
|
|
|
// Launch Chrome headless with remote debugging on the real profile.
|
|
//
|
|
// Security posture of the debug port:
|
|
// - Chrome binds --remote-debugging-port to 127.0.0.1 by default. We rely
|
|
// on that — the port is NOT exposed to the network. Any local process
|
|
// running as the same user could connect and read cookies, but if an
|
|
// attacker already has local-user access they can read the cookie DB
|
|
// directly. Threat model: no worse than baseline.
|
|
// - Port is randomized in [9222, 9321] to avoid collisions with other
|
|
// Chrome-based tools the user may have open. Not cryptographic.
|
|
// - Chrome is always killed in the finally block below (even on crash).
|
|
//
|
|
// Debugging note: if this path starts failing after a Chrome update,
|
|
// check the Chrome version logged below — Chrome's ABE key format (v20)
|
|
// or /json/list shape can change between major versions.
|
|
const debugPort = 9222 + Math.floor(Math.random() * 100);
|
|
const chromeProc = Bun.spawn([
|
|
exePath,
|
|
`--remote-debugging-port=${debugPort}`,
|
|
`--user-data-dir=${userDataDir}`,
|
|
`--profile-directory=${profile}`,
|
|
'--headless=new',
|
|
'--no-first-run',
|
|
'--disable-background-networking',
|
|
'--disable-default-apps',
|
|
'--disable-extensions',
|
|
'--disable-sync',
|
|
'--no-default-browser-check',
|
|
], { stdout: 'pipe', stderr: 'pipe' });
|
|
|
|
// Wait for Chrome to start, then find a page target's WebSocket URL.
|
|
// Network.getAllCookies is only available on page targets, not browser.
|
|
let wsUrl: string | null = null;
|
|
const startTime = Date.now();
|
|
let loggedVersion = false;
|
|
while (Date.now() - startTime < 15_000) {
|
|
try {
|
|
// One-time version log for future diagnostics when Chrome changes v20 format.
|
|
if (!loggedVersion) {
|
|
try {
|
|
const versionResp = await fetch(`http://127.0.0.1:${debugPort}/json/version`);
|
|
if (versionResp.ok) {
|
|
const v = await versionResp.json() as { Browser?: string };
|
|
console.log(`[cookie-import] CDP fallback: ${browser.name} ${v.Browser || 'unknown version'}`);
|
|
loggedVersion = true;
|
|
}
|
|
} catch {}
|
|
}
|
|
const resp = await fetch(`http://127.0.0.1:${debugPort}/json/list`);
|
|
if (resp.ok) {
|
|
const targets = await resp.json() as Array<{ type: string; webSocketDebuggerUrl?: string }>;
|
|
const page = targets.find(t => t.type === 'page');
|
|
if (page?.webSocketDebuggerUrl) {
|
|
wsUrl = page.webSocketDebuggerUrl;
|
|
break;
|
|
}
|
|
}
|
|
} catch {
|
|
// Not ready yet
|
|
}
|
|
await new Promise(r => setTimeout(r, 300));
|
|
}
|
|
|
|
if (!wsUrl) {
|
|
chromeProc.kill();
|
|
throw new CookieImportError(
|
|
`${browser.name} headless did not start within 15s`,
|
|
'cdp_timeout',
|
|
'retry',
|
|
);
|
|
}
|
|
|
|
try {
|
|
// Connect via CDP WebSocket
|
|
const cookies = await extractCookiesViaCdp(wsUrl, domains);
|
|
|
|
const domainCounts: Record<string, number> = {};
|
|
for (const c of cookies) {
|
|
domainCounts[c.domain] = (domainCounts[c.domain] || 0) + 1;
|
|
}
|
|
|
|
return { cookies, count: cookies.length, failed: 0, domainCounts };
|
|
} finally {
|
|
chromeProc.kill();
|
|
}
|
|
}
|
|
|
|
async function extractCookiesViaCdp(wsUrl: string, domains: string[]): Promise<PlaywrightCookie[]> {
|
|
return new Promise((resolve, reject) => {
|
|
const ws = new WebSocket(wsUrl);
|
|
let msgId = 1;
|
|
|
|
const timeout = setTimeout(() => {
|
|
ws.close();
|
|
reject(new CookieImportError('CDP cookie extraction timed out', 'cdp_timeout'));
|
|
}, 10_000);
|
|
|
|
ws.onopen = () => {
|
|
// Enable Network domain first, then request all cookies
|
|
ws.send(JSON.stringify({ id: msgId++, method: 'Network.enable' }));
|
|
};
|
|
|
|
ws.onmessage = (event) => {
|
|
const data = JSON.parse(String(event.data));
|
|
|
|
// After Network.enable succeeds, request all cookies
|
|
if (data.id === 1 && !data.error) {
|
|
ws.send(JSON.stringify({ id: msgId, method: 'Network.getAllCookies' }));
|
|
return;
|
|
}
|
|
|
|
if (data.id === msgId && data.result?.cookies) {
|
|
clearTimeout(timeout);
|
|
ws.close();
|
|
|
|
// Normalize domain matching: domains like ".example.com" match "example.com" and vice versa
|
|
const domainSet = new Set<string>();
|
|
for (const d of domains) {
|
|
domainSet.add(d);
|
|
domainSet.add(d.startsWith('.') ? d.slice(1) : '.' + d);
|
|
}
|
|
|
|
const matched: PlaywrightCookie[] = [];
|
|
for (const c of data.result.cookies as CdpCookie[]) {
|
|
if (!domainSet.has(c.domain)) continue;
|
|
matched.push({
|
|
name: c.name,
|
|
value: c.value,
|
|
domain: c.domain,
|
|
path: c.path || '/',
|
|
expires: c.expires === -1 ? -1 : c.expires,
|
|
secure: c.secure,
|
|
httpOnly: c.httpOnly,
|
|
sameSite: cdpSameSite(c.sameSite),
|
|
});
|
|
}
|
|
resolve(matched);
|
|
} else if (data.id === msgId && data.error) {
|
|
clearTimeout(timeout);
|
|
ws.close();
|
|
reject(new CookieImportError(
|
|
`CDP error: ${data.error.message}`,
|
|
'cdp_error',
|
|
));
|
|
}
|
|
};
|
|
|
|
ws.onerror = (err) => {
|
|
clearTimeout(timeout);
|
|
reject(new CookieImportError(
|
|
`CDP WebSocket error: ${(err as any).message || 'unknown'}`,
|
|
'cdp_error',
|
|
));
|
|
};
|
|
});
|
|
}
|
|
|
|
interface CdpCookie {
|
|
name: string;
|
|
value: string;
|
|
domain: string;
|
|
path: string;
|
|
expires: number;
|
|
size: number;
|
|
httpOnly: boolean;
|
|
secure: boolean;
|
|
session: boolean;
|
|
sameSite: string;
|
|
}
|
|
|
|
function cdpSameSite(value: string): 'Strict' | 'Lax' | 'None' {
|
|
switch (value) {
|
|
case 'Strict': return 'Strict';
|
|
case 'Lax': return 'Lax';
|
|
case 'None': return 'None';
|
|
default: return 'Lax';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if a browser's cookie DB contains v20 (App-Bound) encrypted cookies.
|
|
* Quick check — reads a small sample, no decryption attempted.
|
|
*/
|
|
export function hasV20Cookies(browserName: string, profile = 'Default'): boolean {
|
|
if (process.platform !== 'win32') return false;
|
|
try {
|
|
const browser = resolveBrowser(browserName);
|
|
const match = getBrowserMatch(browser, profile);
|
|
const db = openDb(match.dbPath, browser.name);
|
|
try {
|
|
const rows = db.query('SELECT encrypted_value FROM cookies LIMIT 10').all() as Array<{ encrypted_value: Buffer | Uint8Array }>;
|
|
return rows.some(row => {
|
|
const ev = Buffer.from(row.encrypted_value);
|
|
return ev.length >= 3 && ev.slice(0, 3).toString('utf-8') === 'v20';
|
|
});
|
|
} finally {
|
|
db.close();
|
|
}
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|