//! Secure HTTP driving adapter for `idea --serve`. //! //! B3 deliberately exposes only request/response RPC over `/api/invoke`; PTY and //! live WebSocket streams are left to later lots. The shared backend core stays //! unaware of HTTP, cookies and origins. use std::collections::HashSet; use std::env; use std::net::SocketAddr; use std::path::PathBuf; use std::process::ExitCode; use std::sync::{Arc, Mutex}; use bytes::Bytes; use cookie::{Cookie, SameSite}; use http::header::{HeaderValue, CONTENT_TYPE, COOKIE, ORIGIN, SET_COOKIE}; #[cfg(test)] use http::Request; use http::{HeaderMap, Method, Response, StatusCode, Uri}; use http_body_util::{BodyExt, Full}; use serde::Deserialize; use serde_json::{json, Value}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpListener; use uuid::Uuid; use application::{GetProjectWorkStateInput, OpenProjectInput}; use domain::Project; use crate::dto::{ parse_project_id, ErrorDto, HealthRequestDto, HealthResponseDto, ProjectDto, ProjectListDto, ProjectWorkStateDto, }; use crate::state::AppState; const DEFAULT_LISTEN: &str = "127.0.0.1:17373"; const SESSION_COOKIE: &str = "idea_session"; type ResponseBody = Full; /// Runs the `idea --serve` subcommand from already-split CLI arguments. pub fn run_from_args(args: Vec) -> ExitCode { let config = match ServerConfig::from_args(args).and_then(|config| { config.validate()?; Ok(config) }) { Ok(config) => config, Err(err) => { eprintln!("idea --serve: {err}"); return ExitCode::from(2); } }; let state = Arc::new(ServerState::new(config.clone())); eprintln!("IdeA pairing code: {}", state.pairing_code()); eprintln!("IdeA server listening on {}", config.listen); match tokio::runtime::Builder::new_multi_thread() .enable_all() .build() { Ok(runtime) => match runtime.block_on(run_server(config, state)) { Ok(()) => ExitCode::SUCCESS, Err(err) => { eprintln!("idea --serve: {err}"); ExitCode::from(1) } }, Err(err) => { eprintln!("idea --serve: failed to build runtime: {err}"); ExitCode::from(1) } } } #[derive(Clone)] struct ServerConfig { listen: SocketAddr, public_origin: Option, allow_remote: bool, trust_reverse_proxy: bool, app_data_dir: PathBuf, } impl ServerConfig { fn from_args(args: Vec) -> Result { let mut listen = DEFAULT_LISTEN .parse::() .expect("default listen address is valid"); let mut public_origin = None; let mut allow_remote = false; let mut trust_reverse_proxy = false; let mut app_data_dir = default_app_data_dir(); let mut it = args.into_iter(); while let Some(arg) = it.next() { match arg.as_str() { "--listen" => { let value = it .next() .ok_or_else(|| "--listen requires an address".to_owned())?; listen = value .parse() .map_err(|_| format!("invalid --listen address: {value}"))?; } "--public-origin" => { let value = it .next() .ok_or_else(|| "--public-origin requires an origin".to_owned())?; public_origin = Some(value); } "--allow-remote" => allow_remote = true, "--trust-reverse-proxy" => trust_reverse_proxy = true, "--app-data-dir" => { let value = it .next() .ok_or_else(|| "--app-data-dir requires a path".to_owned())?; app_data_dir = PathBuf::from(value); } "--help" | "-h" => return Err(Self::usage()), other => return Err(format!("unknown --serve argument: {other}")), } } Ok(Self { listen, public_origin, allow_remote, trust_reverse_proxy, app_data_dir, }) } fn validate(&self) -> Result<(), String> { if let Some(origin) = &self.public_origin { validate_origin(origin)?; } if !self.listen.ip().is_loopback() && !self.allow_remote { return Err( "refusing non-loopback bind without --allow-remote and HTTPS proxy config" .to_owned(), ); } if self.allow_remote { let Some(origin) = &self.public_origin else { return Err("--allow-remote requires --public-origin https://...".to_owned()); }; if !origin.starts_with("https://") { return Err("--allow-remote requires an HTTPS public origin".to_owned()); } if !self.trust_reverse_proxy { return Err("--allow-remote requires --trust-reverse-proxy".to_owned()); } } Ok(()) } fn secure_cookie(&self) -> bool { self.allow_remote || self .public_origin .as_deref() .is_some_and(|o| o.starts_with("https://")) } fn usage() -> String { "usage: idea --serve [--listen IP:PORT] [--app-data-dir PATH] [--allow-remote --public-origin https://host --trust-reverse-proxy]".to_owned() } } struct ServerState { config: ServerConfig, app: AppState, pairing_code: String, sessions: Mutex>, } impl ServerState { fn new(config: ServerConfig) -> Self { Self { app: AppState::build(config.app_data_dir.clone()), config, pairing_code: new_pairing_code(), sessions: Mutex::new(HashSet::new()), } } #[cfg(test)] fn new_for_test(config: ServerConfig, pairing_code: impl Into) -> Self { Self { app: AppState::build(config.app_data_dir.clone()), config, pairing_code: pairing_code.into(), sessions: Mutex::new(HashSet::new()), } } fn pairing_code(&self) -> &str { &self.pairing_code } fn create_session(&self) -> String { let token = new_session_token(); if let Ok(mut sessions) = self.sessions.lock() { sessions.insert(token.clone()); } token } fn has_session(&self, token: &str) -> bool { self.sessions .lock() .map(|sessions| sessions.contains(token)) .unwrap_or(false) } } async fn run_server(config: ServerConfig, state: Arc) -> Result<(), String> { let listener = TcpListener::bind(config.listen) .await .map_err(|err| format!("failed to bind {}: {err}", config.listen))?; loop { let (stream, _) = listener .accept() .await .map_err(|err| format!("failed to accept connection: {err}"))?; let state = Arc::clone(&state); tokio::spawn(async move { if let Err(err) = handle_tcp_connection(stream, state).await { eprintln!("idea --serve: connection error: {err}"); } }); } } async fn handle_tcp_connection( mut stream: tokio::net::TcpStream, state: Arc, ) -> Result<(), String> { let mut buffer = Vec::with_capacity(8192); let mut chunk = [0_u8; 2048]; let header_end = loop { let read = stream .read(&mut chunk) .await .map_err(|err| format!("failed to read request: {err}"))?; if read == 0 { return Ok(()); } buffer.extend_from_slice(&chunk[..read]); if buffer.len() > 1024 * 1024 { return Err("request too large".to_owned()); } if let Some(pos) = find_header_end(&buffer) { break pos; } }; let (method, uri, headers, content_length) = parse_http_request_head(&buffer[..header_end])?; let body_start = header_end + 4; while buffer.len() < body_start + content_length { let read = stream .read(&mut chunk) .await .map_err(|err| format!("failed to read request body: {err}"))?; if read == 0 { break; } buffer.extend_from_slice(&chunk[..read]); if buffer.len() > 1024 * 1024 { return Err("request too large".to_owned()); } } if buffer.len() < body_start + content_length { return Err("truncated request body".to_owned()); } let response = dispatch_http( method, uri, headers, Bytes::copy_from_slice(&buffer[body_start..body_start + content_length]), state, ) .await; write_http_response(&mut stream, response).await } #[cfg(test)] async fn handle_request( req: Request, state: Arc, ) -> Response { let (parts, body) = req.into_parts(); let body = match body.collect().await { Ok(body) => body.to_bytes(), Err(err) => { return error_response( StatusCode::BAD_REQUEST, "INVALID", format!("invalid request body: {err}"), None, ); } }; dispatch_http( parts.method, parts.uri, parts.headers, body, Arc::clone(&state), ) .await } fn find_header_end(buffer: &[u8]) -> Option { buffer.windows(4).position(|window| window == b"\r\n\r\n") } fn parse_http_request_head(head: &[u8]) -> Result<(Method, Uri, HeaderMap, usize), String> { let text = std::str::from_utf8(head).map_err(|_| "request head is not UTF-8".to_owned())?; let mut lines = text.split("\r\n"); let request_line = lines .next() .ok_or_else(|| "missing request line".to_owned())?; let mut request_parts = request_line.split_whitespace(); let method = request_parts .next() .ok_or_else(|| "missing method".to_owned())? .parse::() .map_err(|_| "invalid method".to_owned())?; let uri = request_parts .next() .ok_or_else(|| "missing uri".to_owned())? .parse::() .map_err(|_| "invalid uri".to_owned())?; let mut headers = HeaderMap::new(); let mut content_length = 0; for line in lines { if line.is_empty() { continue; } let Some((name, value)) = line.split_once(':') else { return Err("invalid header line".to_owned()); }; let name = http::header::HeaderName::from_bytes(name.trim().as_bytes()) .map_err(|_| "invalid header name".to_owned())?; let value = HeaderValue::from_str(value.trim()).map_err(|_| "invalid header value".to_owned())?; if name == http::header::CONTENT_LENGTH { content_length = value .to_str() .ok() .and_then(|raw| raw.parse::().ok()) .ok_or_else(|| "invalid content-length".to_owned())?; } headers.insert(name, value); } Ok((method, uri, headers, content_length)) } async fn write_http_response( stream: &mut tokio::net::TcpStream, response: Response, ) -> Result<(), String> { let status = response.status(); let headers = response.headers().clone(); let body = response .into_body() .collect() .await .map_err(|err| format!("failed to collect response: {err}"))? .to_bytes(); let reason = status.canonical_reason().unwrap_or("Unknown"); let mut bytes = format!("HTTP/1.1 {} {}\r\n", status.as_u16(), reason).into_bytes(); for (name, value) in &headers { bytes.extend_from_slice(name.as_str().as_bytes()); bytes.extend_from_slice(b": "); bytes.extend_from_slice(value.as_bytes()); bytes.extend_from_slice(b"\r\n"); } bytes.extend_from_slice(format!("content-length: {}\r\n", body.len()).as_bytes()); bytes.extend_from_slice(b"connection: close\r\n\r\n"); bytes.extend_from_slice(&body); stream .write_all(&bytes) .await .map_err(|err| format!("failed to write response: {err}")) } async fn dispatch_http( method: Method, uri: Uri, headers: HeaderMap, body: Bytes, state: Arc, ) -> Response { let origin = match validate_request_origin(&headers, &state.config) { Ok(origin) => origin, Err(response) => return *response, }; if has_forbidden_query_secret(&uri) { return error_response( StatusCode::BAD_REQUEST, "INVALID", "secrets in URL query strings are forbidden", origin.as_deref(), ); } if method == Method::OPTIONS { return cors_response(StatusCode::NO_CONTENT, origin.as_deref()); } match (method, uri.path()) { (Method::POST, "/api/pair") => pair(body, state, origin.as_deref()).await, (Method::POST, "/api/invoke") => { let Some(token) = session_cookie(&headers) else { return error_response( StatusCode::UNAUTHORIZED, "UNAUTHORIZED", "missing session cookie", origin.as_deref(), ); }; if !state.has_session(&token) { return error_response( StatusCode::UNAUTHORIZED, "UNAUTHORIZED", "invalid session cookie", origin.as_deref(), ); } invoke(body, state, origin.as_deref()).await } (_, "/api/pair" | "/api/invoke") => error_response( StatusCode::METHOD_NOT_ALLOWED, "METHOD_NOT_ALLOWED", "method not allowed", origin.as_deref(), ), _ => error_response( StatusCode::NOT_FOUND, "NOT_FOUND", "route not found", origin.as_deref(), ), } } #[derive(Deserialize)] struct PairRequest { code: String, } async fn pair( body: Bytes, state: Arc, origin: Option<&str>, ) -> Response { let request = match serde_json::from_slice::(&body) { Ok(request) => request, Err(err) => { return error_response( StatusCode::BAD_REQUEST, "INVALID", format!("invalid pairing request: {err}"), origin, ); } }; if request.code != state.pairing_code() { return error_response( StatusCode::FORBIDDEN, "FORBIDDEN", "invalid pairing code", origin, ); } let token = state.create_session(); let cookie = Cookie::build((SESSION_COOKIE, token)) .path("/") .http_only(true) .secure(state.config.secure_cookie()) .same_site(SameSite::Strict) .build(); let mut response = json_response(StatusCode::OK, &json!({ "paired": true }), origin); response.headers_mut().insert( SET_COOKIE, HeaderValue::from_str(&cookie.to_string()).expect("session cookie is header-safe"), ); response } #[derive(Deserialize)] struct InvokeRequest { command: String, #[serde(default)] args: Value, } async fn invoke( body: Bytes, state: Arc, origin: Option<&str>, ) -> Response { let request = match serde_json::from_slice::(&body) { Ok(request) => request, Err(err) => { return error_response( StatusCode::BAD_REQUEST, "INVALID", format!("invalid invoke request: {err}"), origin, ); } }; let result = match request.command.as_str() { "health" => invoke_health(&request.args, &state.app), "list_projects" => invoke_list_projects(&state.app).await, "open_project" => invoke_open_project(&request.args, &state.app).await, "get_project_work_state" => invoke_get_project_work_state(&request.args, &state.app).await, _ => Err(ErrorDto { code: "UNKNOWN_COMMAND".to_owned(), message: format!("unknown command: {}", request.command), }), }; match result { Ok(value) => json_response(StatusCode::OK, &value, origin), Err(error) => error_dto_response(status_for_error(&error), error, origin), } } fn invoke_health(args: &Value, state: &AppState) -> Result { let request = optional_request::(args)?; let output = state .health .execute(request.unwrap_or_default().into()) .map(HealthResponseDto::from) .map_err(ErrorDto::from)?; serde_json::to_value(output).map_err(serialization_error) } async fn invoke_list_projects(state: &AppState) -> Result { let output = state .list_projects .execute() .await .map(ProjectListDto::from) .map_err(ErrorDto::from)?; serde_json::to_value(output).map_err(serialization_error) } async fn invoke_open_project(args: &Value, state: &AppState) -> Result { let project_id = args .get("projectId") .and_then(Value::as_str) .ok_or_else(|| ErrorDto { code: "INVALID".to_owned(), message: "open_project requires args.projectId".to_owned(), })?; let project = resolve_project_readonly(project_id, state).await?; serde_json::to_value(ProjectDto::from(project)).map_err(serialization_error) } async fn invoke_get_project_work_state(args: &Value, state: &AppState) -> Result { let project_id = args .get("projectId") .and_then(Value::as_str) .ok_or_else(|| ErrorDto { code: "INVALID".to_owned(), message: "get_project_work_state requires args.projectId".to_owned(), })?; let project = resolve_project_readonly(project_id, state).await?; let output = state .get_project_work_state .execute(GetProjectWorkStateInput { project }) .await .map(ProjectWorkStateDto::from) .map_err(ErrorDto::from)?; serde_json::to_value(output).map_err(serialization_error) } async fn resolve_project_readonly(project_id: &str, state: &AppState) -> Result { let id = parse_project_id(project_id)?; state .open_project .execute(OpenProjectInput { project_id: id }) .await .map(|output| output.project) .map_err(ErrorDto::from) } fn optional_request(args: &Value) -> Result, ErrorDto> where T: for<'de> Deserialize<'de>, { match args { Value::Object(map) => match map.get("request") { Some(value) => serde_json::from_value(value.clone()) .map(Some) .map_err(invalid_args_error), None if map.is_empty() => Ok(None), None => serde_json::from_value(args.clone()) .map(Some) .map_err(invalid_args_error), }, Value::Null => Ok(None), _ => Err(ErrorDto { code: "INVALID".to_owned(), message: "args must be an object".to_owned(), }), } } fn validate_request_origin( headers: &HeaderMap, config: &ServerConfig, ) -> Result, Box>> { let Some(origin) = headers.get(ORIGIN).and_then(|value| value.to_str().ok()) else { return Err(Box::new(error_response( StatusCode::FORBIDDEN, "FORBIDDEN", "missing Origin header", None, ))); }; if origin_allowed(origin, config) { Ok(Some(origin.to_owned())) } else { Err(Box::new(error_response( StatusCode::FORBIDDEN, "FORBIDDEN", "origin not allowed", None, ))) } } fn origin_allowed(origin: &str, config: &ServerConfig) -> bool { if let Some(public_origin) = &config.public_origin { return origin == public_origin; } if !config.listen.ip().is_loopback() { return false; } let port = config.listen.port(); origin == format!("http://127.0.0.1:{port}") || origin == format!("http://localhost:{port}") || origin == format!("http://[::1]:{port}") } fn has_forbidden_query_secret(uri: &Uri) -> bool { uri.query().is_some_and(|query| { query.split('&').any(|pair| { let key = pair.split_once('=').map_or(pair, |(key, _)| key); matches!( key.to_ascii_lowercase().as_str(), "token" | "secret" | "session" | "code" ) }) }) } fn session_cookie(headers: &HeaderMap) -> Option { headers .get(COOKIE) .and_then(|value| value.to_str().ok()) .and_then(|raw| { raw.split(';').find_map(|part| { let (name, value) = part.trim().split_once('=')?; (name == SESSION_COOKIE).then(|| value.to_owned()) }) }) } fn json_response( status: StatusCode, value: &Value, origin: Option<&str>, ) -> Response { let body = serde_json::to_vec(value).expect("JSON value serializes"); let mut response = Response::new(Full::new(Bytes::from(body))); *response.status_mut() = status; response .headers_mut() .insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); add_cors_headers(&mut response, origin); response } fn error_dto_response( status: StatusCode, error: ErrorDto, origin: Option<&str>, ) -> Response { let value = serde_json::to_value(error).expect("ErrorDto serializes"); json_response(status, &value, origin) } fn error_response( status: StatusCode, code: impl Into, message: impl Into, origin: Option<&str>, ) -> Response { error_dto_response( status, ErrorDto { code: code.into(), message: message.into(), }, origin, ) } fn cors_response(status: StatusCode, origin: Option<&str>) -> Response { let mut response = Response::new(Full::new(Bytes::new())); *response.status_mut() = status; add_cors_headers(&mut response, origin); response } fn add_cors_headers(response: &mut Response, origin: Option<&str>) { if let Some(origin) = origin { if let Ok(origin) = HeaderValue::from_str(origin) { response .headers_mut() .insert("access-control-allow-origin", origin); response.headers_mut().insert( "access-control-allow-credentials", HeaderValue::from_static("true"), ); response.headers_mut().insert( "access-control-allow-headers", HeaderValue::from_static("content-type"), ); response.headers_mut().insert( "access-control-allow-methods", HeaderValue::from_static("POST, OPTIONS"), ); } } } fn status_for_error(error: &ErrorDto) -> StatusCode { match error.code.as_str() { "UNKNOWN_COMMAND" => StatusCode::BAD_REQUEST, "INVALID" => StatusCode::BAD_REQUEST, "NOT_FOUND" => StatusCode::NOT_FOUND, "FORBIDDEN" => StatusCode::FORBIDDEN, "UNAUTHORIZED" => StatusCode::UNAUTHORIZED, _ => StatusCode::INTERNAL_SERVER_ERROR, } } fn validate_origin(origin: &str) -> Result<(), String> { if origin == "*" || origin.contains('*') { return Err("--public-origin must be exact, not a wildcard".to_owned()); } if origin.ends_with('/') { return Err("--public-origin must not end with '/'".to_owned()); } if !(origin.starts_with("https://") || origin.starts_with("http://")) { return Err("--public-origin must be an HTTP(S) origin".to_owned()); } Ok(()) } fn default_app_data_dir() -> PathBuf { if let Some(path) = env::var_os("IDEA_APP_DATA_DIR") { return PathBuf::from(path); } if let Some(path) = env::var_os("XDG_DATA_HOME") { return PathBuf::from(path).join("IdeA"); } if let Some(home) = env::var_os("HOME") { return PathBuf::from(home).join(".local/share/IdeA"); } env::current_dir() .unwrap_or_else(|_| PathBuf::from(".")) .join(".ideai/app-data") } fn new_pairing_code() -> String { Uuid::new_v4() .simple() .to_string() .chars() .take(8) .collect::() .to_ascii_uppercase() } fn new_session_token() -> String { format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple()) } fn invalid_args_error(err: serde_json::Error) -> ErrorDto { ErrorDto { code: "INVALID".to_owned(), message: format!("invalid command args: {err}"), } } fn serialization_error(err: serde_json::Error) -> ErrorDto { ErrorDto { code: "SERIALIZATION".to_owned(), message: format!("failed to serialize response: {err}"), } } #[cfg(test)] mod tests { use super::*; use application::CreateProjectInput; use http::header::HeaderName; fn test_config() -> ServerConfig { ServerConfig { listen: "127.0.0.1:17373".parse().unwrap(), public_origin: None, allow_remote: false, trust_reverse_proxy: false, app_data_dir: std::env::temp_dir().join(format!("idea-server-test-{}", Uuid::new_v4())), } } fn state() -> Arc { Arc::new(ServerState::new_for_test(test_config(), "PAIR1234")) } async fn request( state: Arc, method: Method, uri: &str, body: Value, extra_headers: &[(&str, &str)], ) -> Response { let mut builder = Request::builder() .method(method) .uri(uri) .header(ORIGIN, "http://127.0.0.1:17373") .header(CONTENT_TYPE, "application/json"); for (name, value) in extra_headers { builder = builder.header(HeaderName::from_bytes(name.as_bytes()).unwrap(), *value); } handle_request( builder .body(Full::new(Bytes::from(serde_json::to_vec(&body).unwrap()))) .unwrap(), state, ) .await } async fn response_json(response: Response) -> (StatusCode, Value, HeaderMap) { let status = response.status(); let headers = response.headers().clone(); let body = response.into_body().collect().await.unwrap().to_bytes(); let value = if body.is_empty() { Value::Null } else { serde_json::from_slice(&body).unwrap() }; (status, value, headers) } async fn pair_and_cookie(state: Arc) -> String { let response = request( state, Method::POST, "/api/pair", json!({ "code": "PAIR1234" }), &[], ) .await; response .headers() .get(SET_COOKIE) .unwrap() .to_str() .unwrap() .split(';') .next() .unwrap() .to_owned() } async fn create_project_for_test(state: &Arc, name: &str) -> String { let root = std::env::temp_dir() .join(format!("idea-server-project-{}", Uuid::new_v4())) .to_string_lossy() .into_owned(); let output = state .app .create_project .execute(CreateProjectInput { name: name.to_owned(), root, remote: None, default_profile_id: None, }) .await .expect("test project is created"); output.project.id.to_string() } #[test] fn public_bind_requires_explicit_remote_security() { let config = ServerConfig { listen: "0.0.0.0:17373".parse().unwrap(), ..test_config() }; assert!(config.validate().is_err()); let config = ServerConfig { allow_remote: true, public_origin: Some("https://idea.example.com".to_owned()), trust_reverse_proxy: true, ..config }; assert!(config.validate().is_ok()); } #[test] fn allow_remote_requires_https_origin_and_reverse_proxy() { let base = ServerConfig { listen: "0.0.0.0:17373".parse().unwrap(), allow_remote: true, ..test_config() }; assert!(base.validate().is_err()); let with_http = ServerConfig { public_origin: Some("http://idea.example.com".to_owned()), trust_reverse_proxy: true, ..base.clone() }; assert!(with_http.validate().is_err()); let without_proxy = ServerConfig { public_origin: Some("https://idea.example.com".to_owned()), trust_reverse_proxy: false, ..base }; assert!(without_proxy.validate().is_err()); } #[tokio::test] async fn invoke_requires_valid_cookie() { let state = state(); let response = request( state, Method::POST, "/api/invoke", json!({ "command": "health", "args": {} }), &[], ) .await; let (status, body, _) = response_json(response).await; assert_eq!(status, StatusCode::UNAUTHORIZED); assert_eq!(body["code"], "UNAUTHORIZED"); } #[tokio::test] async fn invoke_rejects_invalid_session_cookie() { let state = state(); let response = request( state, Method::POST, "/api/invoke", json!({ "command": "health", "args": {} }), &[("cookie", "idea_session=not-a-valid-session")], ) .await; let (status, body, _) = response_json(response).await; assert_eq!(status, StatusCode::UNAUTHORIZED); assert_eq!(body["code"], "UNAUTHORIZED"); } #[tokio::test] async fn pairing_rejects_wrong_code() { let state = state(); let response = request( state, Method::POST, "/api/pair", json!({ "code": "WRONG999" }), &[], ) .await; let (status, body, headers) = response_json(response).await; assert_eq!(status, StatusCode::FORBIDDEN); assert_eq!(body["code"], "FORBIDDEN"); assert!( !headers.contains_key(SET_COOKIE), "wrong pairing code must not issue a session cookie" ); } #[tokio::test] async fn pairing_sets_http_only_strict_cookie_for_loopback_dev() { let state = state(); let response = request( state, Method::POST, "/api/pair", json!({ "code": "PAIR1234" }), &[], ) .await; let (status, body, headers) = response_json(response).await; assert_eq!(status, StatusCode::OK); assert_eq!(body, json!({ "paired": true })); let cookie = headers.get(SET_COOKIE).unwrap().to_str().unwrap(); assert!(cookie.contains("idea_session=")); assert!(cookie.contains("HttpOnly")); assert!(cookie.contains("SameSite=Strict")); assert!( !cookie.contains("Secure"), "loopback dev over HTTP deliberately avoids Secure so browsers store it" ); } #[tokio::test] async fn pairing_sets_secure_cookie_for_remote_https_origin() { let config = ServerConfig { listen: "0.0.0.0:17373".parse().unwrap(), public_origin: Some("https://idea.example.com".to_owned()), allow_remote: true, trust_reverse_proxy: true, ..test_config() }; let state = Arc::new(ServerState::new_for_test(config, "PAIR1234")); let mut req = Request::builder() .method(Method::POST) .uri("/api/pair") .header(ORIGIN, "https://idea.example.com") .header(CONTENT_TYPE, "application/json") .body(Full::new(Bytes::from_static(br#"{"code":"PAIR1234"}"#))) .unwrap(); *req.headers_mut().get_mut(CONTENT_TYPE).unwrap() = HeaderValue::from_static("application/json"); let response = handle_request(req, state).await; let (status, _, headers) = response_json(response).await; assert_eq!(status, StatusCode::OK); let cookie = headers.get(SET_COOKIE).unwrap().to_str().unwrap(); assert!(cookie.contains("Secure")); assert!(cookie.contains("HttpOnly")); assert!(cookie.contains("SameSite=Strict")); } #[tokio::test] async fn token_or_secret_in_query_string_is_refused() { let state = state(); let response = request( state, Method::POST, "/api/invoke?token=abc", json!({ "command": "health", "args": {} }), &[], ) .await; let (status, body, _) = response_json(response).await; assert_eq!(status, StatusCode::BAD_REQUEST); assert_eq!(body["code"], "INVALID"); } #[tokio::test] async fn non_allowed_origin_is_refused() { let state = state(); let mut req = Request::builder() .method(Method::POST) .uri("/api/pair") .header(ORIGIN, "https://evil.example") .header(CONTENT_TYPE, "application/json") .body(Full::new(Bytes::from_static(br#"{"code":"PAIR1234"}"#))) .unwrap(); *req.headers_mut().get_mut(CONTENT_TYPE).unwrap() = HeaderValue::from_static("application/json"); let response = handle_request(req, state).await; let (status, body, _) = response_json(response).await; assert_eq!(status, StatusCode::FORBIDDEN); assert_eq!(body["code"], "FORBIDDEN"); } #[tokio::test] async fn unknown_allowlisted_command_returns_unknown_command() { let state = state(); let cookie = pair_and_cookie(Arc::clone(&state)).await; let response = request( state, Method::POST, "/api/invoke", json!({ "command": "debug_dump", "args": {} }), &[("cookie", &cookie)], ) .await; let (status, body, _) = response_json(response).await; assert_eq!(status, StatusCode::BAD_REQUEST); assert_eq!(body["code"], "UNKNOWN_COMMAND"); } #[tokio::test] async fn authorized_health_invoke_returns_health_report() { let state = state(); let cookie = pair_and_cookie(Arc::clone(&state)).await; let response = request( state, Method::POST, "/api/invoke", json!({ "command": "health", "args": { "request": { "note": "hi" } } }), &[("cookie", &cookie)], ) .await; let (status, body, _) = response_json(response).await; assert_eq!(status, StatusCode::OK); assert_eq!(body["alive"], true); assert_eq!(body["note"], "hi"); } #[tokio::test] async fn authorized_list_projects_returns_tauri_project_list_contract() { let state = state(); let project_id = create_project_for_test(&state, "Web Read").await; let cookie = pair_and_cookie(Arc::clone(&state)).await; let response = request( state, Method::POST, "/api/invoke", json!({ "command": "list_projects", "args": {} }), &[("cookie", &cookie)], ) .await; let (status, body, _) = response_json(response).await; assert_eq!(status, StatusCode::OK); let projects = body .as_array() .expect("ProjectListDto is a transparent array"); let project = projects .iter() .find(|project| project["id"] == project_id) .expect("created project is listed"); assert_eq!(project["name"], "Web Read"); assert!(project["root"] .as_str() .is_some_and(|root| root.contains("idea-server-project-"))); } #[tokio::test] async fn authorized_open_project_returns_readonly_project_dto() { let state = state(); let project_id = create_project_for_test(&state, "Readonly Open").await; let cookie = pair_and_cookie(Arc::clone(&state)).await; let response = request( state, Method::POST, "/api/invoke", json!({ "command": "open_project", "args": { "projectId": project_id } }), &[("cookie", &cookie)], ) .await; let (status, body, _) = response_json(response).await; assert_eq!(status, StatusCode::OK); assert_eq!(body["id"], project_id); assert_eq!(body["name"], "Readonly Open"); assert!(body["root"].is_string()); } #[tokio::test] async fn authorized_get_project_work_state_returns_tauri_contract() { let state = state(); let project_id = create_project_for_test(&state, "Readonly Work").await; let cookie = pair_and_cookie(Arc::clone(&state)).await; let response = request( state, Method::POST, "/api/invoke", json!({ "command": "get_project_work_state", "args": { "projectId": project_id } }), &[("cookie", &cookie)], ) .await; let (status, body, _) = response_json(response).await; assert_eq!(status, StatusCode::OK); assert!(body["agents"].as_array().is_some()); assert!(body["conversations"].as_array().is_some()); } #[tokio::test] async fn mutation_and_pty_commands_stay_out_of_readonly_allowlist() { let state = state(); let cookie = pair_and_cookie(Arc::clone(&state)).await; for command in ["create_project", "open_terminal", "launch_agent"] { let response = request( Arc::clone(&state), Method::POST, "/api/invoke", json!({ "command": command, "args": {} }), &[("cookie", &cookie)], ) .await; let (status, body, _) = response_json(response).await; assert_eq!(status, StatusCode::BAD_REQUEST); assert_eq!( body["code"], "UNKNOWN_COMMAND", "{command} must stay blocked" ); } } #[tokio::test] async fn invalid_project_id_is_mapped_to_error_dto() { let state = state(); let cookie = pair_and_cookie(Arc::clone(&state)).await; let response = request( state, Method::POST, "/api/invoke", json!({ "command": "open_project", "args": { "projectId": "nope" } }), &[("cookie", &cookie)], ) .await; let (status, body, _) = response_json(response).await; assert_eq!(status, StatusCode::BAD_REQUEST); assert_eq!(body["code"], "INVALID"); } #[test] fn direct_project_id_parser_still_rejects_invalid_ids() { assert!(parse_project_id("not-a-uuid").is_err()); } }