From 4ed0b168d2bcfc35e26d8fd6e008417cd675c141 Mon Sep 17 00:00:00 2001 From: Blomios Date: Wed, 15 Jul 2026 13:06:40 +0200 Subject: [PATCH] =?UTF-8?q?feat(server):=20sous-commande=20idea=20--serve?= =?UTF-8?q?=20avec=20serveur=20HTTP=20s=C3=A9curis=C3=A9=20pairing+cookie?= =?UTF-8?q?=20(#13)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lot B3 du chantier server/client mode : socle serveur sécurisé exposant le cœur backend en mode client/serveur, sans PTY (reporté B5). - crates/app-tauri/src/server.rs : sous-commande --serve, serveur HTTP sécurisé — /api/invoke sur allowlist, /api/pair → cookie de session, Origin strict, refus du secret en query, gate de bind. 16 tests dont les 2 refus de sécurité (pairing_rejects_wrong_code, invoke_rejects_invalid_session_cookie). - crates/app-tauri/src/lib.rs : câblage de la sous-commande --serve. - crates/app-tauri/src/commands.rs : adaptations. - crates/app-tauri/Cargo.toml : deps bytes/cookie/http/http-body-util, feature tokio net. Cargo.lock synchronisé. Validé : cargo check --workspace vert, app-tauri 267 tests verts, cœur backend agnostique confirmé, desktop non régressé. Co-Authored-By: Claude Opus 4.8 --- Cargo.lock | 4 + crates/app-tauri/Cargo.toml | 10 +- crates/app-tauri/src/commands.rs | 11 + crates/app-tauri/src/lib.rs | 15 +- crates/app-tauri/src/server.rs | 1130 ++++++++++++++++++++++++++++++ 5 files changed, 1164 insertions(+), 6 deletions(-) create mode 100644 crates/app-tauri/src/server.rs diff --git a/Cargo.lock b/Cargo.lock index d99a624..68c30c7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -75,7 +75,11 @@ dependencies = [ "async-trait", "backend", "base64 0.22.1", + "bytes", + "cookie", "domain", + "http", + "http-body-util", "infrastructure", "interprocess", "serde", diff --git a/crates/app-tauri/Cargo.toml b/crates/app-tauri/Cargo.toml index adbe0f6..219c52e 100644 --- a/crates/app-tauri/Cargo.toml +++ b/crates/app-tauri/Cargo.toml @@ -26,14 +26,18 @@ infrastructure = { workspace = true } backend = { workspace = true } tauri = { workspace = true } tauri-plugin-dialog = { workspace = true } -# `io-std` (on top of the workspace features) gives the headless `mcp-server` -# bridge access to `tokio::io::{stdin,stdout}` without widening tokio elsewhere. -tokio = { workspace = true, features = ["io-std", "rt"] } +# `io-std` gives the headless `mcp-server` bridge access to +# `tokio::io::{stdin,stdout}`; `net` is used only by the `--serve` HTTP adapter. +tokio = { workspace = true, features = ["io-std", "rt", "net"] } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } uuid = { workspace = true } base64 = "0.22" +bytes = "1.11" +cookie = "0.18" +http = "1.4" +http-body-util = "0.1" # `AppAgentResumer` implements the application's async `AgentResumer` port (LS7). async-trait = { workspace = true } # Cross-OS local IPC for the MCP loopback transport (M5a): Unix domain socket diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index fbfa602..0672bcb 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -113,6 +113,17 @@ pub async fn create_project( pub async fn open_project( project_id: String, state: State<'_, AppState>, +) -> Result { + open_project_for_adapter(project_id, &state).await +} + +/// Shared `open_project` adapter body used by desktop IPC and the HTTP server. +/// +/// Keeps the non-presentation side effects attached to project opening in one +/// place while B3 adds a second driving adapter. +pub(crate) async fn open_project_for_adapter( + project_id: String, + state: &AppState, ) -> Result { let id = parse_project_id(&project_id)?; let output = state diff --git a/crates/app-tauri/src/lib.rs b/crates/app-tauri/src/lib.rs index 8829fa7..dd8728e 100644 --- a/crates/app-tauri/src/lib.rs +++ b/crates/app-tauri/src/lib.rs @@ -21,6 +21,7 @@ pub mod mcp_bridge; pub mod mcp_endpoint; pub mod openai_tools; pub mod pty; +pub mod server; pub mod state; pub mod stream; pub mod tickets; @@ -42,14 +43,17 @@ use state::AppState; /// The `argv[1]` subcommand token that switches the binary into the headless /// `mcp-server` **bridge** mode (cadrage v5 §1.3) instead of launching Tauri. pub const MCP_SERVER_SUBCOMMAND: &str = "mcp-server"; +/// The `argv[1]` subcommand token that starts the secure HTTP server adapter. +pub const SERVE_SUBCOMMAND: &str = "--serve"; /// Process entry point: routes `argv` **before** anything Tauri/WebKit is touched. /// /// When invoked as ` mcp-server …` (an MCP CLI spawned us from the injected /// `.mcp.json` declaration), we run the **stdio↔loopback bridge** headless and -/// **never** initialise the webview — see [`mcp_bridge::run_mcp_bridge`]. Any other -/// invocation is the normal IDE launch: [`run`] (which blocks until the window -/// closes and then exits the process itself). +/// **never** initialise the webview — see [`mcp_bridge::run_mcp_bridge`]. When +/// invoked as ` --serve …`, we run the secure HTTP adapter headless. Any +/// other invocation is the normal IDE launch: [`run`] (which blocks until the +/// window closes and then exits the process itself). /// /// Returns the [`ExitCode`] for the bridge path; the normal path does not return. #[must_use] @@ -61,6 +65,11 @@ pub fn dispatch() -> ExitCode { let rest: Vec = args.map(|a| a.to_string_lossy().into_owned()).collect(); return mcp_bridge::run_mcp_bridge(rest); } + let mut args = std::env::args_os().skip(1); + if args.next().is_some_and(|a| a == *SERVE_SUBCOMMAND) { + let rest: Vec = args.map(|a| a.to_string_lossy().into_owned()).collect(); + return server::run_from_args(rest); + } run(); ExitCode::SUCCESS diff --git a/crates/app-tauri/src/server.rs b/crates/app-tauri/src/server.rs new file mode 100644 index 0000000..b10e614 --- /dev/null +++ b/crates/app-tauri/src/server.rs @@ -0,0 +1,1130 @@ +//! 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 crate::commands::open_project_for_adapter; +use crate::dto::{ErrorDto, HealthRequestDto, HealthResponseDto, ProjectListDto}; +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, + _ => 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 output = open_project_for_adapter(project_id.to_owned(), state).await?; + serde_json::to_value(output).map_err(serialization_error) +} + +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 http::header::HeaderName; + + use crate::dto::parse_project_id; + + 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() + } + + #[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 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()); + } +}