use std::collections::HashSet;
use std::process::Stdio;
use std::sync::OnceLock;
use std::sync::atomic::{AtomicU16, Ordering};
use std::time::Duration;
use futures_util::{SinkExt, StreamExt};
use serde::{Deserialize, Serialize};
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::{Child, Command};
use tokio::sync::Mutex;
use tokio::time::{sleep, timeout};
use tokio_tungstenite::tungstenite::Message;
use crate::auth::AuthState;
use crate::errors::CliError;
const SUNO_HCAPTCHA_SITEKEY: &str = "d65453de-3f1a-4aac-9366-a0f06e52b2ce";
const CDP_PORT: u16 = 9233;
const CDP_HOST: &str = "127.0.0.1";
static ACTIVE_PORT: AtomicU16 = AtomicU16::new(CDP_PORT);
fn active_port() -> u16 {
ACTIVE_PORT.load(Ordering::Relaxed)
}
static CHROME: OnceLock<Mutex<Option<Child>>> = OnceLock::new();
fn chrome_slot() -> &'static Mutex<Option<Child>> {
CHROME.get_or_init(|| Mutex::new(None))
}
static SOLVE_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
fn solve_lock() -> &'static Mutex<()> {
SOLVE_LOCK.get_or_init(|| Mutex::new(()))
}
pub async fn solve(auth: &AuthState) -> Result<String, CliError> {
let _serialized = solve_lock().lock().await;
ensure_chrome_running().await?;
let target = find_or_create_suno_tab().await?;
if !is_loopback_ws(&target.web_socket_debugger_url) {
return Err(CliError::Config(format!(
"CDP returned a non-loopback debugger URL ({}) — refusing to send Suno cookies off-host",
target.web_socket_debugger_url
)));
}
let token = render_and_execute(&target.web_socket_debugger_url, auth).await?;
Ok(token)
}
fn is_loopback_ws(ws_url: &str) -> bool {
let Some(rest) = ws_url
.strip_prefix("ws://")
.or_else(|| ws_url.strip_prefix("wss://"))
else {
return false;
};
let authority = rest.split('/').next().unwrap_or("");
let hostport = authority.rsplit('@').next().unwrap_or(authority);
let host = if let Some(v6) = hostport.strip_prefix('[') {
v6.split(']').next().unwrap_or("")
} else {
hostport
.rsplit_once(':')
.map(|(h, _)| h)
.unwrap_or(hostport)
};
if host == "localhost" {
return true;
}
host.parse::<std::net::IpAddr>()
.is_ok_and(|ip| ip.is_loopback())
}
pub async fn detect_solver_chrome() -> Option<u16> {
cdp_version().await.ok().map(|_| CDP_PORT)
}
async fn ensure_chrome_running() -> Result<(), CliError> {
match cdp_version().await {
Ok(ver) if cdp_looks_like_chrome(&ver) => return Ok(()),
Ok(_) => {
let port = free_loopback_port()?;
ACTIVE_PORT.store(port, Ordering::Relaxed);
}
Err(_) => {}
}
let chrome_path = locate_chrome()?;
let profile_dir = crate::config::data_dir().join("chrome-profile");
std::fs::create_dir_all(&profile_dir)?;
let headless = std::env::var("SUNO_CAPTCHA_HEADLESS").is_ok_and(|v| v == "1");
let port = active_port();
eprintln!(
"Launching {} Chrome for captcha solver (one-time per session)...",
if headless { "headless" } else { "offscreen" }
);
let mut cmd = Command::new(&chrome_path);
cmd.arg(format!("--remote-debugging-port={port}"))
.arg(format!("--user-data-dir={}", profile_dir.display()))
.arg("--no-first-run")
.arg("--no-default-browser-check")
.arg("--disable-search-engine-choice-screen")
.arg("--disable-features=TranslateUI")
.arg("--window-size=1280,900");
if headless {
cmd.arg("--headless=new");
} else {
cmd.arg("--window-position=-32000,-32000")
.arg("--silent-launch");
}
let mut child = cmd
.arg("about:blank")
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| CliError::Config(format!("failed to spawn Chrome at {chrome_path:?}: {e}")))?;
drain_stderr(&mut child);
{
let mut slot = chrome_slot().lock().await;
*slot = Some(child);
}
for _ in 0..20 {
sleep(Duration::from_millis(500)).await;
if cdp_version().await.is_ok() {
return Ok(());
}
}
Err(CliError::Config(
"Chrome was spawned but never opened the CDP port. Check that Chrome can start normally, or set SUNO_CHROME_PATH to a Chrome/Chromium binary.".into(),
))
}
pub(crate) fn locate_chrome() -> Result<String, CliError> {
if let Ok(path) = std::env::var("SUNO_CHROME_PATH")
&& !path.trim().is_empty()
{
if std::path::Path::new(&path).exists() {
return Ok(path);
}
return Err(CliError::Config(format!(
"SUNO_CHROME_PATH points to a missing file: {path}"
)));
}
let candidates: &[&str] = if cfg!(target_os = "macos") {
&[
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
]
} else if cfg!(target_os = "linux") {
&[
"/usr/bin/google-chrome",
"/usr/bin/google-chrome-stable",
"/usr/bin/chromium",
"/usr/bin/chromium-browser",
"/snap/bin/chromium",
]
} else {
&[
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
]
};
for c in candidates {
if std::path::Path::new(c).exists() {
return Ok(c.to_string());
}
}
Err(CliError::Config(
"Could not find a Chrome/Chromium binary. Install Google Chrome or set SUNO_CHROME_PATH."
.into(),
))
}
#[derive(Debug, Deserialize)]
struct Target {
#[serde(rename = "type")]
target_type: String,
url: String,
#[serde(rename = "webSocketDebuggerUrl")]
web_socket_debugger_url: String,
}
fn cdp_looks_like_chrome(version: &serde_json::Value) -> bool {
version
.get("Browser")
.and_then(|b| b.as_str())
.map(|b| {
let b = b.to_ascii_lowercase();
b.contains("chrome") || b.contains("chromium")
})
.unwrap_or(false)
}
fn free_loopback_port() -> Result<u16, CliError> {
let listener = std::net::TcpListener::bind((CDP_HOST, 0))
.map_err(|e| CliError::Config(format!("could not reserve a debug port: {e}")))?;
listener
.local_addr()
.map(|a| a.port())
.map_err(|e| CliError::Config(format!("debug port address: {e}")))
}
async fn cdp_version() -> Result<serde_json::Value, CliError> {
let port = active_port();
let url = format!("http://{CDP_HOST}:{port}/json/version");
let resp = reqwest::Client::new()
.get(&url)
.timeout(Duration::from_secs(2))
.send()
.await
.map_err(|e| CliError::Config(format!("CDP /json/version: {e}")))?;
let v: serde_json::Value = resp
.json()
.await
.map_err(|e| CliError::Config(format!("CDP json parse: {e}")))?;
Ok(v)
}
async fn cdp_list() -> Result<Vec<Target>, CliError> {
let port = active_port();
let url = format!("http://{CDP_HOST}:{port}/json/list");
let resp = reqwest::Client::new()
.get(&url)
.timeout(Duration::from_secs(5))
.send()
.await
.map_err(|e| CliError::Config(format!("CDP /json/list: {e}")))?;
let list: Vec<Target> = resp
.json()
.await
.map_err(|e| CliError::Config(format!("CDP json parse: {e}")))?;
Ok(list)
}
async fn find_or_create_suno_tab() -> Result<Target, CliError> {
let targets = cdp_list().await?;
if let Some(t) = targets.into_iter().find(|t| {
t.target_type == "page"
&& !t.web_socket_debugger_url.is_empty()
&& !t.url.starts_with("chrome://")
}) {
return Ok(t);
}
let url = format!(
"http://{CDP_HOST}:{}/json/new?{}",
active_port(),
urlencode("about:blank")
);
let resp = reqwest::Client::new()
.put(&url)
.timeout(Duration::from_secs(10))
.send()
.await
.map_err(|e| CliError::Config(format!("CDP /json/new: {e}")))?;
let t: Target = resp
.json()
.await
.map_err(|e| CliError::Config(format!("CDP /json/new parse: {e}")))?;
sleep(Duration::from_millis(800)).await;
Ok(t)
}
fn urlencode(s: &str) -> String {
s.replace(":", "%3A").replace("/", "%2F")
}
#[derive(Serialize)]
struct CdpReq<'a> {
id: u64,
method: &'a str,
params: serde_json::Value,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct CdpCookie {
name: String,
value: String,
domain: String,
path: String,
secure: bool,
http_only: bool,
same_site: &'static str,
}
type CdpStream =
tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>;
async fn cdp_call(
ws: &mut CdpStream,
id: u64,
method: &str,
params: serde_json::Value,
) -> Result<serde_json::Value, CliError> {
let req = CdpReq { id, method, params };
let payload = serde_json::to_string(&req).unwrap();
ws.send(Message::Text(payload))
.await
.map_err(|e| CliError::Config(format!("CDP ws send {method}: {e}")))?;
loop {
let msg = timeout(Duration::from_secs(60), ws.next())
.await
.map_err(|_| CliError::Config(format!("CDP {method} timeout")))?
.ok_or_else(|| CliError::Config(format!("CDP {method} ws closed")))?
.map_err(|e| CliError::Config(format!("CDP {method} ws err: {e}")))?;
let text = match msg {
Message::Text(t) => t.to_string(),
Message::Binary(_) | Message::Ping(_) | Message::Pong(_) | Message::Frame(_) => {
continue;
}
Message::Close(_) => {
return Err(CliError::Config(format!("CDP {method} ws closed mid-call")));
}
};
let v: serde_json::Value = serde_json::from_str(&text)
.map_err(|e| CliError::Config(format!("CDP {method} json: {e}")))?;
if v.get("id").and_then(|x| x.as_u64()) == Some(id) {
if let Some(err) = v.get("error") {
return Err(CliError::Config(format!("CDP {method} error: {err}")));
}
return Ok(v.get("result").cloned().unwrap_or(serde_json::Value::Null));
}
}
}
async fn render_and_execute(ws_url: &str, auth: &AuthState) -> Result<String, CliError> {
let (mut ws, _) = tokio_tungstenite::connect_async(ws_url)
.await
.map_err(|e| CliError::Config(format!("CDP ws connect: {e}")))?;
let mut next_id: u64 = 0;
let mut next = || -> u64 {
next_id += 1;
next_id
};
cdp_call(&mut ws, next(), "Network.enable", serde_json::json!({})).await?;
cdp_call(&mut ws, next(), "Page.enable", serde_json::json!({})).await?;
cdp_call(&mut ws, next(), "Runtime.enable", serde_json::json!({})).await?;
cdp_call(
&mut ws,
next(),
"Emulation.setDeviceMetricsOverride",
serde_json::json!({
"width": 1280,
"height": 900,
"deviceScaleFactor": 1,
"mobile": false
}),
)
.await?;
clear_suno_cookies(&mut ws, &mut next).await?;
let cookies = extract_cookies(auth)?;
if !cookies.is_empty() {
cdp_call(
&mut ws,
next(),
"Network.setCookies",
serde_json::json!({ "cookies": cookies }),
)
.await?;
}
cdp_call(
&mut ws,
next(),
"Page.navigate",
serde_json::json!({ "url": "https://suno.com/create" }),
)
.await?;
let mut ready = false;
for _ in 0..30 {
sleep(Duration::from_secs(1)).await;
let probe = cdp_call(
&mut ws,
next(),
"Runtime.evaluate",
serde_json::json!({
"expression": "typeof hcaptcha !== 'undefined' && !!hcaptcha.render",
"returnByValue": true,
}),
)
.await?;
if probe
.get("result")
.and_then(|r| r.get("value"))
.and_then(|v| v.as_bool())
.unwrap_or(false)
{
ready = true;
break;
}
}
if !ready {
let page_state = page_state_excerpt(&mut ws, &mut next).await?;
return Err(CliError::Config(format!(
"hcaptcha never finished loading on suno.com/create ({page_state})"
)));
}
sleep(Duration::from_secs(2)).await;
let solve_js = format!(
r#"
(async () => {{
try {{
const div = document.createElement('div');
div.style.cssText = 'position:fixed;top:-9999px;left:-9999px;';
document.body.appendChild(div);
const id = hcaptcha.render(div, {{
sitekey: '{SUNO_HCAPTCHA_SITEKEY}',
size: 'invisible',
sentry: false,
endpoint: 'https://hcaptcha-endpoint-prod.suno.com',
assethost: 'https://hcaptcha-assets-prod.suno.com',
imghost: 'https://hcaptcha-imgs-prod.suno.com',
reportapi: 'https://hcaptcha-reportapi-prod.suno.com',
}});
const r = await hcaptcha.execute(id, {{ async: true }});
return (r && r.response) ? r.response : '';
}} catch (e) {{
return 'ERR:' + String(e);
}}
}})()
"#
);
let result = cdp_call(
&mut ws,
next(),
"Runtime.evaluate",
serde_json::json!({
"expression": solve_js,
"awaitPromise": true,
"returnByValue": true,
}),
)
.await?;
let token = result
.get("result")
.and_then(|r| r.get("value"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
if token.is_empty() {
return Err(CliError::Config("hcaptcha returned empty token".into()));
}
if token.starts_with("ERR:") {
return Err(CliError::Config(format!("hcaptcha solver: {token}")));
}
Ok(token)
}
async fn clear_suno_cookies(
ws: &mut CdpStream,
next: &mut impl FnMut() -> u64,
) -> Result<(), CliError> {
let existing = cdp_call(
ws,
next(),
"Network.getCookies",
serde_json::json!({
"urls": [
"https://suno.com/",
"https://auth.suno.com/",
"https://studio-api-prod.suno.com/",
]
}),
)
.await?;
let Some(cookies) = existing.get("cookies").and_then(|c| c.as_array()) else {
return Ok(());
};
for ck in cookies {
let name = ck.get("name").and_then(|v| v.as_str()).unwrap_or("");
if name.is_empty() {
continue;
}
let mut params = serde_json::json!({ "name": name });
if let Some(domain) = ck.get("domain").and_then(|v| v.as_str()) {
params["domain"] = domain.into();
}
if let Some(path) = ck.get("path").and_then(|v| v.as_str()) {
params["path"] = path.into();
}
cdp_call(ws, next(), "Network.deleteCookies", params).await?;
}
Ok(())
}
async fn page_state_excerpt(
ws: &mut CdpStream,
next: &mut impl FnMut() -> u64,
) -> Result<String, CliError> {
let state = cdp_call(
ws,
next(),
"Runtime.evaluate",
serde_json::json!({
"expression": "JSON.stringify({ href: location.href, body: (document.body && document.body.innerText || '').slice(0, 240) })",
"returnByValue": true,
}),
)
.await?;
let raw = state
.get("result")
.and_then(|r| r.get("value"))
.and_then(|v| v.as_str())
.unwrap_or("{}");
let parsed: serde_json::Value = serde_json::from_str(raw).unwrap_or_default();
let href = parsed.get("href").and_then(|v| v.as_str()).unwrap_or("");
let body = parsed
.get("body")
.and_then(|v| v.as_str())
.unwrap_or("")
.replace(['\n', '\r'], " ");
if body.is_empty() {
Ok(format!("page={href}"))
} else {
Ok(format!("page={href}; body={body}"))
}
}
fn extract_cookies(auth: &AuthState) -> Result<Vec<CdpCookie>, CliError> {
let mut out = Vec::new();
let mut seen = HashSet::new();
add_live_browser_cookies(&mut out, &mut seen);
merge_stored_auth(auth, &mut out, &mut seen);
Ok(out)
}
fn merge_stored_auth(
auth: &AuthState,
out: &mut Vec<CdpCookie>,
seen: &mut HashSet<(String, String)>,
) {
if let Some(clerk) = auth
.clerk_client_cookie
.as_deref()
.filter(|c| !c.trim().is_empty())
{
push_cookie(out, seen, "__client", clerk.trim(), "auth.suno.com", true);
push_cookie(out, seen, "__client", clerk.trim(), ".suno.com", true);
}
if let Some(device_id) = auth.device_id.as_deref().filter(|d| !d.trim().is_empty()) {
push_cookie(
out,
seen,
"ajs_anonymous_id",
device_id.trim(),
".suno.com",
false,
);
}
if let Some(cookie_header) = auth.cookie.as_deref().filter(|c| !c.trim().is_empty()) {
add_minimal_cookies_from_header(cookie_header, out, seen);
}
}
fn is_session_cookie(name: &str) -> bool {
name == "__client"
|| name == "__session"
|| name.starts_with("__client_")
|| name.starts_with("__session_")
}
fn add_live_browser_cookies(
out: &mut Vec<CdpCookie>,
seen: &mut HashSet<(String, String)>,
) -> bool {
let domains: Vec<String> = vec![
"suno.com".into(),
"auth.suno.com".into(),
".suno.com".into(),
];
let mut chosen: Option<(&'static str, Vec<rookie::enums::Cookie>)> = None;
let mut chosen_has_session = false;
for (browser_name, result) in [
("Chrome", rookie::chrome(Some(domains.clone()))),
("Arc", rookie::arc(Some(domains.clone()))),
("Brave", rookie::brave(Some(domains.clone()))),
("Firefox", rookie::firefox(Some(domains.clone()))),
("Edge", rookie::edge(Some(domains.clone()))),
] {
let Ok(cookies) = result else { continue };
let suno: Vec<_> = cookies
.into_iter()
.filter(|c| c.domain.contains("suno.com"))
.collect();
if suno.is_empty() {
continue;
}
let has_session = suno.iter().any(|c| is_session_cookie(&c.name));
if chosen.is_none() || (has_session && !chosen_has_session) {
chosen = Some((browser_name, suno));
chosen_has_session = has_session;
}
if chosen_has_session {
break;
}
}
let Some((browser_name, cookies)) = chosen else {
return false;
};
for c in &cookies {
add_minimal_cookie(&c.name, &c.value, &c.domain, c.http_only, out, seen);
}
if !out.is_empty() {
eprintln!("Using fresh Suno browser cookies from {browser_name}");
}
!out.is_empty()
}
fn add_minimal_cookies_from_header(
cookie_header: &str,
out: &mut Vec<CdpCookie>,
seen: &mut HashSet<(String, String)>,
) {
for part in cookie_header.split(';') {
let Some((name, value)) = part.trim().split_once('=') else {
continue;
};
add_minimal_cookie(name.trim(), value.trim(), ".suno.com", false, out, seen);
}
}
fn add_minimal_cookie(
name: &str,
value: &str,
domain: &str,
http_only: bool,
out: &mut Vec<CdpCookie>,
seen: &mut HashSet<(String, String)>,
) {
if name.is_empty() || value.is_empty() || !is_captcha_cookie(name) {
return;
}
if name == "__client" || name.starts_with("__client_") {
push_cookie(out, seen, name, value, "auth.suno.com", true);
push_cookie(out, seen, name, value, ".suno.com", true);
return;
}
let cookie_domain = if domain.contains("auth.suno.com") {
"auth.suno.com"
} else {
".suno.com"
};
push_cookie(out, seen, name, value, cookie_domain, http_only);
}
fn is_captcha_cookie(name: &str) -> bool {
matches!(
name,
"__client"
| "__session"
| "clerk_active_context"
| "ajs_anonymous_id"
| "suno_device_id"
| "statsig_stable_id"
| "ssr_bucket"
| "has_logged_in_before"
) || name.starts_with("__client_")
|| name.starts_with("__session_")
}
fn push_cookie(
out: &mut Vec<CdpCookie>,
seen: &mut HashSet<(String, String)>,
name: &str,
value: &str,
domain: &str,
http_only: bool,
) {
let key = (name.to_string(), domain.to_string());
if !seen.insert(key) {
return;
}
out.push(CdpCookie {
name: name.to_string(),
value: value.to_string(),
domain: domain.to_string(),
path: "/".to_string(),
secure: true,
http_only,
same_site: "Lax",
});
}
fn drain_stderr(child: &mut Child) {
if let Some(stderr) = child.stderr.take() {
let mut reader = BufReader::new(stderr).lines();
tokio::spawn(async move {
while let Ok(Some(_)) = reader.next_line().await {
}
});
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn loopback_ws_accepts_only_local_hosts() {
assert!(is_loopback_ws("ws://127.0.0.1:9233/devtools/page/ABCD1234"));
assert!(is_loopback_ws("ws://localhost:9233/devtools/browser/x"));
assert!(is_loopback_ws("ws://[::1]:9233/devtools/page/x"));
assert!(is_loopback_ws("ws://127.9.9.9:1/x"));
assert!(!is_loopback_ws("ws://10.0.0.5:9233/devtools/page/x"));
assert!(!is_loopback_ws("ws://evil.example.com:9233/devtools/x"));
assert!(!is_loopback_ws("ws://127.0.0.1.evil.com:9233/x"));
assert!(!is_loopback_ws("http://127.0.0.1:9233/x"));
assert!(!is_loopback_ws(""));
}
#[test]
fn only_chromium_family_listeners_are_reusable() {
assert!(cdp_looks_like_chrome(&serde_json::json!({
"Browser": "Chrome/146.0.0.0"
})));
assert!(cdp_looks_like_chrome(&serde_json::json!({
"Browser": "HeadlessChromium/120"
})));
assert!(!cdp_looks_like_chrome(&serde_json::json!({
"Browser": "my-debug-proxy/1.0"
})));
assert!(!cdp_looks_like_chrome(&serde_json::json!({})));
}
#[test]
fn session_cookie_matches_clerk_variants() {
assert!(is_session_cookie("__client"));
assert!(is_session_cookie("__session"));
assert!(is_session_cookie("__client_uat"));
assert!(is_session_cookie("__session_ABC"));
assert!(!is_session_cookie("ajs_anonymous_id"));
assert!(!is_session_cookie("client"));
}
}