diff --git a/desktop/skill-package/src/science.rs b/desktop/skill-package/src/science.rs index b46fa23..b08c2af 100644 --- a/desktop/skill-package/src/science.rs +++ b/desktop/skill-package/src/science.rs @@ -9,7 +9,7 @@ use std::thread; use std::time::{Duration, Instant}; use reqwest::blocking::{Client, Response}; -use reqwest::header::{CONTENT_TYPE, COOKIE, ORIGIN, SET_COOKIE}; +use reqwest::header::{ACCEPT, CONTENT_TYPE, COOKIE, ORIGIN, SET_COOKIE}; use reqwest::redirect::Policy; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; @@ -650,18 +650,42 @@ struct ControlSession { } fn authenticate(client: &Client, origin: &str, nonce: &str) -> Result { - let auth = client - .post(format!("{origin}/api/auth/nonce")) + // Current Science consumes the nonce from the browser URL itself. Older + // releases exposed POST /api/auth/nonce instead, so retain that endpoint + // as an explicit compatibility path only when the browser exchange does + // not yield an authenticated session. + let browser_auth = client + .get(format!("{origin}/?nonce={nonce}")) .header(ORIGIN, origin) - .form(&[("nonce", nonce), ("dest", "/")]) - .send() - .map_err(|_| { - AttachError::new("SCIENCE_CONTROL_FAILED", "Science nonce 认证失败").retryable(true) - })?; - ensure_success(&auth, "nonce 认证")?; - let auth_cookie = response_cookie(&auth, "operon_auth").ok_or_else(|| { - AttachError::new("SCIENCE_CONTROL_FAILED", "Science nonce 未返回会话 cookie") - })?; + .header(ACCEPT, "text/html") + .send(); + let browser_cookies = browser_auth.as_ref().ok().and_then(|response| { + (response.status().is_success() || response.status().is_redirection()).then(|| { + ( + response_cookie(response, "operon_auth"), + response_cookie(response, "operon_csrf"), + ) + }) + }); + let (auth_cookie, mut csrf_cookie) = match browser_cookies { + Some((Some(auth_cookie), csrf_cookie)) => (auth_cookie, csrf_cookie), + _ => { + let auth = client + .post(format!("{origin}/api/auth/nonce")) + .header(ORIGIN, origin) + .form(&[("nonce", nonce), ("dest", "/")]) + .send() + .map_err(|_| { + AttachError::new("SCIENCE_CONTROL_FAILED", "Science nonce 认证失败") + .retryable(true) + })?; + ensure_success(&auth, "legacy nonce 认证")?; + let auth_cookie = response_cookie(&auth, "operon_auth").ok_or_else(|| { + AttachError::new("SCIENCE_CONTROL_FAILED", "Science nonce 未返回会话 cookie") + })?; + (auth_cookie, response_cookie(&auth, "operon_csrf")) + } + }; let csrf = client .get(format!("{origin}/api/csrf")) .header(ORIGIN, origin) @@ -669,7 +693,8 @@ fn authenticate(client: &Client, origin: &str, nonce: &str) -> Result, body: &str) { - let cookie = cookie + let cookies = cookie.into_iter().collect::>(); + reply_with_cookies(stream, &cookies, body); + } + + fn reply_with_cookies(stream: &mut TcpStream, cookies: &[&str], body: &str) { + let cookies = cookies + .iter() .map(|value| format!("Set-Cookie: {value}; Path=/; SameSite=Strict\r\n")) - .unwrap_or_default(); + .collect::(); let response = format!( - "HTTP/1.1 200 OK\r\n{cookie}Content-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + "HTTP/1.1 200 OK\r\n{cookies}Content-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len() ); stream.write_all(response.as_bytes()).unwrap(); @@ -1061,8 +1092,7 @@ printf '%s\n' 'http://127.0.0.1:18990/?nonce=fresh-one'"#, ); worker.join().unwrap(); let requests = requests.lock().unwrap(); - assert!(requests[0].starts_with("POST /api/auth/nonce ")); - assert!(requests[0].contains("nonce=fresh-nonce")); + assert!(requests[0].starts_with("GET /?nonce=fresh-nonce ")); assert!(requests[1].starts_with("GET /api/csrf ")); assert!(requests[2].starts_with("GET /api/agents?names=OPERON&include_metadata=true ")); assert!(requests[3].starts_with("POST /api/agents/OPERON/skills ")); @@ -1074,6 +1104,77 @@ printf '%s\n' 'http://127.0.0.1:18990/?nonce=fresh-one'"#, fs::remove_dir_all(context.home.parent().unwrap()).unwrap(); } + #[test] + fn browser_nonce_may_supply_auth_and_csrf_cookies_together() { + let Some(listener) = bind_loopback() else { + return; + }; + let port = listener.local_addr().unwrap().port(); + let worker = thread::spawn(move || { + let (mut auth, _) = listener.accept().unwrap(); + assert!(read_request(&mut auth).starts_with("GET /?nonce=fresh-nonce ")); + reply_with_cookies( + &mut auth, + &["operon_auth=auth-token", "operon_csrf=csrf-token"], + "{}", + ); + let (mut csrf, _) = listener.accept().unwrap(); + assert!(read_request(&mut csrf).starts_with("GET /api/csrf ")); + reply(&mut csrf, None, "{}"); + }); + let client = Client::builder() + .connect_timeout(Duration::from_secs(2)) + .timeout(Duration::from_secs(5)) + .redirect(Policy::none()) + .no_proxy() + .build() + .unwrap(); + let session = + authenticate(&client, &format!("http://127.0.0.1:{port}"), "fresh-nonce").unwrap(); + assert_eq!(session.auth_cookie, "auth-token"); + assert_eq!(session.csrf_cookie, "csrf-token"); + worker.join().unwrap(); + } + + #[test] + fn legacy_nonce_endpoint_remains_an_explicit_compatibility_path() { + let Some(listener) = bind_loopback() else { + return; + }; + let port = listener.local_addr().unwrap().port(); + let requests = Arc::new(Mutex::new(Vec::new())); + let captured = requests.clone(); + let worker = thread::spawn(move || { + let replies = [ + (None, "{}"), + (Some("operon_auth=auth-token"), "{}"), + (Some("operon_csrf=csrf-token"), "{}"), + ]; + for (cookie, body) in replies { + let (mut stream, _) = listener.accept().unwrap(); + captured.lock().unwrap().push(read_request(&mut stream)); + reply(&mut stream, cookie, body); + } + }); + let client = Client::builder() + .connect_timeout(Duration::from_secs(2)) + .timeout(Duration::from_secs(5)) + .redirect(Policy::none()) + .no_proxy() + .build() + .unwrap(); + let session = + authenticate(&client, &format!("http://127.0.0.1:{port}"), "fresh-nonce").unwrap(); + assert_eq!(session.auth_cookie, "auth-token"); + assert_eq!(session.csrf_cookie, "csrf-token"); + worker.join().unwrap(); + let requests = requests.lock().unwrap(); + assert!(requests[0].starts_with("GET /?nonce=fresh-nonce ")); + assert!(requests[1].starts_with("POST /api/auth/nonce ")); + assert!(requests[1].contains("nonce=fresh-nonce")); + assert!(requests[2].starts_with("GET /api/csrf ")); + } + #[test] fn strict_list_readback_rejects_missing_operon_and_malformed_skills() { for body in [ diff --git a/desktop/src-tauri/src/commands/runtime.rs b/desktop/src-tauri/src/commands/runtime.rs index d591dab..52626f3 100644 --- a/desktop/src-tauri/src/commands/runtime.rs +++ b/desktop/src-tauri/src/commands/runtime.rs @@ -97,21 +97,23 @@ pub(crate) fn stop_sandbox_state( result } -/// 切换运行模式("proxy" 第三方 / "official" 官方)。切官方要先拆第三方链路成功再落盘。 +/// 切换当前操作视图("proxy" 第三方 / "official" 官方)。 +/// +/// 官方 Science 是不受 CSSwitch 管理的独立实例;切换视图不得拆掉已经运行的 +/// 第三方 Gateway/隔离 Science。串行锁只用于等待在途 start/stop 完成后再保存视图, +/// 防止配置更新与运行事务交错。 #[tauri::command] pub(crate) async fn set_mode( - app: tauri::AppHandle, state: State<'_, SharedAppState>, lifecycle: State<'_, SharedLifecycle>, mode: String, ) -> Result<(), String> { let state = state.inner().clone(); let lifecycle = lifecycle.inner().clone(); - run_blocking(move || set_mode_inner(app, state, lifecycle, mode)).await + run_blocking(move || set_mode_inner(state, lifecycle, mode)).await } fn set_mode_inner( - app: tauri::AppHandle, state: SharedAppState, lifecycle: SharedLifecycle, mode: String, @@ -119,19 +121,10 @@ fn set_mode_inner( if mode != "proxy" && mode != "official" { return Err(format!("未知模式:{mode}(只支持 proxy / official)。")); } - // 经串行器(修 P1-b):切官方的「拆链路 + 落盘」必须与「一键开始」等互斥,否则一键起到一半时 - // 切官方会先停链路、一键随后又把沙箱/OAuth 起起来 → 显示官方却有第三方沙箱在跑。bump_generation - // 作废任何在途启动,防被停后又拿旧配置写回运行态。 + // 模式现在只表示 UI 操作视图。仍经串行器与「一键开始」等互斥:在途启动先完整 + // 成功或失败,随后才保存新视图;不停止、不重启,也不作废已完成的第三方链路。 lifecycle.with_serialized(|| { let dir = config::default_dir(); - if mode == "official" { - lifecycle.bump_generation(); - let mut st = lock(&state); - stop_sandbox_state(&app, &mut st).map_err(|e| { - format!("停止沙箱失败,未切换到官方模式:{e}(真实实例 8765 未受影响)") - })?; - st.stop_proxy(); - } config::update(&dir, { let mode = mode.clone(); move |c| c.mode = mode @@ -1434,6 +1427,43 @@ esac "7" ); + let running_pid = fs::read_to_string(fake_state_dir.join("pid")).unwrap(); + super::set_mode_inner(state.clone(), lifecycle.clone(), "official".into()) + .expect("switching to the official view must preserve the managed runtime"); + assert_eq!(config::load_from(&config_dir).unwrap().mode, "official"); + wait_http_health(proxy_port); + wait_http_health(sandbox_port); + { + let st = lock(&state); + assert!(st.proxy.is_some(), "Gateway child must remain tracked"); + assert!( + st.science_runtime.is_some(), + "isolated Science identity must remain tracked" + ); + } + assert_eq!( + fs::read_to_string(fake_state_dir.join("pid")).unwrap(), + running_pid, + "switching views must not restart isolated Science" + ); + + super::set_mode_inner(state.clone(), lifecycle.clone(), "proxy".into()) + .expect("switching back to the third-party view should only persist the view"); + let reopened = sandbox_session::one_click_login( + handle.clone(), + state.clone(), + lifecycle.as_ref(), + None, + None, + ) + .expect("one-click after a view round-trip should reuse isolated Science"); + assert_eq!(reopened["action"], "reopened"); + assert_eq!( + fs::read_to_string(fake_state_dir.join("pid")).unwrap(), + running_pid, + "view round-trip must preserve the third-party session" + ); + let status = super::status(app.state::()); assert_eq!(status["proxy"], "green"); assert_eq!(status["sandbox"], "green"); diff --git a/desktop/src-tauri/src/runtime/science.rs b/desktop/src-tauri/src/runtime/science.rs index c058746..b2377fb 100644 --- a/desktop/src-tauri/src/runtime/science.rs +++ b/desktop/src-tauri/src/runtime/science.rs @@ -635,7 +635,25 @@ fn test_listener_marker_matches(pid: &str, runtime: &ScienceRuntimeIdentity) -> .is_some_and(|recorded| recorded.trim() == pid) } -/// Return the sandbox UI URL, falling back to the plain localhost port. +/// Put the CSSwitch-managed browser session on a distinct cookie origin from +/// the official Science instance. Browser cookies are scoped by host, not by +/// port, so opening both instances as `localhost:` lets either login +/// replace the other's session cookie even though their daemons and data dirs +/// are isolated. +fn isolate_sandbox_browser_origin(raw: &str, port: u16) -> Option { + let localhost = format!("http://localhost:{port}"); + let loopback = format!("http://127.0.0.1:{port}"); + let suffix = raw + .strip_prefix(&localhost) + .or_else(|| raw.strip_prefix(&loopback))?; + if !suffix.is_empty() && !suffix.starts_with('/') && !suffix.starts_with('?') { + return None; + } + Some(format!("{loopback}{suffix}")) +} + +/// Return the sandbox UI URL on the dedicated `127.0.0.1` browser origin, +/// falling back to the plain loopback port when the CLI emits no usable URL. pub(crate) fn sandbox_url(port: u16, runtime: &ScienceRuntimeIdentity) -> String { let home = sandbox_home(); let data_dir = sandbox_data_dir(); @@ -648,7 +666,9 @@ pub(crate) fn sandbox_url(port: u16, runtime: &ScienceRuntimeIdentity) -> String { let s = String::from_utf8_lossy(&out.stdout); if let Some(url) = first_http_url(&s) { - return url; + if let Some(isolated) = isolate_sandbox_browser_origin(&url, port) { + return isolated; + } } } format!("http://127.0.0.1:{port}") @@ -834,8 +854,9 @@ mod tests { use std::process::{ExitStatus, Output}; use super::{ - classify_known_runtime_state, classify_sandbox_state, first_http_url, runtime_status_value, - sandbox_home, sandbox_running_ours, sandbox_url, science_runtime_preflight_for_paths, + classify_known_runtime_state, classify_sandbox_state, first_http_url, + isolate_sandbox_browser_origin, runtime_status_value, sandbox_home, sandbox_running_ours, + sandbox_url, science_runtime_preflight_for_paths, science_runtime_preflight_for_paths_cached, science_status_running, select_science_runtime_for_paths, select_science_runtime_for_paths_cached, settings_change_needs_teardown, stop_runtime_from_probe, trusted_science_status, @@ -889,6 +910,32 @@ mod tests { ); } + #[test] + fn sandbox_browser_origin_is_distinct_from_official_localhost_cookie_scope() { + assert_eq!( + isolate_sandbox_browser_origin("http://localhost:8990/?nonce=single-use-token", 8990) + .as_deref(), + Some("http://127.0.0.1:8990/?nonce=single-use-token") + ); + assert_eq!( + isolate_sandbox_browser_origin("http://127.0.0.1:8990/project/one?nonce=token", 8990) + .as_deref(), + Some("http://127.0.0.1:8990/project/one?nonce=token") + ); + assert!( + isolate_sandbox_browser_origin("http://localhost:8765/?nonce=official", 8990).is_none() + ); + assert!(isolate_sandbox_browser_origin( + "http://localhost:8990.evil.invalid/?nonce=bad", + 8990 + ) + .is_none()); + assert!( + isolate_sandbox_browser_origin("https://localhost:8990/?nonce=wrong-scheme", 8990) + .is_none() + ); + } + #[test] fn version_cache_is_shared_and_invalidates_when_binary_changes( ) -> Result<(), Box> { @@ -1207,7 +1254,7 @@ mod tests { } #[test] - fn sandbox_url_falls_back_to_localhost_when_cli_absent() { + fn sandbox_url_falls_back_to_loopback_ip_when_cli_absent() { let root = unique_temp_dir("science-url-fallback").unwrap(); let bin = root.join("claude-science"); write_fake_bin(&bin, 0o755).unwrap(); @@ -1220,6 +1267,28 @@ mod tests { fs::remove_dir_all(root).unwrap(); } + #[test] + fn sandbox_url_normalizes_cli_localhost_nonce_to_loopback_ip() { + let root = unique_temp_dir("science-url-cookie-origin").unwrap(); + let bin = root.join("claude-science"); + fs::write( + &bin, + "#!/bin/sh\nprintf '%s\\n' 'http://localhost:8990/?nonce=one-time'\n", + ) + .unwrap(); + fs::set_permissions(&bin, fs::Permissions::from_mode(0o755)).unwrap(); + let runtime = ScienceRuntimeIdentity { + path: bin, + source: ScienceRuntimeSource::InstalledApp, + version: None, + }; + assert_eq!( + sandbox_url(8990, &runtime), + "http://127.0.0.1:8990/?nonce=one-time" + ); + fs::remove_dir_all(root).unwrap(); + } + #[test] fn sandbox_identity_does_not_trust_health_when_cli_absent() { let root = unique_temp_dir("science-identity-fallback").unwrap(); diff --git a/desktop/src/index.html b/desktop/src/index.html index 34ed503..69a1695 100644 --- a/desktop/src/index.html +++ b/desktop/src/index.html @@ -69,13 +69,13 @@

尚未选择配置

- +