Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 119 additions & 18 deletions desktop/skill-package/src/science.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -650,26 +650,51 @@ struct ControlSession {
}

fn authenticate(client: &Client, origin: &str, nonce: &str) -> Result<ControlSession, AttachError> {
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)
.header(COOKIE, format!("operon_auth={auth_cookie}"))
.send()
.map_err(|_| AttachError::new("SCIENCE_CONTROL_FAILED", "Science CSRF 初始化失败"))?;
ensure_success(&csrf, "CSRF 初始化")?;
let csrf_cookie = response_cookie(&csrf, "operon_csrf")
csrf_cookie = response_cookie(&csrf, "operon_csrf").or(csrf_cookie);
let csrf_cookie = csrf_cookie
.ok_or_else(|| AttachError::new("SCIENCE_CONTROL_FAILED", "Science CSRF 未返回 cookie"))?;
Ok(ControlSession {
auth_cookie,
Expand Down Expand Up @@ -868,11 +893,17 @@ mod tests {
}

fn reply(stream: &mut TcpStream, cookie: Option<&str>, body: &str) {
let cookie = cookie
let cookies = cookie.into_iter().collect::<Vec<_>>();
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::<String>();
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();
Expand Down Expand Up @@ -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 "));
Expand All @@ -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 [
Expand Down
60 changes: 45 additions & 15 deletions desktop/src-tauri/src/commands/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,41 +97,34 @@ pub(crate) fn stop_sandbox_state<R: tauri::Runtime>(
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,
) -> Result<(), String> {
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
Expand Down Expand Up @@ -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::<SharedAppState>());
assert_eq!(status["proxy"], "green");
assert_eq!(status["sandbox"], "green");
Expand Down
79 changes: 74 additions & 5 deletions desktop/src-tauri/src/runtime/science.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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:<port>` 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<String> {
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();
Expand All @@ -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}")
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<dyn std::error::Error>> {
Expand Down Expand Up @@ -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();
Expand All @@ -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();
Expand Down
Loading