feat(cookies): cross-profile cookies export / cookies transfer

Transferring a logged-in session between Chrome profiles previously needed
an ad-hoc external script to decrypt the source profile's cookie store. Make
it first-class:

- `cookies export --from <profile> [--domain <d>[,<d>]]` decrypts another
  profile's on-disk cookies and prints CDP-shaped JSON for `cookies set --curl`.
- `cookies transfer --from <profile> [--domain <d>]` exports + injects into
  the connected browser in one shot (reuses the cookies_set path).

Source profile is resolved by directory name, display name, or "auto". The
store is copied to a temp file (immune to a running Chrome's lock/WAL), read
via sqlite3, and values are decrypted (macOS v10: AES-128-CBC, key from the
shared 'Chrome Safe Storage' Keychain entry). httpOnly/secure/per-domain
auth cookies round-trip intact; SameSite=None without Secure is downgraded
so CDP accepts it. macOS only for now (clear error elsewhere).
This commit is contained in:
leeguooooo
2026-06-12 13:59:37 +09:00
parent 7ba82bc6cc
commit f76ed1ddf5
6 changed files with 484 additions and 2 deletions
+68
View File
@@ -3,6 +3,7 @@ mod color;
mod commands;
mod connect;
mod connection;
mod cookie_export;
mod doctor;
mod findurl;
mod flags;
@@ -200,6 +201,64 @@ fn run_profiles(json_mode: bool) {
}
}
fn run_cookies_export(args: &[String], flags: &Flags) {
// Source profile comes from `--from <profile>`, falling back to the global
// `--profile` (which the flag parser has already moved into flags.profile).
let from = args
.iter()
.position(|a| a == "--from")
.and_then(|i| args.get(i + 1))
.map(|s| s.as_str())
.or(flags.profile.as_deref());
let profile = match from {
Some(p) => p,
None => {
let msg = "cookies export needs a source profile: cookies export --from <profile> [--domain <d>]";
if flags.json {
print_json_error(msg);
} else {
eprintln!("{} {}", color::error_indicator(), msg);
}
exit(1);
}
};
let domain = args
.iter()
.position(|a| a == "--domain")
.and_then(|i| args.get(i + 1))
.map(|s| s.as_str());
match cookie_export::export_cookies(profile, domain) {
Ok(cookies) => {
if flags.json {
print_json_value(json!({ "success": true, "data": cookies }));
} else {
// A JSON array ready for `cookies set --curl <file>`.
println!(
"{}",
serde_json::to_string(&cookies).unwrap_or_else(|_| "[]".to_string())
);
eprintln!(
"{}",
color::dim(&format!(
"{} cookies exported from \"{}\"",
cookies.len(),
profile
))
);
}
}
Err(e) => {
if flags.json {
print_json_error(&e);
} else {
eprintln!("{} {}", color::error_indicator(), e);
}
exit(1);
}
}
}
fn run_session(args: &[String], session: &str, json_mode: bool) {
let subcommand = args.get(1).map(|s| s.as_str());
@@ -639,6 +698,15 @@ fn main() {
return;
}
// Handle `cookies export` (doesn't need daemon): decrypt an on-disk Chrome
// profile's cookies and print them as JSON for `cookies set --curl`.
if clean.first().map(|s| s.as_str()) == Some("cookies")
&& clean.get(1).map(|s| s.as_str()) == Some("export")
{
run_cookies_export(&clean, &flags);
return;
}
// Handle skills command (doesn't need daemon)
if clean.first().map(|s| s.as_str()) == Some("skills") {
skills::run_skills(&clean, flags.json);