From 917be995a48e4c0ef34c853f6b8e3cf7e4a96a7a Mon Sep 17 00:00:00 2001 From: Blomios Date: Wed, 15 Jul 2026 18:09:14 +0200 Subject: [PATCH 01/12] =?UTF-8?q?feat(server):=20endpoint=20PTY=20WebSocke?= =?UTF-8?q?t=20authentifi=C3=A9=20sur=20idea=20--serve=20(#13)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lot B5 du chantier server/client mode : le serveur --serve expose le terminal distant via un endpoint WebSocket, préalable au client xterm (F3). - Handshake WebSocket RFC 6455 fait main, sans nouvelle dépendance. - Auth de l'upgrade par cookie de session + Origin strict. - Frames PTY (open/attach/close/ping), réattache, scrollback, backpressure. - 37 tests server dont les refus de sécurité et les handlers open/attach/close/ping. Clippy propre (result_large_err corrigé). Validé : app-tauri 277 tests verts, backend 28, cohérence des frames B5↔F3 confirmée, desktop non régressé. Réserve connue : le round-trip socket réel relève d'une validation live hors sandbox. Co-Authored-By: Claude Opus 4.8 --- crates/app-tauri/src/server.rs | 1260 +++++++++++++++++++++++++++++++- 1 file changed, 1254 insertions(+), 6 deletions(-) diff --git a/crates/app-tauri/src/server.rs b/crates/app-tauri/src/server.rs index c9953cd..0e78821 100644 --- a/crates/app-tauri/src/server.rs +++ b/crates/app-tauri/src/server.rs @@ -9,8 +9,10 @@ use std::env; use std::net::SocketAddr; use std::path::PathBuf; use std::process::ExitCode; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; +use backend::stream::{OutputBridge, OutputSink, OutputSinkError}; use bytes::Bytes; use cookie::{Cookie, SameSite}; use http::header::{HeaderValue, CONTENT_TYPE, COOKIE, ORIGIN, SET_COOKIE}; @@ -18,23 +20,33 @@ use http::header::{HeaderValue, CONTENT_TYPE, COOKIE, ORIGIN, SET_COOKIE}; use http::Request; use http::{HeaderMap, Method, Response, StatusCode, Uri}; use http_body_util::{BodyExt, Full}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpListener; +use tokio::sync::mpsc; use uuid::Uuid; -use application::{GetProjectWorkStateInput, OpenProjectInput}; -use domain::Project; +use application::{ + CloseTerminalInput, GetProjectWorkStateInput, OpenProjectInput, ResizeTerminalInput, + WriteToTerminalInput, +}; +use domain::ports::PtyHandle; +use domain::{Project, SessionId}; use crate::dto::{ - parse_project_id, ErrorDto, HealthRequestDto, HealthResponseDto, ProjectDto, ProjectListDto, - ProjectWorkStateDto, + parse_project_id, parse_session_id, ErrorDto, HealthRequestDto, HealthResponseDto, + OpenTerminalRequestDto, ProjectDto, ProjectListDto, ProjectWorkStateDto, TerminalSessionDto, }; +use crate::pty::PtyChunk; use crate::state::AppState; const DEFAULT_LISTEN: &str = "127.0.0.1:17373"; const SESSION_COOKIE: &str = "idea_session"; +const WS_PATH: &str = "/api/ws"; +const WS_MAGIC: &str = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; +const WS_MAX_PAYLOAD: usize = 64 * 1024; +const WS_OUTPUT_BUFFER: usize = 512; type ResponseBody = Full; @@ -176,6 +188,7 @@ struct ServerState { app: AppState, pairing_code: String, sessions: Mutex>, + ws_pty_bridge: Arc>, } impl ServerState { @@ -185,6 +198,7 @@ impl ServerState { config, pairing_code: new_pairing_code(), sessions: Mutex::new(HashSet::new()), + ws_pty_bridge: Arc::new(OutputBridge::new()), } } @@ -195,6 +209,7 @@ impl ServerState { config, pairing_code: pairing_code.into(), sessions: Mutex::new(HashSet::new()), + ws_pty_bridge: Arc::new(OutputBridge::new()), } } @@ -261,6 +276,10 @@ async fn handle_tcp_connection( }; let (method, uri, headers, content_length) = parse_http_request_head(&buffer[..header_end])?; + if method == Method::GET && uri.path() == WS_PATH { + return handle_ws_upgrade(stream, headers, state).await; + } + let body_start = header_end + 4; while buffer.len() < body_start + content_length { let read = stream @@ -395,6 +414,373 @@ async fn write_http_response( .map_err(|err| format!("failed to write response: {err}")) } +async fn handle_ws_upgrade( + mut stream: tokio::net::TcpStream, + headers: HeaderMap, + state: Arc, +) -> Result<(), String> { + let accept = match validate_ws_upgrade(&headers, &state) { + Ok(accept) => accept, + Err(response) => return write_http_response(&mut stream, *response).await, + }; + + let response = format!( + "HTTP/1.1 101 Switching Protocols\r\n\ + upgrade: websocket\r\n\ + connection: Upgrade\r\n\ + sec-websocket-accept: {accept}\r\n\r\n" + ); + stream + .write_all(response.as_bytes()) + .await + .map_err(|err| format!("failed to write websocket upgrade: {err}"))?; + run_ws_connection(stream, state).await +} + +fn validate_ws_upgrade( + headers: &HeaderMap, + state: &ServerState, +) -> Result>> { + let origin = validate_request_origin(headers, &state.config)?; + let Some(token) = session_cookie(headers) else { + return Err(Box::new(error_response( + StatusCode::UNAUTHORIZED, + "UNAUTHORIZED", + "missing session cookie", + origin.as_deref(), + ))); + }; + if !state.has_session(&token) { + return Err(Box::new(error_response( + StatusCode::UNAUTHORIZED, + "UNAUTHORIZED", + "invalid session cookie", + origin.as_deref(), + ))); + } + if !header_contains_token(headers, "upgrade", "websocket") + || !header_contains_token(headers, "connection", "upgrade") + { + return Err(Box::new(error_response( + StatusCode::BAD_REQUEST, + "INVALID", + "missing websocket upgrade headers", + origin.as_deref(), + ))); + } + let Some(key) = headers + .get("sec-websocket-key") + .and_then(|value| value.to_str().ok()) + else { + return Err(Box::new(error_response( + StatusCode::BAD_REQUEST, + "INVALID", + "missing Sec-WebSocket-Key", + origin.as_deref(), + ))); + }; + Ok(websocket_accept(key)) +} + +fn header_contains_token(headers: &HeaderMap, name: &str, needle: &str) -> bool { + headers + .get(name) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| { + value + .split(',') + .any(|token| token.trim().eq_ignore_ascii_case(needle)) + }) +} + +async fn run_ws_connection( + stream: tokio::net::TcpStream, + state: Arc, +) -> Result<(), String> { + let (mut reader, mut writer) = stream.into_split(); + let (tx, mut rx) = mpsc::channel::(WS_OUTPUT_BUFFER); + let owned = Arc::new(Mutex::new(Vec::<(SessionId, u64)>::new())); + let writer_task = tokio::spawn(async move { + while let Some(frame) = rx.recv().await { + let text = serde_json::to_vec(&frame) + .map_err(|err| format!("failed to encode server frame: {err}"))?; + let bytes = encode_ws_frame(WsOpcode::Text, &text); + writer + .write_all(&bytes) + .await + .map_err(|err| format!("failed to write websocket frame: {err}"))?; + } + Ok::<(), String>(()) + }); + + loop { + let frame = match read_ws_frame(&mut reader).await { + Ok(frame) => frame, + Err(err) => { + let _ = tx + .send(ServerFrame::error( + None, + None, + "WS_PROTOCOL", + err.to_string(), + )) + .await; + break; + } + }; + match frame.opcode { + WsOpcode::Text => { + let parsed = serde_json::from_slice::(&frame.payload) + .map_err(|err| format!("invalid client frame JSON: {err}")); + match parsed { + Ok(frame) => { + handle_client_frame(frame, &state, &tx, &owned).await; + } + Err(err) => { + let _ = tx + .send(ServerFrame::error(None, None, "INVALID", err)) + .await; + } + } + } + WsOpcode::Ping => { + let _ = tx.send(ServerFrame::pong()).await; + } + WsOpcode::Close => break, + WsOpcode::Pong => {} + WsOpcode::Binary => { + let _ = tx + .send(ServerFrame::error( + None, + None, + "UNSUPPORTED", + "binary websocket frames are not supported", + )) + .await; + } + } + } + + if let Ok(owned) = owned.lock() { + for (session, gen) in owned.iter() { + state.ws_pty_bridge.unregister_if(session, *gen); + } + } + drop(tx); + writer_task + .await + .map_err(|err| format!("websocket writer task failed: {err}"))? +} + +async fn handle_client_frame( + frame: ClientFrame, + state: &Arc, + tx: &mpsc::Sender, + owned: &Arc>>, +) { + let result = match frame.kind.as_str() { + "terminal.open" | "open_terminal" => ws_open_terminal(&frame, state, tx, owned).await, + "terminal.attach" | "attach_terminal" => ws_attach_terminal(&frame, state, tx, owned).await, + "terminal.input" | "input" => ws_input(&frame, state), + "terminal.resize" | "resize" => ws_resize(&frame, state), + "terminal.detach" | "detach" => ws_detach(&frame, state, owned), + "terminal.close" | "close" => match ws_close(&frame, state, owned).await { + Ok((session_id, exit_code)) => { + let _ = tx + .send(ServerFrame::status(session_id, "exited", exit_code)) + .await; + Ok(()) + } + Err(err) => Err(err), + }, + "ping" => { + let _ = tx.send(ServerFrame::pong()).await; + Ok(()) + } + _ => Err(ErrorDto { + code: "UNKNOWN_FRAME".to_owned(), + message: format!("unknown websocket frame kind: {}", frame.kind), + }), + }; + + if let Err(err) = result { + let _ = tx + .send(ServerFrame::error( + Some(frame.id), + payload_session_id(&frame.payload), + err.code, + err.message, + )) + .await; + } +} + +async fn ws_open_terminal( + frame: &ClientFrame, + state: &Arc, + tx: &mpsc::Sender, + owned: &Arc>>, +) -> Result<(), ErrorDto> { + let request_value = frame + .payload + .get("request") + .cloned() + .unwrap_or_else(|| frame.payload.clone()); + let request: OpenTerminalRequestDto = + serde_json::from_value(request_value).map_err(invalid_args_error)?; + let output = state + .app + .open_terminal + .execute(request.into()) + .await + .map_err(ErrorDto::from)?; + let dto = TerminalSessionDto::from(output); + let sid = parse_session_id(&dto.session_id)?; + let sink = WsPtySink::new(sid, tx.clone(), 0); + tx.send(ServerFrame::attached(&frame.id, &dto, Vec::new(), 0, false)) + .await + .map_err(|_| ErrorDto { + code: "WS_CLOSED".to_owned(), + message: "websocket output closed".to_owned(), + })?; + attach_sink_and_pump(state, sid, sink, owned) +} + +async fn ws_attach_terminal( + frame: &ClientFrame, + state: &Arc, + tx: &mpsc::Sender, + owned: &Arc>>, +) -> Result<(), ErrorDto> { + let session_id = payload_string(&frame.payload, "sessionId")?; + let sid = parse_session_id(&session_id)?; + let handle = PtyHandle { session_id: sid }; + let scrollback = state + .app + .pty_port + .scrollback(&handle) + .map_err(|err| ErrorDto::from(application::AppError::from(err)))?; + let rows = payload_u16(&frame.payload, "rows").unwrap_or(24); + let cols = payload_u16(&frame.payload, "cols").unwrap_or(80); + let dto = TerminalSessionDto { + session_id, + cwd: String::new(), + rows, + cols, + assigned_conversation_id: None, + engine_session_id: None, + cell_kind: crate::dto::CellKind::Pty, + }; + let next_seq = u64::from(!scrollback.is_empty()); + tx.send(ServerFrame::attached( + &frame.id, + &dto, + scrollback.clone(), + next_seq, + frame.payload.get("lastSeq").is_some(), + )) + .await + .map_err(|_| ErrorDto { + code: "WS_CLOSED".to_owned(), + message: "websocket output closed".to_owned(), + })?; + let sink = WsPtySink::new(sid, tx.clone(), next_seq); + attach_sink_and_pump(state, sid, sink, owned) +} + +fn attach_sink_and_pump( + state: &Arc, + sid: SessionId, + sink: WsPtySink, + owned: &Arc>>, +) -> Result<(), ErrorDto> { + let gen = state.ws_pty_bridge.register(sid, Arc::new(sink)); + if let Ok(mut owned) = owned.lock() { + owned.push((sid, gen)); + } + let handle = PtyHandle { session_id: sid }; + let stream = state + .app + .pty_port + .subscribe_output(&handle) + .map_err(|err| ErrorDto::from(application::AppError::from(err)))?; + let bridge = Arc::clone(&state.ws_pty_bridge); + std::thread::spawn(move || { + for chunk in stream { + if !bridge.send_output(&sid, chunk) { + break; + } + } + bridge.unregister_if(&sid, gen); + }); + Ok(()) +} + +fn ws_input(frame: &ClientFrame, state: &Arc) -> Result<(), ErrorDto> { + let sid = parse_session_id(&payload_string(&frame.payload, "sessionId")?)?; + let bytes = payload_bytes(&frame.payload)?; + state + .app + .write_terminal + .execute(WriteToTerminalInput { + session_id: sid, + data: bytes, + }) + .map_err(ErrorDto::from) +} + +fn ws_resize(frame: &ClientFrame, state: &Arc) -> Result<(), ErrorDto> { + let sid = parse_session_id(&payload_string(&frame.payload, "sessionId")?)?; + let rows = payload_u16(&frame.payload, "rows").ok_or_else(|| ErrorDto { + code: "INVALID".to_owned(), + message: "resize requires rows".to_owned(), + })?; + let cols = payload_u16(&frame.payload, "cols").ok_or_else(|| ErrorDto { + code: "INVALID".to_owned(), + message: "resize requires cols".to_owned(), + })?; + state + .app + .resize_terminal + .execute(ResizeTerminalInput { + session_id: sid, + rows, + cols, + }) + .map_err(ErrorDto::from) +} + +fn ws_detach( + frame: &ClientFrame, + state: &Arc, + owned: &Arc>>, +) -> Result<(), ErrorDto> { + let sid = parse_session_id(&payload_string(&frame.payload, "sessionId")?)?; + if let Ok(mut owned) = owned.lock() { + if let Some(index) = owned.iter().rposition(|(session, _)| *session == sid) { + let (_, gen) = owned.remove(index); + state.ws_pty_bridge.unregister_if(&sid, gen); + } + } + Ok(()) +} + +async fn ws_close( + frame: &ClientFrame, + state: &Arc, + owned: &Arc>>, +) -> Result<(SessionId, Option), ErrorDto> { + let sid = parse_session_id(&payload_string(&frame.payload, "sessionId")?)?; + ws_detach(frame, state, owned)?; + let output = state + .app + .close_terminal + .execute(CloseTerminalInput { session_id: sid }) + .await + .map_err(ErrorDto::from)?; + Ok((sid, output.code)) +} + async fn dispatch_http( method: Method, uri: Uri, @@ -825,11 +1211,447 @@ fn serialization_error(err: serde_json::Error) -> ErrorDto { } } +#[derive(Debug, Clone, Deserialize, Serialize)] +struct ClientFrame { + id: String, + kind: String, + #[serde(default)] + payload: Value, +} + +#[derive(Debug, Clone, Serialize)] +struct ServerFrame { + kind: String, + #[serde(skip_serializing_if = "Option::is_none", rename = "replyTo")] + reply_to: Option, + payload: Value, +} + +impl ServerFrame { + fn attached( + request_id: &str, + session: &TerminalSessionDto, + scrollback: Vec, + next_seq: u64, + gap: bool, + ) -> Self { + let scrollback = if scrollback.is_empty() { + Vec::new() + } else { + vec![json!({ + "seq": 0, + "bytesBase64": base64_encode(&scrollback), + })] + }; + Self { + kind: "terminal.attached".to_owned(), + reply_to: Some(request_id.to_owned()), + payload: json!({ + "session": { + "sessionId": session.session_id, + "rows": session.rows, + "cols": session.cols, + }, + "scrollback": scrollback, + "nextSeq": next_seq, + "status": "running", + "gap": gap, + "assignedConversationId": session.assigned_conversation_id, + }), + } + } + + fn output(session_id: SessionId, seq: u64, bytes: Vec) -> Self { + Self { + kind: "terminal.output".to_owned(), + reply_to: None, + payload: json!({ + "sessionId": session_id.to_string(), + "seq": seq, + "bytesBase64": base64_encode(&bytes), + }), + } + } + + fn status(session_id: SessionId, status: &str, exit_code: Option) -> Self { + Self { + kind: "terminal.status".to_owned(), + reply_to: None, + payload: json!({ + "sessionId": session_id.to_string(), + "status": status, + "exitCode": exit_code, + }), + } + } + + fn error( + request_id: Option, + session_id: Option, + code: impl Into, + message: impl Into, + ) -> Self { + Self { + kind: "error".to_owned(), + reply_to: request_id, + payload: json!({ + "sessionId": session_id, + "code": code.into(), + "message": message.into(), + }), + } + } + + fn pong() -> Self { + Self { + kind: "pong".to_owned(), + reply_to: None, + payload: json!({}), + } + } +} + +struct WsPtySink { + session_id: SessionId, + tx: mpsc::Sender, + seq: AtomicU64, +} + +impl WsPtySink { + fn new(session_id: SessionId, tx: mpsc::Sender, next_seq: u64) -> Self { + Self { + session_id, + tx, + seq: AtomicU64::new(next_seq), + } + } +} + +impl OutputSink for WsPtySink { + fn send(&self, item: PtyChunk) -> Result<(), OutputSinkError> { + let seq = self.seq.fetch_add(1, Ordering::Relaxed); + self.tx + .try_send(ServerFrame::output(self.session_id, seq, item)) + .map_err(|err| match err { + mpsc::error::TrySendError::Full(_) => OutputSinkError::Full, + mpsc::error::TrySendError::Closed(_) => OutputSinkError::Closed, + }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum WsOpcode { + Text, + Binary, + Close, + Ping, + Pong, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct WsFrame { + opcode: WsOpcode, + payload: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum WsFrameError { + Incomplete, + Protocol(String), + TooLarge, +} + +impl std::fmt::Display for WsFrameError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Incomplete => f.write_str("incomplete websocket frame"), + Self::Protocol(message) => write!(f, "websocket protocol error: {message}"), + Self::TooLarge => f.write_str("websocket frame payload too large"), + } + } +} + +async fn read_ws_frame(reader: &mut R) -> Result +where + R: AsyncRead + Unpin, +{ + let mut header = [0_u8; 2]; + reader + .read_exact(&mut header) + .await + .map_err(|_| WsFrameError::Incomplete)?; + let len_code = header[1] & 0x7f; + let masked = header[1] & 0x80 != 0; + let mut rest = Vec::new(); + let extra_len = match len_code { + 126 => 2, + 127 => 8, + _ => 0, + }; + rest.resize(extra_len + usize::from(masked) * 4, 0); + if !rest.is_empty() { + reader + .read_exact(&mut rest) + .await + .map_err(|_| WsFrameError::Incomplete)?; + } + let mut raw = Vec::with_capacity(2 + rest.len()); + raw.extend_from_slice(&header); + raw.extend_from_slice(&rest); + let payload_len = decoded_payload_len(&raw)?; + if payload_len > WS_MAX_PAYLOAD { + return Err(WsFrameError::TooLarge); + } + let mut payload = vec![0_u8; payload_len]; + reader + .read_exact(&mut payload) + .await + .map_err(|_| WsFrameError::Incomplete)?; + raw.extend_from_slice(&payload); + decode_client_ws_frame(&raw) +} + +fn decode_client_ws_frame(raw: &[u8]) -> Result { + if raw.len() < 2 { + return Err(WsFrameError::Incomplete); + } + let fin = raw[0] & 0x80 != 0; + if !fin { + return Err(WsFrameError::Protocol( + "fragmented frames are not supported".to_owned(), + )); + } + let opcode = match raw[0] & 0x0f { + 0x1 => WsOpcode::Text, + 0x2 => WsOpcode::Binary, + 0x8 => WsOpcode::Close, + 0x9 => WsOpcode::Ping, + 0xA => WsOpcode::Pong, + other => { + return Err(WsFrameError::Protocol(format!( + "unsupported opcode {other}" + ))) + } + }; + let masked = raw[1] & 0x80 != 0; + if !masked { + return Err(WsFrameError::Protocol( + "client frames must be masked".to_owned(), + )); + } + let len_code = raw[1] & 0x7f; + let mut offset = 2; + let len = match len_code { + 126 => { + if raw.len() < offset + 2 { + return Err(WsFrameError::Incomplete); + } + let len = u16::from_be_bytes([raw[offset], raw[offset + 1]]) as usize; + offset += 2; + len + } + 127 => { + if raw.len() < offset + 8 { + return Err(WsFrameError::Incomplete); + } + let len = u64::from_be_bytes(raw[offset..offset + 8].try_into().expect("8 bytes")); + offset += 8; + usize::try_from(len).map_err(|_| WsFrameError::TooLarge)? + } + len => usize::from(len), + }; + if len > WS_MAX_PAYLOAD { + return Err(WsFrameError::TooLarge); + } + if raw.len() < offset + 4 + len { + return Err(WsFrameError::Incomplete); + } + let mask = &raw[offset..offset + 4]; + offset += 4; + let mut payload = raw[offset..offset + len].to_vec(); + for (i, byte) in payload.iter_mut().enumerate() { + *byte ^= mask[i % 4]; + } + Ok(WsFrame { opcode, payload }) +} + +fn decoded_payload_len(raw_header: &[u8]) -> Result { + let len_code = raw_header[1] & 0x7f; + let offset = 2; + let len = match len_code { + 126 => { + if raw_header.len() < offset + 2 { + return Err(WsFrameError::Incomplete); + } + u16::from_be_bytes([raw_header[offset], raw_header[offset + 1]]) as usize + } + 127 => { + if raw_header.len() < offset + 8 { + return Err(WsFrameError::Incomplete); + } + let len = + u64::from_be_bytes(raw_header[offset..offset + 8].try_into().expect("8 bytes")); + usize::try_from(len).map_err(|_| WsFrameError::TooLarge)? + } + len => usize::from(len), + }; + Ok(len) +} + +fn encode_ws_frame(opcode: WsOpcode, payload: &[u8]) -> Vec { + let opcode = match opcode { + WsOpcode::Text => 0x1, + WsOpcode::Binary => 0x2, + WsOpcode::Close => 0x8, + WsOpcode::Ping => 0x9, + WsOpcode::Pong => 0xA, + }; + let mut out = vec![0x80 | opcode]; + match payload.len() { + 0..=125 => out.push(payload.len() as u8), + 126..=65535 => { + out.push(126); + out.extend_from_slice(&(payload.len() as u16).to_be_bytes()); + } + len => { + out.push(127); + out.extend_from_slice(&(len as u64).to_be_bytes()); + } + } + out.extend_from_slice(payload); + out +} + +fn websocket_accept(key: &str) -> String { + let mut input = Vec::with_capacity(key.len() + WS_MAGIC.len()); + input.extend_from_slice(key.as_bytes()); + input.extend_from_slice(WS_MAGIC.as_bytes()); + base64_encode(&sha1_digest(&input)) +} + +fn base64_encode(bytes: &[u8]) -> String { + use base64::Engine as _; + base64::engine::general_purpose::STANDARD.encode(bytes) +} + +fn base64_decode(raw: &str) -> Result, ErrorDto> { + use base64::Engine as _; + base64::engine::general_purpose::STANDARD + .decode(raw) + .map_err(|err| ErrorDto { + code: "INVALID".to_owned(), + message: format!("invalid base64 bytes: {err}"), + }) +} + +fn sha1_digest(input: &[u8]) -> [u8; 20] { + let mut h0: u32 = 0x67452301; + let mut h1: u32 = 0xEFCDAB89; + let mut h2: u32 = 0x98BADCFE; + let mut h3: u32 = 0x10325476; + let mut h4: u32 = 0xC3D2E1F0; + + let bit_len = (input.len() as u64) * 8; + let mut msg = input.to_vec(); + msg.push(0x80); + while (msg.len() % 64) != 56 { + msg.push(0); + } + msg.extend_from_slice(&bit_len.to_be_bytes()); + + for chunk in msg.chunks(64) { + let mut w = [0_u32; 80]; + for (i, word) in w.iter_mut().take(16).enumerate() { + let j = i * 4; + *word = u32::from_be_bytes([chunk[j], chunk[j + 1], chunk[j + 2], chunk[j + 3]]); + } + for i in 16..80 { + w[i] = (w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16]).rotate_left(1); + } + let mut a = h0; + let mut b = h1; + let mut c = h2; + let mut d = h3; + let mut e = h4; + for (i, word) in w.iter().enumerate() { + let (f, k) = match i { + 0..=19 => ((b & c) | ((!b) & d), 0x5A827999), + 20..=39 => (b ^ c ^ d, 0x6ED9EBA1), + 40..=59 => ((b & c) | (b & d) | (c & d), 0x8F1BBCDC), + _ => (b ^ c ^ d, 0xCA62C1D6), + }; + let temp = a + .rotate_left(5) + .wrapping_add(f) + .wrapping_add(e) + .wrapping_add(k) + .wrapping_add(*word); + e = d; + d = c; + c = b.rotate_left(30); + b = a; + a = temp; + } + h0 = h0.wrapping_add(a); + h1 = h1.wrapping_add(b); + h2 = h2.wrapping_add(c); + h3 = h3.wrapping_add(d); + h4 = h4.wrapping_add(e); + } + + let mut out = [0_u8; 20]; + out[0..4].copy_from_slice(&h0.to_be_bytes()); + out[4..8].copy_from_slice(&h1.to_be_bytes()); + out[8..12].copy_from_slice(&h2.to_be_bytes()); + out[12..16].copy_from_slice(&h3.to_be_bytes()); + out[16..20].copy_from_slice(&h4.to_be_bytes()); + out +} + +fn payload_string(payload: &Value, key: &str) -> Result { + payload + .get(key) + .and_then(Value::as_str) + .map(ToOwned::to_owned) + .ok_or_else(|| ErrorDto { + code: "INVALID".to_owned(), + message: format!("payload requires {key}"), + }) +} + +fn payload_u16(payload: &Value, key: &str) -> Option { + payload + .get(key) + .and_then(Value::as_u64) + .and_then(|value| u16::try_from(value).ok()) +} + +fn payload_bytes(payload: &Value) -> Result, ErrorDto> { + if let Some(raw) = payload.get("bytesBase64").and_then(Value::as_str) { + base64_decode(raw) + } else if let Some(raw) = payload.get("bytes").and_then(Value::as_str) { + base64_decode(raw) + } else { + Err(ErrorDto { + code: "INVALID".to_owned(), + message: "payload requires bytesBase64".to_owned(), + }) + } +} + +fn payload_session_id(payload: &Value) -> Option { + payload + .get("sessionId") + .and_then(Value::as_str) + .map(ToOwned::to_owned) +} + #[cfg(test)] mod tests { use super::*; use application::CreateProjectInput; use http::header::HeaderName; + use std::time::Duration; fn test_config() -> ServerConfig { ServerConfig { @@ -921,6 +1743,432 @@ mod tests { output.project.id.to_string() } + fn ws_headers(origin: &str, cookie: Option<&str>) -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert(ORIGIN, HeaderValue::from_str(origin).unwrap()); + headers.insert("upgrade", HeaderValue::from_static("websocket")); + headers.insert("connection", HeaderValue::from_static("Upgrade")); + headers.insert( + "sec-websocket-key", + HeaderValue::from_static("dGhlIHNhbXBsZSBub25jZQ=="), + ); + if let Some(cookie) = cookie { + headers.insert(COOKIE, HeaderValue::from_str(cookie).unwrap()); + } + headers + } + + fn masked_text_frame(text: &str) -> Vec { + let payload = text.as_bytes(); + let mask = [1_u8, 2, 3, 4]; + let mut out = vec![0x81]; + out.push(0x80 | payload.len() as u8); + out.extend_from_slice(&mask); + for (i, byte) in payload.iter().enumerate() { + out.push(byte ^ mask[i % 4]); + } + out + } + + async fn recv_server_frame(rx: &mut mpsc::Receiver) -> ServerFrame { + tokio::time::timeout(Duration::from_secs(2), rx.recv()) + .await + .expect("server frame received before timeout") + .expect("server frame channel is open") + } + + fn drain_server_frames(rx: &mut mpsc::Receiver) { + while rx.try_recv().is_ok() {} + } + + fn ws_frame(id: &str, kind: &str, payload: Value) -> ClientFrame { + ClientFrame { + id: id.to_owned(), + kind: kind.to_owned(), + payload, + } + } + + fn attached_session_id(frame: &ServerFrame) -> String { + assert_eq!(frame.kind, "terminal.attached"); + frame.payload["session"]["sessionId"] + .as_str() + .expect("attached frame carries sessionId") + .to_owned() + } + + fn open_terminal_payload(command: &str, args: Vec<&str>) -> Value { + json!({ + "request": { + "cwd": std::env::temp_dir().to_string_lossy(), + "rows": 24, + "cols": 80, + "command": command, + "args": args, + } + }) + } + + async fn close_ws_session(state: &Arc, session_id: &str) { + let (tx, mut rx) = mpsc::channel(8); + let owned = Arc::new(Mutex::new(Vec::new())); + handle_client_frame( + ws_frame( + "close-cleanup", + "terminal.close", + json!({ "sessionId": session_id }), + ), + state, + &tx, + &owned, + ) + .await; + let _ = recv_server_frame(&mut rx).await; + } + + async fn wait_for_scrollback( + state: &Arc, + session_id: &str, + needle: &[u8], + ) -> Vec { + let sid = parse_session_id(session_id).expect("test session id parses"); + let handle = PtyHandle { session_id: sid }; + for _ in 0..100 { + if let Ok(scrollback) = state.app.pty_port.scrollback(&handle) { + if scrollback + .windows(needle.len()) + .any(|window| window == needle) + { + return scrollback; + } + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + panic!( + "scrollback did not contain {:?}", + String::from_utf8_lossy(needle) + ); + } + + #[test] + fn websocket_accept_matches_rfc_example() { + assert_eq!( + websocket_accept("dGhlIHNhbXBsZSBub25jZQ=="), + "s3pPLMBiTxaQ9kYGzzhZRbK+xOo=" + ); + } + + #[test] + fn websocket_upgrade_requires_valid_cookie() { + let state = state(); + let headers = ws_headers("http://127.0.0.1:17373", None); + + let response = validate_ws_upgrade(&headers, &state).unwrap_err(); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + #[test] + fn websocket_upgrade_rejects_invalid_cookie() { + let state = state(); + let headers = ws_headers( + "http://127.0.0.1:17373", + Some("idea_session=not-a-valid-session"), + ); + + let response = validate_ws_upgrade(&headers, &state).unwrap_err(); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + #[test] + fn websocket_upgrade_requires_allowed_origin() { + let state = state(); + let token = state.create_session(); + let headers = ws_headers( + "https://evil.example", + Some(&format!("idea_session={token}")), + ); + + let response = validate_ws_upgrade(&headers, &state).unwrap_err(); + + assert_eq!(response.status(), StatusCode::FORBIDDEN); + } + + #[test] + fn websocket_upgrade_accepts_valid_cookie_and_origin() { + let state = state(); + let token = state.create_session(); + let headers = ws_headers( + "http://127.0.0.1:17373", + Some(&format!("idea_session={token}")), + ); + + let accept = validate_ws_upgrade(&headers, &state).unwrap(); + + assert_eq!(accept, "s3pPLMBiTxaQ9kYGzzhZRbK+xOo="); + } + + #[test] + fn websocket_client_text_frame_decodes_masked_json() { + let raw = masked_text_frame(r#"{"kind":"ping"}"#); + + let frame = decode_client_ws_frame(&raw).unwrap(); + + assert_eq!(frame.opcode, WsOpcode::Text); + assert_eq!(frame.payload, br#"{"kind":"ping"}"#); + } + + #[test] + fn websocket_client_json_frame_round_trips_base64_bytes() { + let sid = domain::SessionId::new_random().to_string(); + let value = json!({ + "id": "req-1", + "kind": "input", + "payload": { + "sessionId": sid, + "bytesBase64": "AQID" + } + }); + + let frame: ClientFrame = serde_json::from_value(value.clone()).unwrap(); + let roundtrip = serde_json::to_value(&frame).unwrap(); + + assert_eq!(roundtrip, value); + assert_eq!(payload_bytes(&frame.payload).unwrap(), vec![1, 2, 3]); + } + + #[test] + fn websocket_server_attached_frame_uses_contract_shape() { + let sid = domain::SessionId::new_random().to_string(); + let frame = ServerFrame::attached( + "req-attach", + &TerminalSessionDto { + session_id: sid.clone(), + cwd: "/tmp".to_owned(), + rows: 24, + cols: 80, + assigned_conversation_id: None, + engine_session_id: None, + cell_kind: crate::dto::CellKind::Pty, + }, + vec![4, 5, 6], + 1, + true, + ); + let value = serde_json::to_value(frame).unwrap(); + + assert_eq!(value["kind"], "terminal.attached"); + assert_eq!(value["replyTo"], "req-attach"); + assert_eq!(value["payload"]["session"]["sessionId"], sid); + assert_eq!(value["payload"]["scrollback"][0]["seq"], 0); + assert_eq!(value["payload"]["scrollback"][0]["bytesBase64"], "BAUG"); + assert_eq!(value["payload"]["nextSeq"], 1); + assert_eq!(value["payload"]["gap"], true); + } + + #[tokio::test] + async fn websocket_app_ping_emits_pong() { + let state = state(); + let (tx, mut rx) = mpsc::channel(4); + let owned = Arc::new(Mutex::new(Vec::new())); + + handle_client_frame(ws_frame("ping-1", "ping", json!({})), &state, &tx, &owned).await; + let frame = recv_server_frame(&mut rx).await; + + assert_eq!(frame.kind, "pong"); + assert!(frame.payload.as_object().unwrap().is_empty()); + } + + #[tokio::test] + async fn websocket_open_terminal_emits_attached_with_empty_scrollback() { + let state = state(); + let (tx, mut rx) = mpsc::channel(16); + let owned = Arc::new(Mutex::new(Vec::new())); + + handle_client_frame( + ws_frame( + "open-1", + "terminal.open", + open_terminal_payload("/bin/sh", vec!["-c", "sleep 30"]), + ), + &state, + &tx, + &owned, + ) + .await; + let frame = recv_server_frame(&mut rx).await; + let session_id = attached_session_id(&frame); + + assert_eq!(frame.reply_to.as_deref(), Some("open-1")); + assert_eq!(frame.payload["scrollback"].as_array().unwrap().len(), 0); + assert_eq!(frame.payload["nextSeq"], 0); + + close_ws_session(&state, &session_id).await; + } + + #[tokio::test] + async fn websocket_attach_terminal_replays_scrollback_in_attached_ack() { + let state = state(); + let (tx, mut rx) = mpsc::channel(32); + let owned = Arc::new(Mutex::new(Vec::new())); + + handle_client_frame( + ws_frame( + "open-replay", + "terminal.open", + open_terminal_payload("/bin/sh", vec!["-c", "printf replay-ready; sleep 30"]), + ), + &state, + &tx, + &owned, + ) + .await; + let opened = recv_server_frame(&mut rx).await; + let session_id = attached_session_id(&opened); + + let scrollback = wait_for_scrollback(&state, &session_id, b"replay-ready").await; + assert!(scrollback.len() <= 100 * 1024); + drain_server_frames(&mut rx); + + handle_client_frame( + ws_frame( + "attach-replay", + "terminal.attach", + json!({ + "sessionId": session_id, + "lastSeq": 0, + "rows": 30, + "cols": 100, + }), + ), + &state, + &tx, + &owned, + ) + .await; + let attached = recv_server_frame(&mut rx).await; + let replay = attached.payload["scrollback"].as_array().unwrap(); + + assert_eq!(attached.kind, "terminal.attached"); + assert_eq!(attached.reply_to.as_deref(), Some("attach-replay")); + assert_eq!(attached.payload["session"]["rows"], 30); + assert_eq!(attached.payload["session"]["cols"], 100); + assert_eq!(attached.payload["gap"], true); + assert_eq!(attached.payload["nextSeq"], 1); + assert_eq!( + base64_decode(replay[0]["bytesBase64"].as_str().unwrap()).unwrap(), + scrollback + ); + + close_ws_session( + &state, + attached.payload["session"]["sessionId"].as_str().unwrap(), + ) + .await; + } + + #[tokio::test] + async fn websocket_close_terminal_emits_exited_status_and_releases_session() { + let state = state(); + let (tx, mut rx) = mpsc::channel(16); + let owned = Arc::new(Mutex::new(Vec::new())); + + handle_client_frame( + ws_frame( + "open-close", + "terminal.open", + open_terminal_payload("/bin/sh", vec!["-c", "sleep 30"]), + ), + &state, + &tx, + &owned, + ) + .await; + let opened = recv_server_frame(&mut rx).await; + let session_id = attached_session_id(&opened); + + handle_client_frame( + ws_frame( + "close-1", + "terminal.close", + json!({ "sessionId": session_id }), + ), + &state, + &tx, + &owned, + ) + .await; + let status = recv_server_frame(&mut rx).await; + + assert_eq!(status.kind, "terminal.status"); + assert_eq!(status.payload["status"], "exited"); + assert_eq!(status.payload["sessionId"], session_id); + assert_eq!(state.ws_pty_bridge.active_sessions(), 0); + assert!(state.app.terminal_sessions.is_empty()); + } + + #[test] + fn websocket_client_unmasked_frame_is_rejected() { + let raw = [0x81, 0x02, b'h', b'i']; + + let err = decode_client_ws_frame(&raw).unwrap_err(); + + assert!(matches!(err, WsFrameError::Protocol(_))); + } + + #[test] + fn websocket_payload_too_large_is_rejected() { + let len = (WS_MAX_PAYLOAD + 1) as u64; + let mut raw = vec![0x81, 0x80 | 127]; + raw.extend_from_slice(&len.to_be_bytes()); + raw.extend_from_slice(&[1, 2, 3, 4]); + + let err = decode_client_ws_frame(&raw).unwrap_err(); + + assert_eq!(err, WsFrameError::TooLarge); + } + + #[test] + fn websocket_server_frames_are_unmasked() { + let raw = encode_ws_frame(WsOpcode::Text, b"{}"); + + assert_eq!(raw[0], 0x81); + assert_eq!(raw[1] & 0x80, 0, "server frames must not be masked"); + } + + #[tokio::test] + async fn websocket_sink_full_reports_backpressure_without_blocking() { + let (tx, mut rx) = mpsc::channel(1); + let sid = domain::SessionId::new_random(); + let sink = WsPtySink::new(sid, tx, 0); + + assert!(sink.send(vec![1]).is_ok()); + assert_eq!(sink.send(vec![2]), Err(OutputSinkError::Full)); + let frame = rx.recv().await.unwrap(); + assert_eq!(frame.kind, "terminal.output"); + } + + #[tokio::test] + async fn websocket_output_bridge_replaces_attachment_without_double_delivery() { + let bridge = OutputBridge::::new(); + let sid = domain::SessionId::new_random(); + let (old_tx, mut old_rx) = mpsc::channel(4); + let (new_tx, mut new_rx) = mpsc::channel(4); + + let old_gen = bridge.register(sid, Arc::new(WsPtySink::new(sid, old_tx, 0))); + let _new_gen = bridge.register(sid, Arc::new(WsPtySink::new(sid, new_tx, 0))); + + assert!(bridge.send_output(&sid, vec![9])); + assert!(old_rx.try_recv().is_err()); + let frame = new_rx.recv().await.unwrap(); + assert_eq!(frame.kind, "terminal.output"); + assert_eq!(frame.payload["bytesBase64"], "CQ=="); + + bridge.unregister_if(&sid, old_gen); + assert_eq!(bridge.active_sessions(), 1); + } + #[test] fn public_bind_requires_explicit_remote_security() { let config = ServerConfig { From 7487902ddb86f319ef87c243fd70407fa9ec70ff Mon Sep 17 00:00:00 2001 From: Blomios Date: Wed, 15 Jul 2026 18:09:25 +0200 Subject: [PATCH 02/12] feat(frontend): xterm.js sur WebSocket avec reconnexion et scrollback (#13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lot F3 du chantier server/client mode : le client web branche xterm.js sur le terminal distant via WebSocket, en face de l'endpoint PTY B5. - wsLiveClient.ts : transport WebSocket du terminal avec reconnexion. - streamGateways.ts : gateway terminal (open/attach/data/resize/close). - frames.ts : frames PTY alignées sur le contrat serveur B5. - Tests : terminalGateway.test.ts, wsLiveClientReconnect.test.ts. Validé : frontend 743 tests verts (F3 ciblé 12), cohérence des frames B5↔F3 confirmée, desktop non régressé. Co-Authored-By: Claude Opus 4.8 --- frontend/src/adapters/http/frames.ts | 19 +- frontend/src/adapters/http/streamGateways.ts | 35 +- .../src/adapters/http/terminalGateway.test.ts | 144 +++++++ frontend/src/adapters/http/wsLiveClient.ts | 378 ++++++++++++++++-- .../http/wsLiveClientReconnect.test.ts | 170 ++++++++ 5 files changed, 685 insertions(+), 61 deletions(-) create mode 100644 frontend/src/adapters/http/terminalGateway.test.ts create mode 100644 frontend/src/adapters/http/wsLiveClientReconnect.test.ts diff --git a/frontend/src/adapters/http/frames.ts b/frontend/src/adapters/http/frames.ts index e61cef9..d63e8e2 100644 --- a/frontend/src/adapters/http/frames.ts +++ b/frontend/src/adapters/http/frames.ts @@ -94,7 +94,7 @@ export interface ServerFrame { payload: Record; } -/** Payload of a `terminal.attached` acknowledgement. */ +/** Payload of a `terminal.attached` acknowledgement (B5, `server.rs`). */ export interface AttachedPayload { session: { sessionId: string; @@ -104,8 +104,15 @@ export interface AttachedPayload { cols: number; }; nextSeq: number; + /** + * Bounded scrollback replayed at (re)attach. B5 sends a single entry (`seq:0`) + * carrying all retained bytes, or an empty array when there is nothing to + * replay. + */ scrollback: { seq: number; bytesBase64: string }[]; gap: boolean; + /** Lifecycle status carried on the ack (B5 sends `"running"`). */ + status?: string; /** Conversation id minted by an `agent.launch` (mirrors `assignedConversationId`). */ assignedConversationId?: string; } @@ -117,6 +124,16 @@ export interface OutputPayload { bytesBase64: string; } +/** + * Payload of a `terminal.status` frame (B5). `status` is a lifecycle transition + * (`"exited"` on close); `exitCode` is present for `"exited"` (may be `null`). + */ +export interface StatusPayload { + sessionId: string; + status: string; + exitCode?: number | null; +} + /** Payload of a `chat.output` frame (structured assistant stream). */ export interface ChatOutputPayload { sessionId: string; diff --git a/frontend/src/adapters/http/streamGateways.ts b/frontend/src/adapters/http/streamGateways.ts index 3966980..61ecd13 100644 --- a/frontend/src/adapters/http/streamGateways.ts +++ b/frontend/src/adapters/http/streamGateways.ts @@ -93,12 +93,11 @@ export function makeWsTerminalHandle( detach(): void { // Stop delivering output locally and tell the server the view is gone. The // backend PTY keeps running (detach ≠ close), matching the Tauri handle. - ws.removeOutputSink(sessionId); - void ws.sendFireAndForget("terminal.detach", { sessionId }); + // Untracking also disables reconnection re-attach for this session. + void ws.detachTerminal(sessionId); }, async close(): Promise { - ws.removeOutputSink(sessionId); - await ws.send("terminal.close", { sessionId }); + await ws.closeTerminalSession(sessionId); }, }; } @@ -137,37 +136,33 @@ export class HttpTerminalGateway implements TerminalGateway { options: OpenTerminalOptions, onData: (bytes: Uint8Array) => void, ): Promise { - const ack = await this.ws.send("terminal.open", { - projectId: undefined, // TODO(F3/B5): the port lacks projectId; confirm contract. - nodeId: options.nodeId ?? null, + // B5: `terminal.open` carries no projectId; `cwd` is a server-validated path. + // A fresh open has empty scrollback (the view does not repaint on open). + const res = await this.ws.openTerminal({ cwd: options.cwd, rows: options.rows, cols: options.cols, + nodeId: options.nodeId ?? null, + onData, }); - const payload = ack.payload as unknown as AttachedPayload; - const sessionId = payload.session.sessionId; - this.ws.setOutputSink(sessionId, onData); - const scrollback = attachedToScrollback(payload); - if (scrollback.length > 0) onData(scrollback); - return makeWsTerminalHandle(sessionId, this.ws); + return makeWsTerminalHandle(res.sessionId, this.ws); } async reattach( sessionId: string, onData: (bytes: Uint8Array) => void, ): Promise { - const ack = await this.ws.send("terminal.attach", { sessionId, lastSeq: null }); - const payload = ack.payload as unknown as AttachedPayload; - this.ws.setOutputSink(sessionId, onData); + // The view repaints the returned scrollback itself (TerminalView contract); + // the client does not also push it through `onData` on the initial attach. + const res = await this.ws.attachTerminal({ sessionId, onData }); return { - handle: makeWsTerminalHandle(sessionId, this.ws), - scrollback: attachedToScrollback(payload), + handle: makeWsTerminalHandle(res.sessionId, this.ws), + scrollback: res.scrollback, }; } async closeTerminal(sessionId: string): Promise { - this.ws.removeOutputSink(sessionId); - await this.ws.send("terminal.close", { sessionId }); + await this.ws.closeTerminalSession(sessionId); } } diff --git a/frontend/src/adapters/http/terminalGateway.test.ts b/frontend/src/adapters/http/terminalGateway.test.ts new file mode 100644 index 0000000..c7449bc --- /dev/null +++ b/frontend/src/adapters/http/terminalGateway.test.ts @@ -0,0 +1,144 @@ +/** + * F3 — the WS terminal gateway + handle round-trip against the B5 frame contract: + * open→attached (empty scrollback), attach→attached (scrollback returned for the + * view to repaint, not double-pushed), input/resize emit conforming frames, + * detach ≠ close, and `terminal.output` bytes are base64-decoded into the sink. + */ +import { describe, it, expect, vi } from "vitest"; + +import { HttpTerminalGateway } from "./streamGateways"; +import { WsLiveClient, type WebSocketLike } from "./wsLiveClient"; +import { bytesToBase64 } from "./frames"; + +class FakeSocket implements WebSocketLike { + sent: string[] = []; + onopen: (() => void) | null = null; + onmessage: ((event: { data: string }) => void) | null = null; + onerror: ((event: unknown) => void) | null = null; + onclose: (() => void) | null = null; + send(data: string): void { + this.sent.push(data); + } + close(): void { + this.onclose?.(); + } + receive(frame: unknown): void { + this.onmessage?.({ data: JSON.stringify(frame) }); + } +} + +/** Client whose socket auto-opens on the next microtask. */ +function client(): { ws: WsLiveClient; sockets: FakeSocket[] } { + const sockets: FakeSocket[] = []; + const ws = new WsLiveClient({ + wsUrl: "wss://host", + socketFactory: () => { + const s = new FakeSocket(); + sockets.push(s); + queueMicrotask(() => s.onopen?.()); + return s; + }, + }); + return { ws, sockets }; +} + +/** Awaits the frame the client just sent, then replies with `reply(id)`. */ +async function replyToLast( + socket: FakeSocket, + index: number, + reply: (id: string) => unknown, +): Promise> { + await vi.waitFor(() => expect(socket.sent.length).toBeGreaterThan(index)); + const sent = JSON.parse(socket.sent[index]); + socket.receive(reply(sent.id)); + return sent; +} + +function attachedAck(id: string, sessionId: string, scrollbackBytes?: Uint8Array) { + return { + kind: "terminal.attached", + replyTo: id, + payload: { + session: { sessionId, rows: 30, cols: 120 }, + scrollback: scrollbackBytes + ? [{ seq: 0, bytesBase64: bytesToBase64(scrollbackBytes) }] + : [], + nextSeq: scrollbackBytes ? 1 : 0, + status: "running", + gap: false, + }, + }; +} + +describe("HttpTerminalGateway round-trip (B5 frames)", () => { + it("open → attached with empty scrollback; output is decoded into the sink", async () => { + const { ws, sockets } = client(); + const gw = new HttpTerminalGateway(ws); + const chunks: Uint8Array[] = []; + + const handlePromise = gw.openTerminal({ cwd: "/srv/app", rows: 30, cols: 120 }, (b) => + chunks.push(b), + ); + const sent = await replyToLast(sockets[0], 0, (id) => attachedAck(id, "s1")); + const handle = await handlePromise; + + // Conforming open frame (no projectId; cwd is server-validated). + expect(sent.kind).toBe("terminal.open"); + expect(sent.payload).toMatchObject({ cwd: "/srv/app", rows: 30, cols: 120 }); + expect(handle.sessionId).toBe("s1"); + // No scrollback pushed on a fresh open. + expect(chunks).toHaveLength(0); + + // A terminal.output frame is base64-decoded and written to the sink. + sockets[0].receive({ + kind: "terminal.output", + payload: { sessionId: "s1", seq: 1, bytesBase64: bytesToBase64(new Uint8Array([104, 105])) }, + }); + expect(chunks).toEqual([new Uint8Array([104, 105])]); + }); + + it("attach → attached returns the scrollback for the view (not double-pushed)", async () => { + const { ws, sockets } = client(); + const gw = new HttpTerminalGateway(ws); + const chunks: Uint8Array[] = []; + const scroll = new Uint8Array([65, 66, 67]); + + const resPromise = gw.reattach("s7", (b) => chunks.push(b)); + const sent = await replyToLast(sockets[0], 0, (id) => attachedAck(id, "s7", scroll)); + const res = await resPromise; + + expect(sent.kind).toBe("terminal.attach"); + expect(sent.payload).toMatchObject({ sessionId: "s7" }); + // Scrollback is returned for TerminalView to repaint… + expect(res.scrollback).toEqual(scroll); + // …and NOT also pushed through onData (no double paint on the initial attach). + expect(chunks).toHaveLength(0); + }); + + it("input and resize emit conforming frames; detach ≠ close", async () => { + const { ws, sockets } = client(); + const gw = new HttpTerminalGateway(ws); + + const handlePromise = gw.openTerminal({ cwd: "/srv", rows: 24, cols: 80 }, () => {}); + await replyToLast(sockets[0], 0, (id) => attachedAck(id, "s1")); + const handle = await handlePromise; + const base = sockets[0].sent.length; + + await handle.write(new Uint8Array([13])); + await handle.resize(40, 140); + + const input = JSON.parse(sockets[0].sent[base]); + expect(input.kind).toBe("terminal.input"); + expect(input.payload).toEqual({ sessionId: "s1", bytesBase64: bytesToBase64(new Uint8Array([13])) }); + + const resize = JSON.parse(sockets[0].sent[base + 1]); + expect(resize.kind).toBe("terminal.resize"); + expect(resize.payload).toEqual({ sessionId: "s1", rows: 40, cols: 140 }); + + // detach keeps the PTY alive (terminal.detach); close kills it (terminal.close). + handle.detach(); + await vi.waitFor(() => + expect(JSON.parse(sockets[0].sent[base + 2]).kind).toBe("terminal.detach"), + ); + }); +}); diff --git a/frontend/src/adapters/http/wsLiveClient.ts b/frontend/src/adapters/http/wsLiveClient.ts index 21e9d70..a5e5f07 100644 --- a/frontend/src/adapters/http/wsLiveClient.ts +++ b/frontend/src/adapters/http/wsLiveClient.ts @@ -1,28 +1,45 @@ /** - * WebSocket live client skeleton for the web transport — ticket #13, lot F1. + * WebSocket live client for the web transport — ticket #13, lot F3 (finalised + * from the F1 skeleton). * - * Owns a single WS connection to `{wsUrl}/ws/live` and multiplexes over it, per - * the B0 draft: PTY output, structured chat output and the low-frequency domain - * event stream (`event.domain`, replacing Tauri `listen("domain://event")`). + * Owns a single WS connection to `{wsUrl}/ws/live` (authenticated by the session + * cookie at the upgrade — same-origin, no URL secret) and multiplexes over it, + * aligned on the **B5 frozen frame contract** (`crates/app-tauri/src/server.rs`): * - * **F1 scope = skeleton.** The connection, frame envelope, request/reply - * correlation by `id`, and the per-session output routing are implemented and - * unit-testable (inject a fake WebSocket factory). What is deliberately deferred - * to **F3/B5** (when a server exists — B3/B4): reconnection/backpressure, exact - * `open`/`launch` reply shape, sequence-gap replay policy, and the full xterm - * round-trip. Those are marked `TODO(F3/B5)`. + * - client→server: `terminal.open` `{cwd,rows,cols}`, `terminal.attach` + * `{sessionId,rows?,cols?,lastSeq?}`, `terminal.input` `{sessionId,bytesBase64}`, + * `terminal.resize` `{sessionId,rows,cols}`, `terminal.detach` `{sessionId}`, + * `terminal.close` `{sessionId}`, `ping`. + * - server→client: `terminal.attached` (ack: `{session,scrollback,nextSeq,status, + * gap,assignedConversationId}`), `terminal.output` `{sessionId,seq,bytesBase64}`, + * `terminal.status` `{sessionId,status,exitCode}`, `error`, `pong`, plus the + * low-frequency `event.domain` stream (domain events). * - * Lives in `src/adapters/**`; touches no `@tauri-apps/api`. + * F3 additions over F1: a **connection state machine** (connecting / connected / + * reconnecting / closed) with automatic reconnection + re-attach and bounded + * scrollback repaint, and per-session **status routing** (`exited`). The terminal + * lifecycle is owned here so a browser reload / network drop transparently + * re-attaches the surviving server-side PTY. Multi-tab "last attach wins" is + * silent on the wire (the evicted attachment simply stops receiving output — see + * the F3 report), so there is nothing to crash on; the socket-close path handles + * disconnection uniformly. + * + * Lives in `src/adapters/**`; touches no `@tauri-apps/api`. The port + * (`TerminalGateway`/`TerminalHandle`) and `TerminalView` are unchanged; the UI + * indication of connection state is written into xterm through the session's own + * output sink (a notice line), so no component needs to know about this client. */ -import type { DomainEvent } from "@/domain"; +import type { DomainEvent, Unsubscribe } from "@/domain"; import { base64ToBytes, bytesToBase64, + type AttachedPayload, type ClientFrame, type ClientFrameKind, type OutputPayload, type ServerFrame, + type StatusPayload, } from "./frames"; /** Minimal WebSocket surface used here; injectable so tests pass a fake. */ @@ -38,14 +55,48 @@ export interface WebSocketLike { /** Factory building a {@link WebSocketLike} for a URL (defaults to global WS). */ export type WebSocketFactory = (url: string) => WebSocketLike; +/** Injectable deferred-call primitives (defaults to global timers). */ +export type SetTimeoutLike = (fn: () => void, ms: number) => unknown; +export type ClearTimeoutLike = (handle: unknown) => void; + +/** UI-facing connection state of the live socket. */ +export type ConnectionState = "connecting" | "connected" | "reconnecting" | "closed"; + /** Configuration for the live client. */ export interface WsLiveClientConfig { /** Base WS URL, e.g. `wss://host:port`. No trailing slash. */ wsUrl: string; - /** Bearer token (ticket #13 auth). B0 note: prefer header/cookie over URL. */ + /** Bearer token (dev/tests only). Prod auth is the session cookie at upgrade. */ token?: string; /** Injected WebSocket factory (defaults to `new WebSocket(url)`). */ socketFactory?: WebSocketFactory; + /** Base reconnect delay in ms (exponential backoff, capped). Default 500. */ + reconnectBaseMs?: number; + /** Max reconnect delay in ms. Default 10000. */ + reconnectMaxMs?: number; + /** Injected `setTimeout` (tests drive reconnection deterministically). */ + setTimeoutImpl?: SetTimeoutLike; + /** Injected `clearTimeout`. */ + clearTimeoutImpl?: ClearTimeoutLike; +} + +/** A live terminal subscription tracked for output routing + reconnection replay. */ +interface TerminalSubscription { + /** Delivers raw PTY bytes (and adapter notices) to the xterm view. */ + onData: (bytes: Uint8Array) => void; + /** Optional lifecycle callback (`exited`), for tests / future UI. */ + onStatus?: (status: string, exitCode?: number | null) => void; + /** Last output sequence seen — sent as `lastSeq` when re-attaching. */ + lastSeq: number; +} + +/** Result of opening/attaching a terminal over the WS. */ +export interface TerminalAttachResult { + sessionId: string; + /** Bounded scrollback bytes to repaint (empty on a fresh open). */ + scrollback: Uint8Array; + assignedConversationId?: string; + status?: string; } let frameCounter = 0; @@ -55,20 +106,50 @@ function nextFrameId(): string { return `c${frameCounter}`; } +const encoder = new TextEncoder(); +/** Encodes a human notice line to bytes for writing into xterm. */ +function notice(text: string): Uint8Array { + return encoder.encode(`\r\n\x1b[2m[${text}]\x1b[0m\r\n`); +} + +/** Concatenates an attached-frame scrollback list into a single byte buffer. */ +function scrollbackBytes(payload: AttachedPayload): Uint8Array { + const chunks = payload.scrollback.map((c) => base64ToBytes(c.bytesBase64)); + const total = chunks.reduce((n, c) => n + c.length, 0); + const out = new Uint8Array(total); + let offset = 0; + for (const c of chunks) { + out.set(c, offset); + offset += c.length; + } + return out; +} + /** - * Manages the single live WebSocket. Callers subscribe an output sink per PTY - * session and a single domain-event handler; the client routes inbound frames. + * Manages the single live WebSocket, the connection state machine, and the live + * terminal sessions (so reconnection re-attaches them transparently). */ export class WsLiveClient { private readonly wsUrl: string; private readonly token?: string; private readonly socketFactory: WebSocketFactory; + private readonly reconnectBaseMs: number; + private readonly reconnectMaxMs: number; + private readonly setTimeoutImpl: SetTimeoutLike; + private readonly clearTimeoutImpl: ClearTimeoutLike; private socket: WebSocketLike | null = null; private opening: Promise | null = null; + private disposed = false; + private everConnected = false; + private connState: ConnectionState = "connecting"; + private reconnectAttempt = 0; + private reconnectHandle: unknown = null; - /** Per-session PTY output sinks (sessionId → onData). */ + /** Per-session PTY output sinks (sessionId → onData). Low-level routing map. */ private readonly outputSinks = new Map void>(); + /** Live terminal subscriptions tracked for reconnection replay + status. */ + private readonly terminals = new Map(); /** Pending command acknowledgements, keyed by client frame id. */ private readonly pending = new Map< string, @@ -76,6 +157,8 @@ export class WsLiveClient { >(); /** The single domain-event handler (set by the system gateway). */ private domainEventHandler: ((event: DomainEvent) => void) | null = null; + /** Connection-state listeners (UI / tests). */ + private readonly connListeners = new Set<(state: ConnectionState) => void>(); constructor(config: WsLiveClientConfig) { this.wsUrl = config.wsUrl.replace(/\/+$/, ""); @@ -83,65 +166,248 @@ export class WsLiveClient { this.socketFactory = config.socketFactory ?? ((url: string) => new WebSocket(url) as unknown as WebSocketLike); + this.reconnectBaseMs = config.reconnectBaseMs ?? 500; + this.reconnectMaxMs = config.reconnectMaxMs ?? 10000; + this.setTimeoutImpl = + config.setTimeoutImpl ?? ((fn, ms) => setTimeout(fn, ms)); + this.clearTimeoutImpl = + config.clearTimeoutImpl ?? ((h) => clearTimeout(h as ReturnType)); } - /** Registers the domain-event handler (replaces any previous one). */ + // ------------------------------------------------------------------------- + // Connection state + // ------------------------------------------------------------------------- + + /** Current connection state. */ + getConnectionState(): ConnectionState { + return this.connState; + } + + /** Subscribes to connection-state transitions (returns an unsubscribe). */ + onConnectionStateChange(listener: (state: ConnectionState) => void): Unsubscribe { + this.connListeners.add(listener); + return () => this.connListeners.delete(listener); + } + + private setConnState(state: ConnectionState): void { + if (this.connState === state) return; + this.connState = state; + for (const listener of this.connListeners) listener(state); + } + + // ------------------------------------------------------------------------- + // Domain events (system gateway) + // ------------------------------------------------------------------------- + setDomainEventHandler(handler: (event: DomainEvent) => void): void { this.domainEventHandler = handler; } - - /** Clears the domain-event handler. */ clearDomainEventHandler(): void { this.domainEventHandler = null; } - /** Registers a per-session PTY output sink. */ + // ------------------------------------------------------------------------- + // Low-level output sink map (used by the agent gateway too) + // ------------------------------------------------------------------------- + setOutputSink(sessionId: string, onData: (bytes: Uint8Array) => void): void { this.outputSinks.set(sessionId, onData); } - - /** Drops a per-session PTY output sink (view detached). */ removeOutputSink(sessionId: string): void { this.outputSinks.delete(sessionId); } + // ------------------------------------------------------------------------- + // Connection lifecycle + // ------------------------------------------------------------------------- + /** Ensures the socket is connected, connecting on first use. */ async ensureConnected(): Promise { if (this.socket) return; if (this.opening) return this.opening; + return this.connect(); + } + private connect(): Promise { + this.setConnState(this.everConnected ? "reconnecting" : "connecting"); this.opening = new Promise((resolve, reject) => { - // B0 auth note: token should ride an `Authorization` header / secure - // cookie at upgrade, NOT a URL secret. Browsers can't set WS upgrade - // headers, so the token placement is a contract point to confirm (see the - // F1 report). The query below is only a placeholder for the skeleton. + // Prod: the session cookie rides the upgrade (same-origin). The optional + // token is a dev/test convenience only (never a prod secret in the URL). const url = this.token ? `${this.wsUrl}/ws/live?token=${encodeURIComponent(this.token)}` : `${this.wsUrl}/ws/live`; const socket = this.socketFactory(url); socket.onopen = () => { this.socket = socket; + this.opening = null; + this.everConnected = true; + this.reconnectAttempt = 0; + this.setConnState("connected"); resolve(); }; socket.onerror = (event) => { this.opening = null; reject(event); }; - socket.onclose = () => { - // TODO(F3/B5): reconnection + re-attach of live sessions. For F1 we drop - // the socket so a later call reconnects fresh. - this.socket = null; - this.rejectAllPending({ code: "WS_CLOSED", message: "socket closed" }); - }; + socket.onclose = () => this.handleSocketClosed(); socket.onmessage = (event) => this.handleMessage(event.data); }); return this.opening; } + private handleSocketClosed(): void { + this.socket = null; + this.opening = null; + this.rejectAllPending({ code: "WS_CLOSED", message: "socket closed" }); + if (this.disposed) { + this.setConnState("closed"); + return; + } + // Only reconnect when there is a live terminal to re-attach; otherwise stay + // idle until the next explicit use. + if (this.terminals.size > 0) { + this.setConnState("reconnecting"); + for (const sub of this.terminals.values()) sub.onData(notice("déconnecté — reconnexion…")); + this.scheduleReconnect(); + } else { + this.setConnState("reconnecting"); + } + } + + private scheduleReconnect(): void { + if (this.disposed || this.reconnectHandle) return; + const delay = Math.min( + this.reconnectMaxMs, + this.reconnectBaseMs * 2 ** this.reconnectAttempt, + ); + this.reconnectAttempt += 1; + this.reconnectHandle = this.setTimeoutImpl(() => { + this.reconnectHandle = null; + void this.reconnectNow(); + }, delay); + } + + private async reconnectNow(): Promise { + if (this.disposed) return; + try { + await this.connect(); + await this.reattachAll(); + } catch { + // Still down: back off and retry (unless everything was detached/closed). + if (this.terminals.size > 0) this.scheduleReconnect(); + } + } + + /** Re-attaches every tracked terminal after a reconnect and repaints scrollback. */ + private async reattachAll(): Promise { + for (const [sessionId, sub] of this.terminals) { + try { + const ack = await this.send("terminal.attach", { + sessionId, + lastSeq: sub.lastSeq, + }); + const payload = ack.payload as unknown as AttachedPayload; + sub.onData(notice("reconnecté")); + const bytes = scrollbackBytes(payload); + if (bytes.length > 0) sub.onData(bytes); + if (typeof payload.nextSeq === "number") { + sub.lastSeq = Math.max(0, payload.nextSeq - 1); + } + } catch { + // A single session failing to re-attach (e.g. server killed it) must not + // block the others; leave it tracked for the next reconnect cycle. + } + } + } + + // ------------------------------------------------------------------------- + // Terminal sessions (high-level; used by HttpTerminalGateway) + // ------------------------------------------------------------------------- + + /** Opens a fresh terminal (`terminal.open`) and starts tracking it. */ + openTerminal(params: { + cwd: string; + rows: number; + cols: number; + nodeId?: string | null; + onData: (bytes: Uint8Array) => void; + onStatus?: (status: string, exitCode?: number | null) => void; + }): Promise { + return this.attachInternal( + "terminal.open", + { cwd: params.cwd, rows: params.rows, cols: params.cols, nodeId: params.nodeId ?? null }, + { onData: params.onData, onStatus: params.onStatus, lastSeq: 0 }, + ); + } + + /** Re-attaches to an existing terminal (`terminal.attach`) and tracks it. */ + attachTerminal(params: { + sessionId: string; + rows?: number; + cols?: number; + lastSeq?: number; + onData: (bytes: Uint8Array) => void; + onStatus?: (status: string, exitCode?: number | null) => void; + }): Promise { + return this.attachInternal( + "terminal.attach", + { + sessionId: params.sessionId, + rows: params.rows, + cols: params.cols, + lastSeq: params.lastSeq ?? null, + }, + { onData: params.onData, onStatus: params.onStatus, lastSeq: params.lastSeq ?? 0 }, + params.sessionId, + ); + } + + private async attachInternal( + kind: ClientFrameKind, + payload: Record, + sub: TerminalSubscription, + knownSessionId?: string, + ): Promise { + const ack = await this.send(kind, payload); + const ap = ack.payload as unknown as AttachedPayload; + const sessionId = knownSessionId ?? ap.session.sessionId; + if (typeof ap.nextSeq === "number") sub.lastSeq = Math.max(0, ap.nextSeq - 1); + this.terminals.set(sessionId, sub); + // Route output to the subscription's onData (the low-level map is the single + // source of routing; lastSeq is tracked in handleMessage). + this.setOutputSink(sessionId, sub.onData); + return { + sessionId, + scrollback: scrollbackBytes(ap), + assignedConversationId: ap.assignedConversationId, + status: ap.status, + }; + } + + /** Detaches the view (server PTY keeps running): stop tracking + `terminal.detach`. */ + async detachTerminal(sessionId: string): Promise { + this.removeTerminal(sessionId); + await this.sendFireAndForget("terminal.detach", { sessionId }); + } + + /** Kills the PTY (`terminal.close`) and stops tracking. */ + async closeTerminalSession(sessionId: string): Promise { + this.removeTerminal(sessionId); + await this.send("terminal.close", { sessionId }); + } + + private removeTerminal(sessionId: string): void { + this.terminals.delete(sessionId); + this.outputSinks.delete(sessionId); + } + + // ------------------------------------------------------------------------- + // Frame I/O + // ------------------------------------------------------------------------- + /** - * Sends a client frame and resolves with its acknowledgement frame (the server - * frame whose `replyTo` equals this frame's `id`). Fire-and-forget frames - * (input/resize/detach) can ignore the returned promise. + * Sends a client frame and resolves with its acknowledgement (the server frame + * whose `replyTo` equals this frame's `id`). */ async send( kind: ClientFrameKind, @@ -150,8 +416,7 @@ export class WsLiveClient { await this.ensureConnected(); const socket = this.socket; if (!socket) { - const err = { code: "WS_CLOSED", message: "socket not connected" }; - throw err; + throw { code: "WS_CLOSED", message: "socket not connected" }; } const id = nextFrameId(); const frame: ClientFrame = { id, kind, payload }; @@ -180,12 +445,19 @@ export class WsLiveClient { /** Closes the socket and rejects everything pending. */ dispose(): void { + this.disposed = true; + if (this.reconnectHandle) { + this.clearTimeoutImpl(this.reconnectHandle); + this.reconnectHandle = null; + } this.rejectAllPending({ code: "WS_CLOSED", message: "client disposed" }); this.outputSinks.clear(); + this.terminals.clear(); this.domainEventHandler = null; this.socket?.close(); this.socket = null; this.opening = null; + this.setConnState("closed"); } private rejectAllPending(err: unknown): void { @@ -198,20 +470,25 @@ export class WsLiveClient { try { frame = JSON.parse(data) as ServerFrame; } catch { - return; // Ignore malformed frames in the skeleton. + return; // Ignore malformed frames. } // Route unsolicited streams first (no replyTo). switch (frame.kind) { case "terminal.output": { const payload = frame.payload as unknown as OutputPayload; + const sub = this.terminals.get(payload.sessionId); + if (sub && typeof payload.seq === "number") sub.lastSeq = payload.seq; const sink = this.outputSinks.get(payload.sessionId); - // TODO(F3/B5): honour `seq` ordering + gap detection for precise replay. if (sink) sink(base64ToBytes(payload.bytesBase64)); return; } + case "terminal.status": { + const payload = frame.payload as unknown as StatusPayload; + this.handleStatus(payload); + return; + } case "event.domain": { - // `payload` is a DomainEventDto (kind-tagged); forward as-is. this.domainEventHandler?.(frame.payload as unknown as DomainEvent); return; } @@ -229,4 +506,25 @@ export class WsLiveClient { } } } + + private handleStatus(payload: StatusPayload): void { + const sub = this.terminals.get(payload.sessionId); + if (!sub) return; + if (payload.status === "exited" || payload.status === "closed") { + const code = payload.exitCode; + sub.onData( + notice( + code === null || code === undefined + ? "session terminée" + : `session terminée (code ${code})`, + ), + ); + sub.onStatus?.(payload.status, payload.exitCode); + // The PTY is gone: stop tracking so a later disconnect does not try to + // re-attach a dead session. + this.removeTerminal(payload.sessionId); + } else { + sub.onStatus?.(payload.status, payload.exitCode); + } + } } diff --git a/frontend/src/adapters/http/wsLiveClientReconnect.test.ts b/frontend/src/adapters/http/wsLiveClientReconnect.test.ts new file mode 100644 index 0000000..da057e7 --- /dev/null +++ b/frontend/src/adapters/http/wsLiveClientReconnect.test.ts @@ -0,0 +1,170 @@ +/** + * F3 — the WS live client connection state machine + reconnection: + * - disconnected (socket close) ⇒ `reconnecting`, a notice is written to xterm; + * - on reconnect, the tracked terminal is re-attached with its `lastSeq` and the + * bounded scrollback is repainted; state returns to `connected`; + * - a `terminal.status` `exited` frame notifies the session and stops tracking + * (so a later disconnect does not try to re-attach a dead PTY). + */ +import { describe, it, expect, vi } from "vitest"; + +import { WsLiveClient, type WebSocketLike, type ConnectionState } from "./wsLiveClient"; +import { bytesToBase64 } from "./frames"; + +class FakeSocket implements WebSocketLike { + sent: string[] = []; + onopen: (() => void) | null = null; + onmessage: ((event: { data: string }) => void) | null = null; + onerror: ((event: unknown) => void) | null = null; + onclose: (() => void) | null = null; + send(data: string): void { + this.sent.push(data); + } + close(): void { + this.onclose?.(); + } + receive(frame: unknown): void { + this.onmessage?.({ data: JSON.stringify(frame) }); + } +} + +function harness() { + const sockets: FakeSocket[] = []; + let timerFn: (() => void) | null = null; + const ws = new WsLiveClient({ + wsUrl: "wss://host", + reconnectBaseMs: 1, + socketFactory: () => { + const s = new FakeSocket(); + sockets.push(s); + queueMicrotask(() => s.onopen?.()); + return s; + }, + setTimeoutImpl: (fn) => { + timerFn = fn; + return 1; + }, + clearTimeoutImpl: () => { + timerFn = null; + }, + }); + const states: ConnectionState[] = []; + ws.onConnectionStateChange((s) => states.push(s)); + return { + ws, + sockets, + states, + fireTimer: () => { + const fn = timerFn; + timerFn = null; + fn?.(); + }, + hasTimer: () => timerFn !== null, + }; +} + +function attachedAck(id: string, sessionId: string, nextSeq: number, scroll?: Uint8Array) { + return { + kind: "terminal.attached", + replyTo: id, + payload: { + session: { sessionId, rows: 30, cols: 120 }, + scrollback: scroll ? [{ seq: 0, bytesBase64: bytesToBase64(scroll) }] : [], + nextSeq, + status: "running", + gap: false, + }, + }; +} + +async function attach( + ws: WsLiveClient, + sockets: FakeSocket[], + sessionId: string, + onData: (b: Uint8Array) => void, + onStatus?: (s: string, c?: number | null) => void, +): Promise { + const p = ws.attachTerminal({ sessionId, onData, onStatus }); + // The socket is created lazily by the factory inside ensureConnected. + await vi.waitFor(() => expect(sockets[0]?.sent.length ?? 0).toBeGreaterThan(0)); + const sent = JSON.parse(sockets[0].sent[0]); + sockets[0].receive(attachedAck(sent.id, sessionId, 0)); + await p; +} + +const decode = (b: Uint8Array) => new TextDecoder().decode(b); + +describe("WsLiveClient connection state + reconnection", () => { + it("goes connecting → connected on the first attach", async () => { + const h = harness(); + await attach(h.ws, h.sockets, "s1", () => {}); + expect(h.ws.getConnectionState()).toBe("connected"); + expect(h.states).toContain("connected"); + }); + + it("on socket close it reconnects, re-attaches with lastSeq and repaints scrollback", async () => { + const h = harness(); + const chunks: Uint8Array[] = []; + await attach(h.ws, h.sockets, "s1", (b) => chunks.push(b)); + + // Advance lastSeq via an output frame (seq 5). + h.sockets[0].receive({ + kind: "terminal.output", + payload: { sessionId: "s1", seq: 5, bytesBase64: bytesToBase64(new Uint8Array([120])) }, + }); + + // Drop the socket → reconnecting + a "déconnecté" notice. + h.sockets[0].close(); + expect(h.ws.getConnectionState()).toBe("reconnecting"); + expect(chunks.some((c) => decode(c).includes("déconnecté"))).toBe(true); + expect(h.hasTimer()).toBe(true); + + // Fire the reconnect timer → a new socket opens and re-attaches. + h.fireTimer(); + await vi.waitFor(() => expect(h.sockets.length).toBe(2)); + await vi.waitFor(() => expect(h.sockets[1].sent.length).toBeGreaterThan(0)); + const reattach = JSON.parse(h.sockets[1].sent[0]); + expect(reattach.kind).toBe("terminal.attach"); + // lastSeq carries the last output seq seen (5) for precise replay. + expect(reattach.payload).toMatchObject({ sessionId: "s1", lastSeq: 5 }); + + // Server replies with fresh scrollback; the client repaints it + "reconnecté". + const scroll = new Uint8Array([82, 69]); + h.sockets[1].receive(attachedAck(reattach.id, "s1", 3, scroll)); + await vi.waitFor(() => expect(h.ws.getConnectionState()).toBe("connected")); + // The repaint happens after the re-attach ack resolves (a microtask later). + await vi.waitFor(() => + expect(chunks.some((c) => decode(c).includes("reconnecté"))).toBe(true), + ); + expect(chunks.some((c) => c.length === 2 && c[0] === 82 && c[1] === 69)).toBe(true); + }); + + it("routes terminal.status exited to onStatus and stops tracking the session", async () => { + const h = harness(); + const chunks: Uint8Array[] = []; + const onStatus = vi.fn(); + await attach(h.ws, h.sockets, "s1", (b) => chunks.push(b), onStatus); + + h.sockets[0].receive({ + kind: "terminal.status", + payload: { sessionId: "s1", status: "exited", exitCode: 0 }, + }); + + expect(onStatus).toHaveBeenCalledWith("exited", 0); + expect(chunks.some((c) => decode(c).includes("session terminée"))).toBe(true); + + // Session untracked ⇒ a later socket close does not schedule a reconnect. + h.sockets[0].close(); + expect(h.hasTimer()).toBe(false); + }); + + it("dispose() moves to closed and clears any pending reconnect", async () => { + const h = harness(); + await attach(h.ws, h.sockets, "s1", () => {}); + h.sockets[0].close(); + expect(h.ws.getConnectionState()).toBe("reconnecting"); + h.ws.dispose(); + expect(h.ws.getConnectionState()).toBe("closed"); + expect(h.hasTimer()).toBe(false); + }); +}); From 6391d1c75a7d47e1320968103ef54cc65d7326e3 Mon Sep 17 00:00:00 2001 From: Blomios Date: Wed, 15 Jul 2026 23:17:11 +0200 Subject: [PATCH 03/12] feat(server): agents CLI via le PTY WebSocket sur idea --serve (#13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lot B6 du chantier server/client mode : le serveur --serve permet de lancer et piloter un agent CLI à travers le PTY WebSocket B5. - Frame WS agent.launch → ack terminal.attached avec assignedConversationId. - Réutilise LaunchAgent + TerminalSessions + sink WS B5, réattache sans respawn, singleton AGENT_ALREADY_RUNNING, structured → UNSUPPORTED. - 42 tests server (launch/reattach/singleton/structured), tests flaky stabilisés. Clippy server.rs propre. Validé : app-tauri 286 tests verts, backend 28, contrat B6↔F4 aligné (aucun écart de frame), desktop non régressé. Réserve connue : le round-trip socket réel relève d'une validation live hors sandbox. Co-Authored-By: Claude Opus 4.8 --- crates/app-tauri/src/server.rs | 465 ++++++++++++++++++++++++++++++++- 1 file changed, 460 insertions(+), 5 deletions(-) diff --git a/crates/app-tauri/src/server.rs b/crates/app-tauri/src/server.rs index 0e78821..8a4c62a 100644 --- a/crates/app-tauri/src/server.rs +++ b/crates/app-tauri/src/server.rs @@ -28,15 +28,16 @@ use tokio::sync::mpsc; use uuid::Uuid; use application::{ - CloseTerminalInput, GetProjectWorkStateInput, OpenProjectInput, ResizeTerminalInput, - WriteToTerminalInput, + CloseTerminalInput, GetProjectWorkStateInput, LaunchAgentInput, McpRuntime, OpenProjectInput, + ResizeTerminalInput, RotateConversationLogInput, WriteToTerminalInput, }; use domain::ports::PtyHandle; use domain::{Project, SessionId}; use crate::dto::{ - parse_project_id, parse_session_id, ErrorDto, HealthRequestDto, HealthResponseDto, - OpenTerminalRequestDto, ProjectDto, ProjectListDto, ProjectWorkStateDto, TerminalSessionDto, + parse_agent_id, parse_node_id, parse_project_id, parse_session_id, ErrorDto, HealthRequestDto, + HealthResponseDto, LaunchAgentRequestDto, OpenTerminalRequestDto, ProjectDto, ProjectListDto, + ProjectWorkStateDto, TerminalSessionDto, }; use crate::pty::PtyChunk; use crate::state::AppState; @@ -580,6 +581,7 @@ async fn handle_client_frame( ) { let result = match frame.kind.as_str() { "terminal.open" | "open_terminal" => ws_open_terminal(&frame, state, tx, owned).await, + "agent.launch" | "launch_agent" => ws_launch_agent(&frame, state, tx, owned).await, "terminal.attach" | "attach_terminal" => ws_attach_terminal(&frame, state, tx, owned).await, "terminal.input" | "input" => ws_input(&frame, state), "terminal.resize" | "resize" => ws_resize(&frame, state), @@ -646,6 +648,126 @@ async fn ws_open_terminal( attach_sink_and_pump(state, sid, sink, owned) } +async fn ws_launch_agent( + frame: &ClientFrame, + state: &Arc, + tx: &mpsc::Sender, + owned: &Arc>>, +) -> Result<(), ErrorDto> { + let request_value = frame + .payload + .get("request") + .cloned() + .unwrap_or_else(|| frame.payload.clone()); + let request: LaunchAgentRequestDto = + serde_json::from_value(request_value).map_err(invalid_args_error)?; + let output = execute_launch_agent_for_ws(state, request).await?; + send_launch_agent_attached(frame, state, tx, owned, output).await +} + +async fn send_launch_agent_attached( + frame: &ClientFrame, + state: &Arc, + tx: &mpsc::Sender, + owned: &Arc>>, + output: application::LaunchAgentOutput, +) -> Result<(), ErrorDto> { + if output.structured.is_some() { + return Err(ErrorDto { + code: "UNSUPPORTED".to_owned(), + message: "structured agent sessions do not stream over the PTY websocket".to_owned(), + }); + } + let session_id = output.session.id; + let dto = TerminalSessionDto::from(output); + let sid = parse_session_id(&dto.session_id)?; + let sink = WsPtySink::new(sid, tx.clone(), 0); + tx.send(ServerFrame::attached(&frame.id, &dto, Vec::new(), 0, false)) + .await + .map_err(|_| ErrorDto { + code: "WS_CLOSED".to_owned(), + message: "websocket output closed".to_owned(), + })?; + attach_sink_and_pump(state, session_id, sink, owned) +} + +async fn execute_launch_agent_for_ws( + state: &Arc, + request: LaunchAgentRequestDto, +) -> Result { + let project = resolve_project_readonly(&request.project_id, &state.app).await?; + let agent_id = parse_agent_id(&request.agent_id)?; + let node_id = request.node_id.as_deref().map(parse_node_id).transpose()?; + let mcp_runtime = crate::mcp_endpoint::idea_exe_path().map(|exe| McpRuntime { + exe, + endpoint: crate::mcp_endpoint::mcp_endpoint(&project.id) + .as_cli_arg() + .to_owned(), + project_id: project.id.as_uuid().simple().to_string(), + requester: agent_id.to_string(), + }); + + state.app.reconcile_claude_run_dirs(&project).await; + + let resume_project = project.clone(); + let rotation_root = project.root.clone(); + let watch_root = project.root.clone(); + let output = state + .app + .launch_agent + .execute(LaunchAgentInput { + project, + agent_id, + rows: request.rows, + cols: request.cols, + node_id, + conversation_id: request.conversation_id.clone(), + mcp_runtime, + allow_structured_alongside_pty: false, + }) + .await + .map_err(ErrorDto::from)?; + + if let Ok(mut contexts) = state.app.resume_contexts.lock() { + contexts.insert( + agent_id, + crate::state::ResumeContext { + project: resume_project, + rows: request.rows, + cols: request.cols, + }, + ); + } + + if let Some(profile) = output.profile.as_ref() { + state.app.arm_turn_watch( + &watch_root, + agent_id, + profile, + output.assigned_conversation_id.clone(), + ); + } + + if let Some(conversation) = request + .conversation_id + .as_deref() + .and_then(|raw| uuid::Uuid::parse_str(raw).ok()) + .map(domain::ConversationId::from_uuid) + { + let rotate = Arc::clone(&state.app.rotate_conversation_log); + tokio::spawn(async move { + let _ = rotate + .execute(RotateConversationLogInput { + project_root: rotation_root, + conversation, + }) + .await; + }); + } + + Ok(output) +} + async fn ws_attach_terminal( frame: &ClientFrame, state: &Arc, @@ -1649,7 +1771,14 @@ fn payload_session_id(payload: &Value) -> Option { #[cfg(test)] mod tests { use super::*; - use application::CreateProjectInput; + use application::{ + CreateAgentInput, CreateProjectInput, LaunchAgentOutput, SaveProfileInput, + StructuredSessionDescriptor, + }; + use domain::{ + AgentId, AgentProfile, ContextInjection, NodeId, ProfileId, ProjectPath, PtySize, + SessionKind, SessionStatus, SessionStrategy, TerminalSession, + }; use http::header::HeaderName; use std::time::Duration; @@ -1743,6 +1872,54 @@ mod tests { output.project.id.to_string() } + async fn create_raw_cli_agent_for_test( + state: &Arc, + name: &str, + ) -> (String, String) { + let project_id = create_project_for_test(state, name).await; + let project = resolve_project_readonly(&project_id, &state.app) + .await + .expect("test project resolves"); + let profile_id = ProfileId::from_uuid(Uuid::new_v4()); + let profile = AgentProfile::new( + profile_id, + format!("{name} shell profile"), + "/bin/sh", + vec![ + "-c".to_owned(), + "printf agent-ready; sleep 30".to_owned(), + "idea-test-sh".to_owned(), + ], + ContextInjection::env("IDEA_CONTEXT").expect("valid env injection"), + None, + "{agentRunDir}", + Some( + SessionStrategy::new(Some("--session-id".to_owned()), "--resume") + .expect("valid session strategy"), + ), + ) + .expect("valid raw CLI profile"); + state + .app + .save_profile + .execute(SaveProfileInput { profile }) + .await + .expect("test profile saved"); + let agent = state + .app + .create_agent + .execute(CreateAgentInput { + project, + name: format!("{name} agent"), + profile_id, + initial_content: Some("Test agent context".to_owned()), + }) + .await + .expect("test agent created") + .agent; + (project_id, agent.id.to_string()) + } + fn ws_headers(origin: &str, cookie: Option<&str>) -> HeaderMap { let mut headers = HeaderMap::new(); headers.insert(ORIGIN, HeaderValue::from_str(origin).unwrap()); @@ -1777,6 +1954,19 @@ mod tests { .expect("server frame channel is open") } + async fn recv_server_frame_where( + rx: &mut mpsc::Receiver, + mut predicate: impl FnMut(&ServerFrame) -> bool, + ) -> ServerFrame { + for _ in 0..16 { + let frame = recv_server_frame(rx).await; + if predicate(&frame) { + return frame; + } + } + panic!("matching server frame was not received"); + } + fn drain_server_frames(rx: &mut mpsc::Receiver) { while rx.try_recv().is_ok() {} } @@ -1809,6 +1999,22 @@ mod tests { }) } + fn agent_launch_payload( + project_id: &str, + agent_id: &str, + node_id: &str, + conversation_id: Option<&str>, + ) -> Value { + json!({ + "projectId": project_id, + "agentId": agent_id, + "nodeId": node_id, + "rows": 24, + "cols": 80, + "conversationId": conversation_id, + }) + } + async fn close_ws_session(state: &Arc, session_id: &str) { let (tx, mut rx) = mpsc::channel(8); let owned = Arc::new(Mutex::new(Vec::new())); @@ -2108,6 +2314,255 @@ mod tests { assert!(state.app.terminal_sessions.is_empty()); } + #[tokio::test] + async fn websocket_launch_agent_emits_attached_with_assigned_conversation_id() { + let state = state(); + let (project_id, agent_id) = create_raw_cli_agent_for_test(&state, "ws-launch").await; + let node_id = Uuid::new_v4().to_string(); + let (tx, mut rx) = mpsc::channel(32); + let owned = Arc::new(Mutex::new(Vec::new())); + + handle_client_frame( + ws_frame( + "agent-launch-1", + "agent.launch", + agent_launch_payload(&project_id, &agent_id, &node_id, None), + ), + &state, + &tx, + &owned, + ) + .await; + let attached = recv_server_frame(&mut rx).await; + let session_id = attached_session_id(&attached); + + assert_eq!(attached.kind, "terminal.attached"); + assert_eq!(attached.reply_to.as_deref(), Some("agent-launch-1")); + assert_eq!(attached.payload["scrollback"].as_array().unwrap().len(), 0); + assert!(attached.payload["assignedConversationId"] + .as_str() + .and_then(|raw| Uuid::parse_str(raw).ok()) + .is_some()); + + close_ws_session(&state, &session_id).await; + } + + #[tokio::test] + async fn websocket_launch_agent_structured_is_unsupported() { + let state = state(); + let (tx, mut rx) = mpsc::channel(4); + let owned = Arc::new(Mutex::new(Vec::new())); + let session_id = SessionId::new_random(); + let agent_id = AgentId::from_uuid(Uuid::new_v4()); + let node_id = NodeId::from_uuid(Uuid::new_v4()); + let size = PtySize::new(24, 80).unwrap(); + let mut session = TerminalSession::starting( + session_id, + node_id, + ProjectPath::new("/".to_owned()).unwrap(), + SessionKind::Agent { agent_id }, + size, + ); + session.status = SessionStatus::Running; + let frame = ws_frame("agent-structured", "agent.launch", json!({})); + let output = LaunchAgentOutput { + session, + assigned_conversation_id: Some("pair-conversation".to_owned()), + engine_session_id: Some("engine-session".to_owned()), + structured: Some(StructuredSessionDescriptor { + session_id, + agent_id, + node_id, + conversation_id: Some("engine-session".to_owned()), + }), + profile: None, + }; + + if let Err(err) = send_launch_agent_attached(&frame, &state, &tx, &owned, output).await { + tx.send(ServerFrame::error( + Some(frame.id), + payload_session_id(&frame.payload), + err.code, + err.message, + )) + .await + .unwrap(); + } + let error = recv_server_frame(&mut rx).await; + + assert_eq!(error.kind, "error"); + assert_eq!(error.reply_to.as_deref(), Some("agent-structured")); + assert_eq!(error.payload["code"], "UNSUPPORTED"); + assert_eq!( + error.payload["message"], + "structured agent sessions do not stream over the PTY websocket" + ); + assert_eq!(state.ws_pty_bridge.active_sessions(), 0); + } + + #[tokio::test] + async fn websocket_launch_agent_reattach_replays_scrollback_without_respawn() { + let state = state(); + let (project_id, agent_id) = create_raw_cli_agent_for_test(&state, "ws-reattach").await; + let node_id = Uuid::new_v4().to_string(); + let (tx, mut rx) = mpsc::channel(32); + let owned = Arc::new(Mutex::new(Vec::new())); + + handle_client_frame( + ws_frame( + "agent-launch-reattach", + "agent.launch", + agent_launch_payload(&project_id, &agent_id, &node_id, None), + ), + &state, + &tx, + &owned, + ) + .await; + let launched = recv_server_frame(&mut rx).await; + let session_id = attached_session_id(&launched); + let scrollback = wait_for_scrollback(&state, &session_id, b"agent-ready").await; + drain_server_frames(&mut rx); + + handle_client_frame( + ws_frame( + "agent-attach", + "terminal.attach", + json!({ "sessionId": session_id, "lastSeq": 0, "rows": 24, "cols": 80 }), + ), + &state, + &tx, + &owned, + ) + .await; + let attached = recv_server_frame_where(&mut rx, |frame| { + frame.kind == "terminal.attached" && frame.reply_to.as_deref() == Some("agent-attach") + }) + .await; + let replay = attached.payload["scrollback"].as_array().unwrap(); + + assert_eq!(attached.kind, "terminal.attached"); + assert_eq!(attached.payload["session"]["sessionId"], session_id); + assert_eq!( + base64_decode(replay[0]["bytesBase64"].as_str().unwrap()).unwrap(), + scrollback + ); + let aid = parse_agent_id(&agent_id).unwrap(); + assert_eq!( + state.app.terminal_sessions.sessions_for_agent(&aid).len(), + 1 + ); + + close_ws_session(&state, &session_id).await; + } + + #[tokio::test] + async fn websocket_launch_agent_same_cell_is_idempotent_singleton() { + let state = state(); + let (project_id, agent_id) = create_raw_cli_agent_for_test(&state, "ws-singleton").await; + let node_id = Uuid::new_v4().to_string(); + let (tx, mut rx) = mpsc::channel(32); + let owned = Arc::new(Mutex::new(Vec::new())); + + handle_client_frame( + ws_frame( + "agent-launch-first", + "agent.launch", + agent_launch_payload(&project_id, &agent_id, &node_id, None), + ), + &state, + &tx, + &owned, + ) + .await; + let first = recv_server_frame(&mut rx).await; + let first_session = attached_session_id(&first); + + handle_client_frame( + ws_frame( + "agent-launch-second", + "agent.launch", + agent_launch_payload( + &project_id, + &agent_id, + &node_id, + first.payload["assignedConversationId"].as_str(), + ), + ), + &state, + &tx, + &owned, + ) + .await; + let second = recv_server_frame_where(&mut rx, |frame| { + frame.kind == "terminal.attached" + && frame.reply_to.as_deref() == Some("agent-launch-second") + }) + .await; + + assert_eq!(second.kind, "terminal.attached"); + assert_eq!(attached_session_id(&second), first_session); + let aid = parse_agent_id(&agent_id).unwrap(); + assert_eq!( + state.app.terminal_sessions.sessions_for_agent(&aid).len(), + 1 + ); + + close_ws_session(&state, &first_session).await; + } + + #[tokio::test] + async fn websocket_launch_agent_different_cell_is_refused_by_singleton_guard() { + let state = state(); + let (project_id, agent_id) = + create_raw_cli_agent_for_test(&state, "ws-singleton-refuse").await; + let first_node = Uuid::new_v4().to_string(); + let second_node = Uuid::new_v4().to_string(); + let (tx, mut rx) = mpsc::channel(32); + let owned = Arc::new(Mutex::new(Vec::new())); + + handle_client_frame( + ws_frame( + "agent-launch-first-cell", + "agent.launch", + agent_launch_payload(&project_id, &agent_id, &first_node, None), + ), + &state, + &tx, + &owned, + ) + .await; + let first = recv_server_frame(&mut rx).await; + let session_id = attached_session_id(&first); + + handle_client_frame( + ws_frame( + "agent-launch-other-cell", + "agent.launch", + agent_launch_payload(&project_id, &agent_id, &second_node, None), + ), + &state, + &tx, + &owned, + ) + .await; + let error = recv_server_frame_where(&mut rx, |frame| { + frame.kind == "error" && frame.reply_to.as_deref() == Some("agent-launch-other-cell") + }) + .await; + + assert_eq!(error.kind, "error"); + assert_eq!(error.reply_to.as_deref(), Some("agent-launch-other-cell")); + assert_eq!(error.payload["code"], "AGENT_ALREADY_RUNNING"); + let aid = parse_agent_id(&agent_id).unwrap(); + assert_eq!( + state.app.terminal_sessions.sessions_for_agent(&aid).len(), + 1 + ); + + close_ws_session(&state, &session_id).await; + } + #[test] fn websocket_client_unmasked_frame_is_rejected() { let raw = [0x81, 0x02, b'h', b'i']; From 5254f16095b93bc5a5dbdd683b53b9815cf60c75 Mon Sep 17 00:00:00 2001 From: Blomios Date: Wed, 15 Jul 2026 23:17:24 +0200 Subject: [PATCH 04/12] feat(frontend): surface agent web sur WebSocket (cellule agent) (#13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lot F4 du chantier server/client mode : le client web expose une cellule agent branchée sur le PTY WebSocket, en face de agent.launch (B6). - wsLiveClient.ts : launchAgent sur le transport WebSocket. - streamGateways.ts : gateway agent alignée sur le contrat serveur B6. - WebAgentCell.tsx : cellule agent web, câblée dans WebWorkspace et index. - Tests : agentGateway.test.ts, WebApp.test.tsx. Validé : frontend 749 tests verts, contrat B6↔F4 aligné (aucun écart de frame), desktop non régressé. Co-Authored-By: Claude Opus 4.8 --- .../src/adapters/http/agentGateway.test.ts | 172 ++++++++++++++++++ frontend/src/adapters/http/streamGateways.ts | 43 ++--- frontend/src/adapters/http/wsLiveClient.ts | 32 ++++ frontend/src/features/web/WebAgentCell.tsx | 76 ++++++++ frontend/src/features/web/WebApp.test.tsx | 15 ++ frontend/src/features/web/WebWorkspace.tsx | 52 +++++- frontend/src/features/web/index.ts | 1 + 7 files changed, 359 insertions(+), 32 deletions(-) create mode 100644 frontend/src/adapters/http/agentGateway.test.ts create mode 100644 frontend/src/features/web/WebAgentCell.tsx diff --git a/frontend/src/adapters/http/agentGateway.test.ts b/frontend/src/adapters/http/agentGateway.test.ts new file mode 100644 index 0000000..948864d --- /dev/null +++ b/frontend/src/adapters/http/agentGateway.test.ts @@ -0,0 +1,172 @@ +/** + * F4 — the WS agent gateway round-trip against the B6 frame contract: + * `agent.launch` → unified `terminal.attached` (sessionId + assignedConversationId + * consumed), then output/input/resize reuse the terminal mechanics; agent + * re-attach uses `terminal.attach` (no relaunch) and returns scrollback; a + * structured agent (`UNSUPPORTED`) and a dead session (`NOT_FOUND`) reject clearly. + */ +import { describe, it, expect, vi } from "vitest"; + +import { HttpAgentGateway } from "./streamGateways"; +import { HttpInvoker } from "./httpInvoker"; +import { WsLiveClient, type WebSocketLike } from "./wsLiveClient"; +import { bytesToBase64 } from "./frames"; + +class FakeSocket implements WebSocketLike { + sent: string[] = []; + onopen: (() => void) | null = null; + onmessage: ((event: { data: string }) => void) | null = null; + onerror: ((event: unknown) => void) | null = null; + onclose: (() => void) | null = null; + send(data: string): void { + this.sent.push(data); + } + close(): void { + this.onclose?.(); + } + receive(frame: unknown): void { + this.onmessage?.({ data: JSON.stringify(frame) }); + } +} + +function gateway(): { gw: HttpAgentGateway; sockets: FakeSocket[] } { + const sockets: FakeSocket[] = []; + const ws = new WsLiveClient({ + wsUrl: "wss://host", + socketFactory: () => { + const s = new FakeSocket(); + sockets.push(s); + queueMicrotask(() => s.onopen?.()); + return s; + }, + }); + // The HTTP invoker is unused by the WS paths under test. + const gw = new HttpAgentGateway(new HttpInvoker({ baseUrl: "https://host" }), ws); + return { gw, sockets }; +} + +async function replyToLast( + socket: FakeSocket, + index: number, + reply: (id: string) => unknown, +): Promise> { + await vi.waitFor(() => expect(socket.sent.length).toBeGreaterThan(index)); + const sent = JSON.parse(socket.sent[index]); + socket.receive(reply(sent.id)); + return sent; +} + +function attachedAck( + id: string, + sessionId: string, + opts: { scroll?: Uint8Array; assignedConversationId?: string } = {}, +) { + return { + kind: "terminal.attached", + replyTo: id, + payload: { + session: { sessionId, rows: 24, cols: 80 }, + scrollback: opts.scroll ? [{ seq: 0, bytesBase64: bytesToBase64(opts.scroll) }] : [], + nextSeq: opts.scroll ? 1 : 0, + status: "running", + gap: false, + assignedConversationId: opts.assignedConversationId, + }, + }; +} + +const OPTS = { cwd: "/srv/app", rows: 24, cols: 80, nodeId: "node-1" }; + +describe("HttpAgentGateway WS round-trip (B6 frames)", () => { + it("launch → attached: conforming agent.launch frame, assignedConversationId consumed", async () => { + const { gw, sockets } = gateway(); + const chunks: Uint8Array[] = []; + + const handlePromise = gw.launchAgent("proj-1", "agent-1", { ...OPTS, conversationId: "resume-9" }, (b) => + chunks.push(b), + ); + const sent = await replyToLast(sockets[0], 0, (id) => + attachedAck(id, "sess-1", { assignedConversationId: "conv-1" }), + ); + const handle = await handlePromise; + + // B6 payload: flat camelCase, no cwd (agent launch resolves the project). + expect(sent.kind).toBe("agent.launch"); + expect(sent.payload).toEqual({ + projectId: "proj-1", + agentId: "agent-1", + nodeId: "node-1", + rows: 24, + cols: 80, + conversationId: "resume-9", + }); + expect(handle.sessionId).toBe("sess-1"); + // The conversation id minted by the launch is surfaced on the handle. + expect(handle.assignedConversationId).toBe("conv-1"); + // Fresh launch: nothing repainted. + expect(chunks).toHaveLength(0); + + // Output/input/resize reuse the terminal mechanics. + sockets[0].receive({ + kind: "terminal.output", + payload: { sessionId: "sess-1", seq: 1, bytesBase64: bytesToBase64(new Uint8Array([65])) }, + }); + expect(chunks).toEqual([new Uint8Array([65])]); + + const base = sockets[0].sent.length; + await handle.write(new Uint8Array([13])); + await handle.resize(30, 100); + expect(JSON.parse(sockets[0].sent[base]).kind).toBe("terminal.input"); + expect(JSON.parse(sockets[0].sent[base + 1])).toMatchObject({ + kind: "terminal.resize", + payload: { sessionId: "sess-1", rows: 30, cols: 100 }, + }); + }); + + it("omits assignedConversationId on the handle when the launch minted none", async () => { + const { gw, sockets } = gateway(); + const handlePromise = gw.launchAgent("p", "a", OPTS, () => {}); + await replyToLast(sockets[0], 0, (id) => attachedAck(id, "sess-x")); + const handle = await handlePromise; + expect(handle.assignedConversationId).toBeUndefined(); + }); + + it("re-attaches to an existing agent via terminal.attach (no relaunch) and returns scrollback", async () => { + const { gw, sockets } = gateway(); + const chunks: Uint8Array[] = []; + const scroll = new Uint8Array([104, 105]); + + const resPromise = gw.reattach("sess-7", (b) => chunks.push(b)); + const sent = await replyToLast(sockets[0], 0, (id) => attachedAck(id, "sess-7", { scroll })); + const res = await resPromise; + + // Re-attach must NOT relaunch: the frame is terminal.attach, not agent.launch. + expect(sent.kind).toBe("terminal.attach"); + expect(sent.payload).toMatchObject({ sessionId: "sess-7" }); + expect(res.scrollback).toEqual(scroll); + // Scrollback returned for the view to repaint, not double-pushed via onData. + expect(chunks).toHaveLength(0); + }); + + it("rejects a structured agent with UNSUPPORTED (no crash)", async () => { + const { gw, sockets } = gateway(); + const handlePromise = gw.launchAgent("p", "a", OPTS, () => {}); + await replyToLast(sockets[0], 0, (id) => ({ + kind: "error", + replyTo: id, + payload: { code: "UNSUPPORTED", message: "structured agent sessions do not stream over the PTY websocket" }, + })); + await expect(handlePromise).rejects.toMatchObject({ code: "UNSUPPORTED" }); + }); + + it("rejects re-attach to a missing/exited session with NOT_FOUND", async () => { + const { gw, sockets } = gateway(); + const resPromise = gw.reattach("gone", () => {}); + await replyToLast(sockets[0], 0, (id) => ({ + kind: "error", + replyTo: id, + payload: { code: "NOT_FOUND", message: "terminal session not found" }, + })); + await expect(resPromise).rejects.toMatchObject({ code: "NOT_FOUND" }); + }); +}); diff --git a/frontend/src/adapters/http/streamGateways.ts b/frontend/src/adapters/http/streamGateways.ts index 61ecd13..e3474bf 100644 --- a/frontend/src/adapters/http/streamGateways.ts +++ b/frontend/src/adapters/http/streamGateways.ts @@ -52,22 +52,9 @@ import type { } from "@/ports"; import type { HttpInvoker } from "./httpInvoker"; import { WsLiveClient } from "./wsLiveClient"; -import { base64ToBytes, type AttachedPayload, type ChatOutputPayload } from "./frames"; +import { type ChatOutputPayload } from "./frames"; import { unsupportedOnWeb } from "./unsupported"; -/** Concatenates a scrollback frame list into a single byte buffer. */ -function attachedToScrollback(payload: AttachedPayload): Uint8Array { - const chunks = payload.scrollback.map((c) => base64ToBytes(c.bytesBase64)); - const total = chunks.reduce((n, c) => n + c.length, 0); - const out = new Uint8Array(total); - let offset = 0; - for (const c of chunks) { - out.set(c, offset); - offset += c.length; - } - return out; -} - /** * Builds a {@link TerminalHandle} whose control operations are WS frames. The * output stream is delivered through the sink the gateway registered on the @@ -235,34 +222,34 @@ export class HttpAgentGateway implements AgentGateway { options: OpenTerminalOptions, onData: (bytes: Uint8Array) => void, ): Promise { - // Raw-CLI agents stream over the same WS PTY channel (B0). Structured - // sessions do not use this channel — that branch is B6. - const ack = await this.ws.send("agent.launch", { + // B6: a raw-CLI agent streams over the same PTY channel as a terminal. The + // WS client tracks the session (reconnection/replay/status parity with F3); + // the unified `terminal.attached` ack yields the sessionId + the conversation + // id minted by this launch. Structured agents are refused server-side with + // `UNSUPPORTED` — the rejection propagates to the caller (a clear cell error, + // no crash). A fresh launch has empty scrollback (no repaint on open). + const res = await this.ws.launchAgent({ projectId, agentId, nodeId: options.nodeId ?? null, rows: options.rows, cols: options.cols, conversationId: options.conversationId ?? null, + onData, }); - const payload = ack.payload as unknown as AttachedPayload; - const sessionId = payload.session.sessionId; - this.ws.setOutputSink(sessionId, onData); - const scrollback = attachedToScrollback(payload); - if (scrollback.length > 0) onData(scrollback); - return makeWsTerminalHandle(sessionId, this.ws, payload.assignedConversationId); + return makeWsTerminalHandle(res.sessionId, this.ws, res.assignedConversationId); } async reattach( sessionId: string, onData: (bytes: Uint8Array) => void, ): Promise { - const ack = await this.ws.send("terminal.attach", { sessionId, lastSeq: null }); - const payload = ack.payload as unknown as AttachedPayload; - this.ws.setOutputSink(sessionId, onData); + // Agent sessions re-attach through the same `terminal.attach` mechanics as a + // terminal — no relaunch, bounded scrollback replayed for the view to repaint. + const res = await this.ws.attachTerminal({ sessionId, onData }); return { - handle: makeWsTerminalHandle(sessionId, this.ws), - scrollback: attachedToScrollback(payload), + handle: makeWsTerminalHandle(res.sessionId, this.ws), + scrollback: res.scrollback, }; } } diff --git a/frontend/src/adapters/http/wsLiveClient.ts b/frontend/src/adapters/http/wsLiveClient.ts index a5e5f07..92d1d9c 100644 --- a/frontend/src/adapters/http/wsLiveClient.ts +++ b/frontend/src/adapters/http/wsLiveClient.ts @@ -340,6 +340,38 @@ export class WsLiveClient { ); } + /** + * Launches a CLI agent (`agent.launch`) over the same PTY channel and tracks + * it exactly like a terminal (B6): the ack is a unified `terminal.attached` + * carrying `sessionId` + `assignedConversationId`, then output/input/resize and + * reconnection replay reuse the terminal mechanics. Structured agents are + * refused server-side with `UNSUPPORTED` (this channel is PTY-only); the + * rejected `send` surfaces that error to the caller unchanged. + */ + launchAgent(params: { + projectId: string; + agentId: string; + rows: number; + cols: number; + nodeId?: string | null; + conversationId?: string | null; + onData: (bytes: Uint8Array) => void; + onStatus?: (status: string, exitCode?: number | null) => void; + }): Promise { + return this.attachInternal( + "agent.launch", + { + projectId: params.projectId, + agentId: params.agentId, + nodeId: params.nodeId ?? null, + rows: params.rows, + cols: params.cols, + conversationId: params.conversationId ?? null, + }, + { onData: params.onData, onStatus: params.onStatus, lastSeq: 0 }, + ); + } + /** Re-attaches to an existing terminal (`terminal.attach`) and tracks it. */ attachTerminal(params: { sessionId: string; diff --git a/frontend/src/features/web/WebAgentCell.tsx b/frontend/src/features/web/WebAgentCell.tsx new file mode 100644 index 0000000..992c628 --- /dev/null +++ b/frontend/src/features/web/WebAgentCell.tsx @@ -0,0 +1,76 @@ +/** + * Web agent cell — ticket #13, lot F4. Thin wiring (no new terminal logic) that + * reuses the existing, transport-neutral {@link TerminalView} to render a CLI + * agent over the WebSocket in web mode. + * + * The agent's CLI runs server-side (B6); the browser is display-only. This + * component just supplies `TerminalView` with the DI **agent gateway** as the + * opener/reattacher: + * - `open` → `agent.launchAgent(projectId, agentId, …)` (frame `agent.launch`, + * unified `terminal.attached` ack; the minted conversation id is carried on the + * returned handle), + * - `reattach` → `agent.reattach(sessionId, …)` (frame `terminal.attach`, no + * relaunch, bounded scrollback repainted) — so a browser reload/reconnect + * resumes the surviving server-side PTY. + * + * The session id is persisted in component state so a re-mount re-attaches rather + * than relaunching, matching the desktop cell's lifecycle. A structured agent is + * refused server-side (`UNSUPPORTED`) and surfaced by `TerminalView`'s own error + * banner — no crash. It touches only gateways via DI (no `@tauri-apps/api`). + */ + +import { useCallback, useState } from "react"; + +import type { + OpenTerminalOptions, + ReattachResult, + TerminalHandle, +} from "@/ports"; +import { useGateways } from "@/app/di"; +import { TerminalView } from "@/features/terminals"; + +interface WebAgentCellProps { + /** Owning project id (resolved server-side by `agent.launch`). */ + projectId: string; + /** Agent to launch/attach. */ + agentId: string; + /** Working directory for the cell (typically the project root). */ + cwd: string; + /** Layout leaf id, when the caller tracks one (drives the singleton guard). */ + nodeId?: string; +} + +export function WebAgentCell({ projectId, agentId, cwd, nodeId }: WebAgentCellProps) { + const { agent } = useGateways(); + const [sessionId, setSessionId] = useState(null); + + const open = useCallback( + (options: OpenTerminalOptions, onData: (bytes: Uint8Array) => void): Promise => + agent.launchAgent( + projectId, + agentId, + nodeId ? { ...options, nodeId } : options, + onData, + ), + [agent, projectId, agentId, nodeId], + ); + + const reattach = useCallback( + (sid: string, onData: (bytes: Uint8Array) => void): Promise => + agent.reattach(sid, onData), + [agent], + ); + + return ( +
+ +
+ ); +} diff --git a/frontend/src/features/web/WebApp.test.tsx b/frontend/src/features/web/WebApp.test.tsx index 7ae53ef..f4d3491 100644 --- a/frontend/src/features/web/WebApp.test.tsx +++ b/frontend/src/features/web/WebApp.test.tsx @@ -105,6 +105,21 @@ describe("WebApp pairing routing", () => { expect(snapshot.textContent).toContain("idle"); }); + it("opens a live agent cell for a work-state agent (F4 affordance)", async () => { + const session = new WebSession({ baseUrl: "https://h", fetchImpl: okFetch, store: memStore() }); + session.markPaired(); + renderWebApp(session, await seededGateways()); + + fireEvent.click(await screen.findByText("Demo")); + await screen.findByTestId("web-workstate"); + + // The per-agent "Ouvrir" affordance mounts the reusable TerminalView cell, + // wired to the DI agent gateway (the CLI runs server-side). + expect(screen.queryByTestId("web-agent-cell")).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Ouvrir" })); + expect(await screen.findByTestId("web-agent-cell")).toBeTruthy(); + }); + it("returns to pairing when the session reports unauthorized (401)", async () => { const session = new WebSession({ baseUrl: "https://h", fetchImpl: okFetch, store: memStore() }); session.markPaired(); diff --git a/frontend/src/features/web/WebWorkspace.tsx b/frontend/src/features/web/WebWorkspace.tsx index c376f1e..7797cf1 100644 --- a/frontend/src/features/web/WebWorkspace.tsx +++ b/frontend/src/features/web/WebWorkspace.tsx @@ -17,6 +17,7 @@ import { useCallback, useEffect, useState } from "react"; import type { GatewayError, Project, ProjectWorkState } from "@/domain"; import { useGateways } from "@/app/di"; import { Button, Panel, Spinner } from "@/shared"; +import { WebAgentCell } from "./WebAgentCell"; function describe(e: unknown): string { if (e && typeof e === "object" && "message" in e) { @@ -32,6 +33,10 @@ export function WebWorkspace() { const [openId, setOpenId] = useState(null); const [snapshot, setSnapshot] = useState(null); const [loadingSnapshot, setLoadingSnapshot] = useState(false); + // The agent currently opened in a live cell (CLI streamed over the WS), if any. + const [openAgentId, setOpenAgentId] = useState(null); + + const openRoot = projects?.find((p) => p.id === openId)?.root ?? null; const refresh = useCallback(async () => { setError(null); @@ -52,6 +57,7 @@ export function WebWorkspace() { setLoadingSnapshot(true); setOpenId(projectId); setSnapshot(null); + setOpenAgentId(null); try { // Read-only: open resolves the project server-side, then we read the // live/work-state snapshot. No layout/agents/PTY are mounted. @@ -117,7 +123,11 @@ export function WebWorkspace() { {loadingSnapshot && } {snapshot ? ( - + ) : loadingSnapshot ? (

Chargement de l'état…

) : ( @@ -125,19 +135,45 @@ export function WebWorkspace() { )} )} + + {openId && openRoot && openAgentId && ( + +
+

Agent

+ +
+ {/* Re-mount the cell per agent so a switch relaunches/reattaches cleanly. */} + +
+ )} ); } -/** Pure render of the read-only work-state snapshot. */ -function WorkStateSnapshot({ snapshot }: { snapshot: ProjectWorkState }) { +/** Pure render of the read-only work-state snapshot + per-agent "open" affordance. */ +function WorkStateSnapshot({ + snapshot, + openAgentId, + onOpenAgent, +}: { + snapshot: ProjectWorkState; + openAgentId: string | null; + onOpenAgent: (agentId: string) => void; +}) { if (snapshot.agents.length === 0) { return

Aucun agent actif.

; } return (
    {snapshot.agents.map((a) => ( -
  • +
  • {a.name} {a.live ? `live · ${a.live.kind}` : "offline"} @@ -148,6 +184,14 @@ function WorkStateSnapshot({ snapshot }: { snapshot: ProjectWorkState }) { > {a.busy.state === "busy" ? "busy" : "idle"} +
  • ))} diff --git a/frontend/src/features/web/index.ts b/frontend/src/features/web/index.ts index c15e2c5..ad46ed4 100644 --- a/frontend/src/features/web/index.ts +++ b/frontend/src/features/web/index.ts @@ -6,3 +6,4 @@ export { WebApp } from "./WebApp"; export { PairingScreen } from "./PairingScreen"; export { WebWorkspace } from "./WebWorkspace"; +export { WebAgentCell } from "./WebAgentCell"; From 1bc5217dbc724fcfa212475e2e0f27146066964c Mon Sep 17 00:00:00 2001 From: Blomios Date: Wed, 15 Jul 2026 23:36:50 +0200 Subject: [PATCH 05/12] feat(server): relais live-state + actions background via web (event.domain, allowlist) (#13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lot B7 du chantier server/client mode : le serveur --serve relaie l'état live et les tâches de fond au client web. - Relais du bus domaine en frames event.domain, queue bornée avec drop, exclusion de PtyOutput. - Allowlist étendue : list_background_tasks (read) + cancel_background_task / retry_background_task (actions), sous auth. - 49 tests server (fan-out, drop sur queue pleine, relais de complétion background, exclusion PtyOutput, actions allowlist + auth). Clippy propre. Validé : app-tauri 292+ tests verts, backend 28, cœur backend agnostique, desktop non régressé. Réserve connue : le fan-out socket réel relève d'une validation live hors sandbox. Co-Authored-By: Claude Opus 4.8 --- crates/app-tauri/src/server.rs | 386 ++++++++++++++++++++++++++++++++- 1 file changed, 378 insertions(+), 8 deletions(-) diff --git a/crates/app-tauri/src/server.rs b/crates/app-tauri/src/server.rs index 8a4c62a..7f71c5a 100644 --- a/crates/app-tauri/src/server.rs +++ b/crates/app-tauri/src/server.rs @@ -1,8 +1,7 @@ //! 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. +//! The shared backend core stays unaware of HTTP, cookies, origins and +//! WebSocket framing; this module owns the secure web driving adapter. use std::collections::HashSet; use std::env; @@ -15,6 +14,8 @@ use std::sync::{Arc, Mutex}; use backend::stream::{OutputBridge, OutputSink, OutputSinkError}; use bytes::Bytes; use cookie::{Cookie, SameSite}; +use domain::events::DomainEvent; +use domain::ports::BackgroundTaskPortError; use http::header::{HeaderValue, CONTENT_TYPE, COOKIE, ORIGIN, SET_COOKIE}; #[cfg(test)] use http::Request; @@ -35,10 +36,11 @@ use domain::ports::PtyHandle; use domain::{Project, SessionId}; use crate::dto::{ - parse_agent_id, parse_node_id, parse_project_id, parse_session_id, ErrorDto, HealthRequestDto, - HealthResponseDto, LaunchAgentRequestDto, OpenTerminalRequestDto, ProjectDto, ProjectListDto, - ProjectWorkStateDto, TerminalSessionDto, + parse_agent_id, parse_node_id, parse_project_id, parse_session_id, parse_task_id, + BackgroundTaskDto, ErrorDto, HealthRequestDto, HealthResponseDto, LaunchAgentRequestDto, + OpenTerminalRequestDto, ProjectDto, ProjectListDto, ProjectWorkStateDto, TerminalSessionDto, }; +use crate::events::DomainEventDto; use crate::pty::PtyChunk; use crate::state::AppState; @@ -501,6 +503,8 @@ async fn run_ws_connection( let (mut reader, mut writer) = stream.into_split(); let (tx, mut rx) = mpsc::channel::(WS_OUTPUT_BUFFER); let owned = Arc::new(Mutex::new(Vec::<(SessionId, u64)>::new())); + let event_relay_task = + spawn_ws_domain_event_relay(state.app.event_bus.raw_receiver(), tx.clone()); let writer_task = tokio::spawn(async move { while let Some(frame) = rx.recv().await { let text = serde_json::to_vec(&frame) @@ -567,12 +571,38 @@ async fn run_ws_connection( state.ws_pty_bridge.unregister_if(session, *gen); } } + event_relay_task.abort(); drop(tx); writer_task .await .map_err(|err| format!("websocket writer task failed: {err}"))? } +fn spawn_ws_domain_event_relay( + mut rx: tokio::sync::broadcast::Receiver, + tx: mpsc::Sender, +) -> tokio::task::JoinHandle<()> { + use tokio::sync::broadcast::error::RecvError; + + tokio::spawn(async move { + loop { + match rx.recv().await { + Ok(event) => { + if matches!(event, DomainEvent::PtyOutput { .. }) { + continue; + } + match tx.try_send(ServerFrame::domain_event(&event)) { + Ok(()) | Err(mpsc::error::TrySendError::Full(_)) => {} + Err(mpsc::error::TrySendError::Closed(_)) => break, + } + } + Err(RecvError::Lagged(_)) => continue, + Err(RecvError::Closed) => break, + } + } + }) +} + async fn handle_client_frame( frame: ClientFrame, state: &Arc, @@ -1039,6 +1069,9 @@ async fn invoke( "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, + "list_background_tasks" => invoke_list_background_tasks(&request.args, &state.app).await, + "cancel_background_task" => invoke_cancel_background_task(&request.args, &state.app).await, + "retry_background_task" => invoke_retry_background_task(&request.args, &state.app).await, _ => Err(ErrorDto { code: "UNKNOWN_COMMAND".to_owned(), message: format!("unknown command: {}", request.command), @@ -1101,6 +1134,128 @@ async fn invoke_get_project_work_state(args: &Value, state: &AppState) -> Result serde_json::to_value(output).map_err(serialization_error) } +async fn invoke_list_background_tasks(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: "list_background_tasks requires args.projectId".to_owned(), + }) + .and_then(parse_project_id)?; + let agent_id = args + .get("agentId") + .and_then(Value::as_str) + .map(parse_agent_id) + .transpose()?; + + let mut by_id = std::collections::HashMap::::new(); + if let Some(agent_id) = agent_id { + for task in state + .background_task_store + .list_open_for_agent(agent_id) + .await + .map_err(background_error)? + { + by_id.insert(task.id, task); + } + } + for task in state + .background_task_store + .list_undelivered_completions() + .await + .map_err(background_error)? + { + by_id.entry(task.id).or_insert(task); + } + + let mut tasks = by_id + .into_values() + .filter(|task| task.project_id == project_id) + .filter(|task| agent_id.map_or(true, |agent_id| task.owner_agent_id == agent_id)) + .collect::>(); + tasks.sort_by_key(|task| (task.created_at_ms, task.id)); + let dto = tasks + .into_iter() + .map(BackgroundTaskDto::from) + .collect::>(); + serde_json::to_value(dto).map_err(serialization_error) +} + +async fn invoke_cancel_background_task(args: &Value, state: &AppState) -> Result { + let task_id = task_id_arg(args, "cancel_background_task")?; + verify_background_task_project(args, state, task_id).await?; + let output = state + .cancel_background_task + .execute(task_id) + .await + .map_err(ErrorDto::from)?; + serde_json::to_value(output.task.map(BackgroundTaskDto::from)).map_err(serialization_error) +} + +async fn invoke_retry_background_task(args: &Value, state: &AppState) -> Result { + let task_id = task_id_arg(args, "retry_background_task")?; + verify_background_task_project(args, state, task_id).await?; + let output = state + .retry_background_task + .execute(task_id) + .await + .map_err(ErrorDto::from)?; + serde_json::to_value(BackgroundTaskDto::from(output.task)).map_err(serialization_error) +} + +fn task_id_arg(args: &Value, command: &str) -> Result { + args.get("taskId") + .and_then(Value::as_str) + .ok_or_else(|| ErrorDto { + code: "INVALID".to_owned(), + message: format!("{command} requires args.taskId"), + }) + .and_then(parse_task_id) +} + +async fn verify_background_task_project( + args: &Value, + state: &AppState, + task_id: domain::TaskId, +) -> Result<(), ErrorDto> { + let Some(project_id) = args.get("projectId").and_then(Value::as_str) else { + return Ok(()); + }; + let project_id = parse_project_id(project_id)?; + let Some(task) = state + .background_task_store + .get(task_id) + .await + .map_err(background_error)? + else { + return Err(ErrorDto { + code: "NOT_FOUND".to_owned(), + message: format!("background task not found: {task_id}"), + }); + }; + if task.project_id != project_id { + return Err(ErrorDto { + code: "FORBIDDEN".to_owned(), + message: "background task does not belong to requested project".to_owned(), + }); + } + Ok(()) +} + +fn background_error(err: BackgroundTaskPortError) -> ErrorDto { + let code = match &err { + BackgroundTaskPortError::NotFound => "NOT_FOUND", + BackgroundTaskPortError::AlreadyExists | BackgroundTaskPortError::Invalid(_) => "INVALID", + BackgroundTaskPortError::Runner(_) => "PROCESS", + BackgroundTaskPortError::Store(_) => "STORE", + }; + ErrorDto { + code: code.to_owned(), + message: err.to_string(), + } +} + async fn resolve_project_readonly(project_id: &str, state: &AppState) -> Result { let id = parse_project_id(project_id)?; state @@ -1407,6 +1562,15 @@ impl ServerFrame { } } + fn domain_event(event: &DomainEvent) -> Self { + Self { + kind: "event.domain".to_owned(), + reply_to: None, + payload: serde_json::to_value(DomainEventDto::from(event)) + .expect("DomainEventDto serializes"), + } + } + fn error( request_id: Option, session_id: Option, @@ -1775,9 +1939,11 @@ mod tests { CreateAgentInput, CreateProjectInput, LaunchAgentOutput, SaveProfileInput, StructuredSessionDescriptor, }; + use domain::ports::EventBus; use domain::{ - AgentId, AgentProfile, ContextInjection, NodeId, ProfileId, ProjectPath, PtySize, - SessionKind, SessionStatus, SessionStrategy, TerminalSession, + AgentId, AgentProfile, BackgroundTask, BackgroundTaskKind, BackgroundTaskWakePolicy, + ContextInjection, NodeId, ProfileId, ProjectId, ProjectPath, PtySize, SessionKind, + SessionStatus, SessionStrategy, TaskId, TerminalSession, }; use http::header::HeaderName; use std::time::Duration; @@ -1872,6 +2038,35 @@ mod tests { output.project.id.to_string() } + async fn create_background_task_for_test( + state: &Arc, + project_id: &str, + label: &str, + ) -> (TaskId, AgentId) { + let task_id = TaskId::from_uuid(Uuid::new_v4()); + let owner = AgentId::from_uuid(Uuid::new_v4()); + let project_id = ProjectId::from_uuid(Uuid::parse_str(project_id).unwrap()); + let task = BackgroundTask::new( + task_id, + project_id, + owner, + BackgroundTaskKind::Command { + label: label.to_owned(), + }, + BackgroundTaskWakePolicy::WakeOwner, + 1_000, + None, + ) + .expect("test background task is valid"); + state + .app + .background_task_store + .create(&task) + .await + .expect("test background task is stored"); + (task_id, owner) + } + async fn create_raw_cli_agent_for_test( state: &Arc, name: &str, @@ -2186,6 +2381,125 @@ mod tests { assert!(frame.payload.as_object().unwrap().is_empty()); } + #[test] + fn websocket_domain_event_frame_uses_contract_shape() { + let project_id = ProjectId::from_uuid(Uuid::new_v4()); + let frame = ServerFrame::domain_event(&DomainEvent::ProjectCreated { project_id }); + + assert_eq!(frame.kind, "event.domain"); + assert!(frame.reply_to.is_none()); + assert_eq!(frame.payload["type"], "projectCreated"); + assert_eq!(frame.payload["projectId"], project_id.to_string()); + } + + #[tokio::test] + async fn websocket_domain_event_relay_forwards_domain_events() { + let state = state(); + let project_id = ProjectId::from_uuid(Uuid::new_v4()); + let event_rx = state.app.event_bus.raw_receiver(); + let (tx, mut rx) = mpsc::channel(4); + let relay = spawn_ws_domain_event_relay(event_rx, tx); + + state + .app + .event_bus + .publish(DomainEvent::ProjectCreated { project_id }); + let frame = tokio::time::timeout(Duration::from_secs(1), rx.recv()) + .await + .expect("event frame is forwarded") + .expect("event frame channel is open"); + relay.abort(); + + assert_eq!(frame.kind, "event.domain"); + assert_eq!(frame.payload["type"], "projectCreated"); + assert_eq!(frame.payload["projectId"], project_id.to_string()); + } + + #[tokio::test] + async fn websocket_domain_event_relay_forwards_background_completion() { + let state = state(); + let project_id = ProjectId::from_uuid(Uuid::new_v4()); + let task_id = TaskId::from_uuid(Uuid::new_v4()); + let owner = AgentId::from_uuid(Uuid::new_v4()); + let event_rx = state.app.event_bus.raw_receiver(); + let (tx, mut rx) = mpsc::channel(4); + let relay = spawn_ws_domain_event_relay(event_rx, tx); + + state + .app + .event_bus + .publish(DomainEvent::BackgroundTaskCompleted { + project_id, + task_id, + owner_agent_id: owner, + }); + let frame = tokio::time::timeout(Duration::from_secs(1), rx.recv()) + .await + .expect("background completion event is forwarded") + .expect("event frame channel is open"); + relay.abort(); + + assert_eq!(frame.kind, "event.domain"); + assert_eq!(frame.payload["type"], "backgroundTaskChanged"); + assert_eq!(frame.payload["projectId"], project_id.to_string()); + assert_eq!(frame.payload["taskId"], task_id.to_string()); + assert_eq!(frame.payload["agentId"], owner.to_string()); + assert_eq!(frame.payload["state"], "completed"); + } + + #[tokio::test] + async fn websocket_domain_event_relay_excludes_pty_output() { + let state = state(); + let project_id = ProjectId::from_uuid(Uuid::new_v4()); + let event_rx = state.app.event_bus.raw_receiver(); + let (tx, mut rx) = mpsc::channel(4); + let relay = spawn_ws_domain_event_relay(event_rx, tx); + + state.app.event_bus.publish(DomainEvent::PtyOutput { + session_id: SessionId::new_random(), + bytes: b"hidden from global live bus".to_vec(), + }); + state + .app + .event_bus + .publish(DomainEvent::ProjectCreated { project_id }); + let frame = tokio::time::timeout(Duration::from_secs(1), rx.recv()) + .await + .expect("sentinel event is forwarded") + .expect("event frame channel is open"); + relay.abort(); + + assert_eq!(frame.kind, "event.domain"); + assert_eq!(frame.payload["type"], "projectCreated"); + assert_eq!(frame.payload["projectId"], project_id.to_string()); + assert!( + rx.try_recv().is_err(), + "PtyOutput must not be emitted on the global event.domain stream" + ); + } + + #[tokio::test] + async fn websocket_domain_event_relay_drops_when_client_queue_is_full() { + let state = state(); + let event_rx = state.app.event_bus.raw_receiver(); + let (tx, mut rx) = mpsc::channel(1); + assert!(tx.try_send(ServerFrame::pong()).is_ok()); + let relay = spawn_ws_domain_event_relay(event_rx, tx); + + state.app.event_bus.publish(DomainEvent::ProjectCreated { + project_id: ProjectId::from_uuid(Uuid::new_v4()), + }); + tokio::time::sleep(Duration::from_millis(20)).await; + let first = rx.try_recv().expect("pre-filled frame remains queued"); + relay.abort(); + + assert_eq!(first.kind, "pong"); + assert!( + rx.try_recv().is_err(), + "full client queue should drop the live event instead of buffering" + ); + } + #[tokio::test] async fn websocket_open_terminal_emits_attached_with_empty_scrollback() { let state = state(); @@ -2932,6 +3246,62 @@ mod tests { assert!(body["conversations"].as_array().is_some()); } + #[tokio::test] + async fn authorized_list_background_tasks_returns_tauri_contract() { + let state = state(); + let project_id = create_project_for_test(&state, "Background Snapshot").await; + let (task_id, owner) = + create_background_task_for_test(&state, &project_id, "web background").await; + let cookie = pair_and_cookie(Arc::clone(&state)).await; + + let response = request( + state, + Method::POST, + "/api/invoke", + json!({ + "command": "list_background_tasks", + "args": { "projectId": project_id, "agentId": owner.to_string() } + }), + &[("cookie", &cookie)], + ) + .await; + let (status, body, _) = response_json(response).await; + + assert_eq!(status, StatusCode::OK); + let tasks = body.as_array().expect("background task list is an array"); + assert_eq!(tasks.len(), 1); + assert_eq!(tasks[0]["taskId"], task_id.to_string()); + assert_eq!(tasks[0]["ownerAgentId"], owner.to_string()); + assert_eq!(tasks[0]["projectId"], project_id); + assert_eq!(tasks[0]["kind"], "command"); + assert_eq!(tasks[0]["state"], "queued"); + } + + #[tokio::test] + async fn background_actions_are_allowlisted_with_auth_gate() { + let state = state(); + let cookie = pair_and_cookie(Arc::clone(&state)).await; + let bad_task_id = "not-a-task-id"; + + for command in ["cancel_background_task", "retry_background_task"] { + let response = request( + Arc::clone(&state), + Method::POST, + "/api/invoke", + json!({ "command": command, "args": { "taskId": bad_task_id } }), + &[("cookie", &cookie)], + ) + .await; + let (status, body, _) = response_json(response).await; + + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!( + body["code"], "INVALID", + "{command} must be an explicit action, not UNKNOWN_COMMAND" + ); + } + } + #[tokio::test] async fn mutation_and_pty_commands_stay_out_of_readonly_allowlist() { let state = state(); From dd1d083a1a071111d7eabf891ab1fd1f975f50f8 Mon Sep 17 00:00:00 2001 From: Blomios Date: Wed, 15 Jul 2026 23:37:02 +0200 Subject: [PATCH 06/12] feat(frontend): surfaces live web (workstate live, background, inbox, reconnect) (#13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lot F5 du chantier server/client mode : le client web consomme les frames event.domain (B7) pour animer ses surfaces live. - webLive.ts : consommation du flux event.domain (workstate, background, inbox). - useLiveReconnect.ts : reconnexion du flux live. - wsLiveClient.ts / index.ts : câblage du transport live. - WebWorkspace.tsx : surfaces live branchées. - Tests : WebWorkspaceLive.test.tsx, wsLiveClientReconnect.test.ts, WebApp.test.tsx. Validé : frontend 753 tests verts, desktop non régressé. Co-Authored-By: Claude Opus 4.8 --- frontend/src/adapters/http/index.ts | 6 + frontend/src/adapters/http/webLive.ts | 28 ++ frontend/src/adapters/http/wsLiveClient.ts | 20 +- .../http/wsLiveClientReconnect.test.ts | 23 ++ frontend/src/features/web/WebApp.test.tsx | 2 +- frontend/src/features/web/WebWorkspace.tsx | 323 ++++++++++++------ .../features/web/WebWorkspaceLive.test.tsx | 140 ++++++++ frontend/src/features/web/useLiveReconnect.ts | 27 ++ 8 files changed, 457 insertions(+), 112 deletions(-) create mode 100644 frontend/src/adapters/http/webLive.ts create mode 100644 frontend/src/features/web/WebWorkspaceLive.test.tsx create mode 100644 frontend/src/features/web/useLiveReconnect.ts diff --git a/frontend/src/adapters/http/index.ts b/frontend/src/adapters/http/index.ts index 98c8070..57af01d 100644 --- a/frontend/src/adapters/http/index.ts +++ b/frontend/src/adapters/http/index.ts @@ -19,6 +19,7 @@ import { LocalStorageUiPreferencesGateway } from "../uiPreferences"; import { HttpInvoker } from "./httpInvoker"; import { WsLiveClient } from "./wsLiveClient"; import { getWebSession } from "./webSession"; +import { setWebLiveClient } from "./webLive"; import { HttpConversationGateway, HttpEmbedderGateway, @@ -86,6 +87,9 @@ export function createHttpWsGateways(config: HttpWsGatewaysConfig = {}): Gateway onUnauthorized: () => session.notifyUnauthorized(), }); const ws = new WsLiveClient({ wsUrl, token: config.token }); + // Share the live client with the web feature so live surfaces can re-sync on + // reconnect (F5). Inert on desktop (never called there). + setWebLiveClient(ws); return { system: new HttpSystemGateway(http, ws), @@ -116,3 +120,5 @@ export function createHttpWsGateways(config: HttpWsGatewaysConfig = {}): Gateway export { HttpInvoker } from "./httpInvoker"; export { WsLiveClient } from "./wsLiveClient"; export { WebSession, getWebSession, setWebSessionForTests } from "./webSession"; +export { getWebLiveClient, setWebLiveClient } from "./webLive"; +export type { ConnectionState } from "./wsLiveClient"; diff --git a/frontend/src/adapters/http/webLive.ts b/frontend/src/adapters/http/webLive.ts new file mode 100644 index 0000000..0241beb --- /dev/null +++ b/frontend/src/adapters/http/webLive.ts @@ -0,0 +1,28 @@ +/** + * Web live-connection accessor — ticket #13, lot F5. + * + * The web gateways share a single {@link WsLiveClient} (created in + * `createHttpWsGateways`). The live surfaces (workstate / background / + * notifications) need to know when that socket **reconnects** after an outage so + * they can re-synchronise their read-models (events emitted while disconnected + * were missed). The `SystemGateway` port intentionally does not expose transport + * connection state, so — rather than leak it through the port — the web client is + * registered here as a module singleton and consumed only by the web feature + * (`useLiveReconnect`). Desktop never registers a client, so this is inert there. + * + * Lives in `src/adapters/**`; touches no `@tauri-apps/api`. + */ + +import type { WsLiveClient } from "./wsLiveClient"; + +let liveClient: WsLiveClient | null = null; + +/** Registers the shared web live client (called by `createHttpWsGateways`). */ +export function setWebLiveClient(client: WsLiveClient | null): void { + liveClient = client; +} + +/** Returns the shared web live client, or `null` outside web transport. */ +export function getWebLiveClient(): WsLiveClient | null { + return liveClient; +} diff --git a/frontend/src/adapters/http/wsLiveClient.ts b/frontend/src/adapters/http/wsLiveClient.ts index 92d1d9c..96056d2 100644 --- a/frontend/src/adapters/http/wsLiveClient.ts +++ b/frontend/src/adapters/http/wsLiveClient.ts @@ -263,15 +263,19 @@ export class WsLiveClient { this.setConnState("closed"); return; } - // Only reconnect when there is a live terminal to re-attach; otherwise stay - // idle until the next explicit use. + this.setConnState("reconnecting"); + // Reconnect while anything still needs the socket: a live terminal to + // re-attach (F3) OR an active domain-event subscription (F5 live surfaces). + // Otherwise stay idle until the next explicit use. if (this.terminals.size > 0) { - this.setConnState("reconnecting"); for (const sub of this.terminals.values()) sub.onData(notice("déconnecté — reconnexion…")); - this.scheduleReconnect(); - } else { - this.setConnState("reconnecting"); } + if (this.needsReconnect()) this.scheduleReconnect(); + } + + /** Whether the socket should auto-reconnect (a terminal or a live subscription). */ + private needsReconnect(): boolean { + return this.terminals.size > 0 || this.domainEventHandler !== null; } private scheduleReconnect(): void { @@ -293,8 +297,8 @@ export class WsLiveClient { await this.connect(); await this.reattachAll(); } catch { - // Still down: back off and retry (unless everything was detached/closed). - if (this.terminals.size > 0) this.scheduleReconnect(); + // Still down: back off and retry (unless nothing needs the socket anymore). + if (this.needsReconnect()) this.scheduleReconnect(); } } diff --git a/frontend/src/adapters/http/wsLiveClientReconnect.test.ts b/frontend/src/adapters/http/wsLiveClientReconnect.test.ts index da057e7..d0084bf 100644 --- a/frontend/src/adapters/http/wsLiveClientReconnect.test.ts +++ b/frontend/src/adapters/http/wsLiveClientReconnect.test.ts @@ -158,6 +158,29 @@ describe("WsLiveClient connection state + reconnection", () => { expect(h.hasTimer()).toBe(false); }); + it("reconnects for a live domain-event subscription even with no terminal (F5)", async () => { + const h = harness(); + // A live surface subscribes to domain events (no terminal open). + h.ws.setDomainEventHandler(() => {}); + await h.ws.ensureConnected(); + await vi.waitFor(() => expect(h.ws.getConnectionState()).toBe("connected")); + + // Drop the socket → must schedule a reconnect (the subscription still needs it). + h.sockets[0].close(); + expect(h.ws.getConnectionState()).toBe("reconnecting"); + expect(h.hasTimer()).toBe(true); + + h.fireTimer(); + await vi.waitFor(() => expect(h.sockets.length).toBe(2)); + await vi.waitFor(() => expect(h.ws.getConnectionState()).toBe("connected")); + + // Events resume flowing to the persisted handler after reconnect. + const events: unknown[] = []; + h.ws.setDomainEventHandler((e) => events.push(e)); + h.sockets[1].receive({ kind: "event.domain", payload: { type: "agentBusyChanged", agentId: "a1", busy: true } }); + expect(events).toHaveLength(1); + }); + it("dispose() moves to closed and clears any pending reconnect", async () => { const h = harness(); await attach(h.ws, h.sockets, "s1", () => {}); diff --git a/frontend/src/features/web/WebApp.test.tsx b/frontend/src/features/web/WebApp.test.tsx index f4d3491..e85645c 100644 --- a/frontend/src/features/web/WebApp.test.tsx +++ b/frontend/src/features/web/WebApp.test.tsx @@ -102,7 +102,7 @@ describe("WebApp pairing routing", () => { const snapshot = await screen.findByTestId("web-workstate"); expect(snapshot.textContent).toContain("Archi"); - expect(snapshot.textContent).toContain("idle"); + expect(snapshot.textContent).toContain("Idle"); }); it("opens a live agent cell for a work-state agent (F4 affordance)", async () => { diff --git a/frontend/src/features/web/WebWorkspace.tsx b/frontend/src/features/web/WebWorkspace.tsx index 7797cf1..af45ade 100644 --- a/frontend/src/features/web/WebWorkspace.tsx +++ b/frontend/src/features/web/WebWorkspace.tsx @@ -1,23 +1,33 @@ /** - * Read-only web workspace — ticket #13, lot F2 (first shippable increment). + * Live web workspace — ticket #13, lots F2 (read-only) + F5 (live surfaces). * - * The minimal post-pairing surface: list the projects, open one **read-only**, - * and show a snapshot of its live/work state. No PTY (xterm over WS = F3), no - * mutation — every call goes through the existing transport-neutral gateways - * (`project`, `workState`) via DI, so no component touches `@tauri-apps/api`. + * After pairing: list projects, open one read-only, and show its **live** + * work-state — agents (live/idle/busy), background tasks (with cancel/retry) and + * per-agent inbox — updated in real time. The live mechanism is the desktop's own + * transport-neutral {@link useProjectWorkState} hook (refreshes the read-model on + * relevant `event.domain` events pushed over the WS, B7); {@link useLiveReconnect} + * re-synchronises after a WS outage. Background cancel/retry go through the + * {@link WorkStateGateway} — writes via DI, no direct transport. A CLI agent can + * be opened into a live cell (F4). No component touches `@tauri-apps/api`. * - * It reuses the frozen read-model types (`ProjectWorkState`) and only calls the - * commands B4 puts on the read-only allowlist: `list_projects`, `open_project`, - * `get_project_work_state`. A deliberately small surface — the full IDE (layout, - * agents, terminals) is out of scope until the streaming lots. + * Read-only allowlist used (B4/B7): `list_projects`, `open_project`, + * `get_project_work_state`, plus the background `cancel`/`retry` commands and the + * `event.domain` live stream. */ import { useCallback, useEffect, useState } from "react"; -import type { GatewayError, Project, ProjectWorkState } from "@/domain"; +import type { + AgentWorkState, + BackgroundCompletion, + GatewayError, + Project, +} from "@/domain"; import { useGateways } from "@/app/di"; -import { Button, Panel, Spinner } from "@/shared"; +import { useProjectWorkState } from "@/features/workstate/useProjectWorkState"; +import { Button, Panel, Spinner, cn } from "@/shared"; import { WebAgentCell } from "./WebAgentCell"; +import { useLiveReconnect } from "./useLiveReconnect"; function describe(e: unknown): string { if (e && typeof e === "object" && "message" in e) { @@ -27,14 +37,10 @@ function describe(e: unknown): string { } export function WebWorkspace() { - const { project, workState } = useGateways(); + const { project } = useGateways(); const [projects, setProjects] = useState(null); const [error, setError] = useState(null); const [openId, setOpenId] = useState(null); - const [snapshot, setSnapshot] = useState(null); - const [loadingSnapshot, setLoadingSnapshot] = useState(false); - // The agent currently opened in a live cell (CLI streamed over the WS), if any. - const [openAgentId, setOpenAgentId] = useState(null); const openRoot = projects?.find((p) => p.id === openId)?.root ?? null; @@ -54,22 +60,17 @@ export function WebWorkspace() { const openReadOnly = useCallback( async (projectId: string) => { setError(null); - setLoadingSnapshot(true); - setOpenId(projectId); - setSnapshot(null); - setOpenAgentId(null); + setOpenId(null); try { - // Read-only: open resolves the project server-side, then we read the - // live/work-state snapshot. No layout/agents/PTY are mounted. + // Read-only: resolve the project server-side; the live panel then streams + // its work-state. No layout/PTY is mounted here. await project.openProject(projectId); - setSnapshot(await workState.getProjectWorkState(projectId)); + setOpenId(projectId); } catch (e) { setError(describe(e)); - } finally { - setLoadingSnapshot(false); } }, - [project, workState], + [project], ); return ( @@ -112,89 +113,205 @@ export function WebWorkspace() { )} {openId && ( - -
    -

    - État (lecture seule) - - read-only - -

    - {loadingSnapshot && } -
    - {snapshot ? ( - - ) : loadingSnapshot ? ( -

    Chargement de l'état…

    - ) : ( -

    Aucun état disponible.

    - )} -
    - )} - - {openId && openRoot && openAgentId && ( - -
    -

    Agent

    - -
    - {/* Re-mount the cell per agent so a switch relaunches/reattaches cleanly. */} - -
    + )} ); } -/** Pure render of the read-only work-state snapshot + per-agent "open" affordance. */ -function WorkStateSnapshot({ - snapshot, - openAgentId, - onOpenAgent, -}: { - snapshot: ProjectWorkState; - openAgentId: string | null; - onOpenAgent: (agentId: string) => void; -}) { - if (snapshot.agents.length === 0) { - return

    Aucun agent actif.

    ; - } +/** Live work-state + background + inbox for the opened project (F5). */ +function LiveProjectPanel({ projectId, root }: { projectId: string; root: string | null }) { + const vm = useProjectWorkState(projectId); + // Re-sync the read-model when the WS reconnects (events missed while offline). + useLiveReconnect(vm.refresh); + const [openAgentId, setOpenAgentId] = useState(null); + const agents = vm.state?.agents ?? []; + return ( -
      - {snapshot.agents.map((a) => ( -
    • - {a.name} - - {a.live ? `live · ${a.live.kind}` : "offline"} - - {a.busy.state === "busy" ? "busy" : "idle"} - - + +
      +

      + État live + + read-only -

    • - ))} -
    + + + + + {vm.error &&

    {vm.error}

    } + + {vm.busy && vm.state === null ? ( +

    Chargement de l'état…

    + ) : agents.length === 0 ? ( +

    Aucun agent actif.

    + ) : ( +
      + {agents.map((a) => ( + + setOpenAgentId((cur) => (cur === a.agentId ? null : a.agentId)) + } + onRefresh={vm.refresh} + /> + ))} +
    + )} + + {root && openAgentId && ( +
    +
    + Agent + +
    + +
    + )} + + ); +} + +/** One agent row: live/idle/busy + inbox + background tasks + open affordance. */ +function AgentLiveRow({ + agent, + open, + onToggleOpen, + onRefresh, +}: { + agent: AgentWorkState; + open: boolean; + onToggleOpen: () => void; + onRefresh: () => Promise; +}) { + const live = agent.live !== undefined; + const busy = agent.busy?.state === "busy"; + const inbox = [...(agent.inbox ?? [])].sort((a, b) => a.createdAtMs - b.createdAtMs); + const backgroundTasks = [...(agent.backgroundTasks ?? [])].sort( + (a, b) => b.updatedAtMs - a.updatedAtMs || a.taskId.localeCompare(b.taskId), + ); + + return ( +
  • +
    + {agent.name} + + + {live ? "Live" : "Offline"} + + + {busy ? "Busy" : "Idle"} + + + +
    + + {inbox.length > 0 && ( +
    +

    Inbox

    +
      + {inbox.map((item) => ( +
    • + {item.kind} + {item.body} +
    • + ))} +
    +
    + )} + + {backgroundTasks.length > 0 && ( +
    +

    Background tasks

    +
      + {backgroundTasks.map((task) => ( + + ))} +
    +
    + )} +
  • + ); +} + +const TASK_STATUS_LABEL: Record = { + running: "Running", + completed: "Completed", + failed: "Failed", + cancelled: "Cancelled", + pending: "Pending", + delivered: "Delivered", +}; + +function taskStatusClass(status: BackgroundCompletion["status"]): string { + if (status === "running" || status === "pending") return "bg-warning/15 text-warning"; + if (status === "completed" || status === "delivered") return "bg-success/10 text-success"; + if (status === "failed" || status === "cancelled") return "bg-danger/10 text-danger"; + return "bg-raised text-muted"; +} + +/** One background task with live status + cancel/retry via the DI gateway. */ +function WebBackgroundTaskRow({ + task, + onRefresh, +}: { + task: BackgroundCompletion; + onRefresh: () => Promise; +}) { + const { workState } = useGateways(); + const [actionBusy, setActionBusy] = useState(false); + const [message, setMessage] = useState(null); + const canCancel = task.status === "running" || task.status === "pending"; + const canRetry = task.status === "failed" || task.status === "cancelled"; + + async function runAction(action: (taskId: string) => Promise): Promise { + setActionBusy(true); + setMessage(null); + try { + await action(task.taskId); + await onRefresh(); + } catch (e) { + setMessage(describe(e)); + } finally { + setActionBusy(false); + } + } + + return ( +
  • +
    + + {TASK_STATUS_LABEL[task.status]} + + {task.kind} + + +
    + {message &&

    {message}

    } +
  • ); } diff --git a/frontend/src/features/web/WebWorkspaceLive.test.tsx b/frontend/src/features/web/WebWorkspaceLive.test.tsx new file mode 100644 index 0000000..fe40c52 --- /dev/null +++ b/frontend/src/features/web/WebWorkspaceLive.test.tsx @@ -0,0 +1,140 @@ +/** + * F5 — the live web surfaces: a `event.domain` event refreshes the displayed + * work-state; background tasks render with status + cancel/retry wired to the + * gateway; a WS reconnect re-synchronises the read-model. + */ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; + +import type { Gateways } from "@/ports"; +import type { BackgroundCompletion, ProjectWorkState } from "@/domain"; +import { DIProvider } from "@/app/di"; +import { createMockGateways, MockSystemGateway, MockWorkStateGateway } from "@/adapters/mock"; +import { + WsLiveClient, + setWebLiveClient, + WebSession, +} from "@/adapters/http"; +import { WebApp } from "./WebApp"; + +afterEach(() => setWebLiveClient(null)); + +const okFetch = async () => ({ + ok: true, + status: 200, + json: async () => ({ ok: true }), + text: async () => "{}", +}); + +function memStore() { + const map = new Map(); + return { + getItem: (k: string) => map.get(k) ?? null, + setItem: (k: string, v: string) => void map.set(k, v), + removeItem: (k: string) => void map.delete(k), + }; +} + +function state(agents: ProjectWorkState["agents"]): ProjectWorkState { + return { agents, conversations: [] }; +} + +async function seeded(initial: ProjectWorkState): Promise<{ gateways: Gateways; projectId: string }> { + const gateways = createMockGateways(); + const project = await gateways.project.createProject("Demo", "/srv/demo"); + (gateways.workState as MockWorkStateGateway)._setProjectWorkState(project.id, initial); + return { gateways, projectId: project.id }; +} + +function renderPaired(gateways: Gateways) { + const session = new WebSession({ baseUrl: "https://h", fetchImpl: okFetch, store: memStore() }); + session.markPaired(); + return render( + + + , + ); +} + +const AGENT_IDLE = { agentId: "a1", name: "Archi", profileId: "p1", busy: { state: "idle" as const }, tickets: [] }; + +describe("WebWorkspace live surfaces (F5)", () => { + it("refreshes the work-state on a relevant event.domain event", async () => { + const { gateways, projectId } = await seeded(state([AGENT_IDLE])); + renderPaired(gateways); + + fireEvent.click(await screen.findByText("Demo")); + const panel = await screen.findByTestId("web-workstate"); + expect(panel.textContent).toContain("Idle"); + + // The agent goes busy server-side; the read-model now reflects it… + (gateways.workState as MockWorkStateGateway)._setProjectWorkState( + projectId, + state([{ ...AGENT_IDLE, busy: { state: "busy", ticket: "t-1", sinceMs: 1 } }]), + ); + // …and an `agentBusyChanged` domain event drives a live refresh. + act(() => { + (gateways.system as MockSystemGateway).emit({ type: "agentBusyChanged", agentId: "a1", busy: true }); + }); + + await waitFor(() => + expect(screen.getByTestId("web-workstate").textContent).toContain("Busy"), + ); + }); + + it("renders background tasks and wires cancel through the gateway", async () => { + const task: BackgroundCompletion = { + taskId: "bt-1", + ownerAgentId: "a1", + projectId: "p", + kind: "shell", + status: "running", + exitCode: null, + summary: null, + stdoutTail: null, + stderrTail: null, + updatedAtMs: 1, + }; + const { gateways } = await seeded(state([{ ...AGENT_IDLE, backgroundTasks: [task] }])); + const cancelSpy = vi.spyOn(gateways.workState, "cancelBackgroundTask"); + renderPaired(gateways); + + fireEvent.click(await screen.findByText("Demo")); + const tasks = await screen.findByLabelText("Archi background tasks"); + expect(tasks.textContent).toContain("Running"); + expect(tasks.textContent).toContain("shell"); + + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + await waitFor(() => expect(cancelSpy).toHaveBeenCalledWith("bt-1")); + }); + + it("re-synchronises the read-model when the WS reconnects", async () => { + const { gateways, projectId } = await seeded(state([AGENT_IDLE])); + // Register a live client whose connection state we can drive. + let notify: ((s: "connecting" | "connected" | "reconnecting" | "closed") => void) | null = null; + const fakeClient = { + getConnectionState: () => "connected" as const, + onConnectionStateChange: (l: (s: "connecting" | "connected" | "reconnecting" | "closed") => void) => { + notify = l; + return () => { + notify = null; + }; + }, + }; + setWebLiveClient(fakeClient as unknown as WsLiveClient); + + const refreshSpy = vi.spyOn(gateways.workState, "getProjectWorkState"); + renderPaired(gateways); + fireEvent.click(await screen.findByText("Demo")); + await screen.findByTestId("web-workstate"); + const callsAfterOpen = refreshSpy.mock.calls.length; + expect(projectId).toBeTruthy(); + + // Simulate a reconnect: reconnecting → connected must re-fetch the read-model. + act(() => { + notify?.("reconnecting"); + notify?.("connected"); + }); + await waitFor(() => expect(refreshSpy.mock.calls.length).toBeGreaterThan(callsAfterOpen)); + }); +}); diff --git a/frontend/src/features/web/useLiveReconnect.ts b/frontend/src/features/web/useLiveReconnect.ts new file mode 100644 index 0000000..aacd8c4 --- /dev/null +++ b/frontend/src/features/web/useLiveReconnect.ts @@ -0,0 +1,27 @@ +/** + * `useLiveReconnect` — ticket #13, lot F5. Web-only hook that re-synchronises a + * live read-model when the shared WebSocket **reconnects** after an outage. + * + * During a disconnection, domain events emitted by the server are missed, so a + * plain event-driven refresh (the desktop `useProjectWorkState` mechanism) would + * leave the UI stale until the next event. This hook watches the web live + * client's connection state and calls `onReconnect` on each `reconnecting → + * connected` transition. It is inert outside web transport (no client + * registered), so it is safe to mount unconditionally. + */ + +import { useEffect } from "react"; + +import { getWebLiveClient } from "@/adapters/http"; + +export function useLiveReconnect(onReconnect: () => void): void { + useEffect(() => { + const client = getWebLiveClient(); + if (!client) return; + let previous = client.getConnectionState(); + return client.onConnectionStateChange((state) => { + if (state === "connected" && previous === "reconnecting") onReconnect(); + previous = state; + }); + }, [onReconnect]); +} From d538808a0db89f0377f4bc994dfdccd779821f7d Mon Sep 17 00:00:00 2001 From: Blomios Date: Thu, 16 Jul 2026 08:10:31 +0200 Subject: [PATCH 07/12] =?UTF-8?q?feat(server):=20servir=20dist=20same-orig?= =?UTF-8?q?in=20+=20durcissement=20(logout,=20logs=20s=C3=A9curit=C3=A9,?= =?UTF-8?q?=20static=20hardening)=20(#13)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sert le bundle web (dist/) en same-origin via --web-root / IDEA_WEB_ROOT ou le web/ packagé. serve_static durci : anti path-traversal, typage MIME, X-Content-Type-Options nosniff, CSP, fallback SPA. Ajoute POST /api/logout qui révoque la session HTTP + WS, et une journalisation sécurité via SecurityLogger. Documente le déploiement remote (dev local, packaging web/, reverse proxy HTTPS). Co-Authored-By: Claude Opus 4.8 --- crates/app-tauri/src/server.rs | 642 +++++++++++++++++++++++++++++- docs/server-client-mode-remote.md | 45 +++ 2 files changed, 676 insertions(+), 11 deletions(-) create mode 100644 docs/server-client-mode-remote.md diff --git a/crates/app-tauri/src/server.rs b/crates/app-tauri/src/server.rs index 7f71c5a..1fc8192 100644 --- a/crates/app-tauri/src/server.rs +++ b/crates/app-tauri/src/server.rs @@ -6,7 +6,7 @@ use std::collections::HashSet; use std::env; use std::net::SocketAddr; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::process::ExitCode; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; @@ -95,6 +95,7 @@ struct ServerConfig { allow_remote: bool, trust_reverse_proxy: bool, app_data_dir: PathBuf, + web_root: PathBuf, } impl ServerConfig { @@ -106,6 +107,7 @@ impl ServerConfig { let mut allow_remote = false; let mut trust_reverse_proxy = false; let mut app_data_dir = default_app_data_dir(); + let mut web_root = None; let mut it = args.into_iter(); while let Some(arg) = it.next() { @@ -132,10 +134,17 @@ impl ServerConfig { .ok_or_else(|| "--app-data-dir requires a path".to_owned())?; app_data_dir = PathBuf::from(value); } + "--web-root" => { + let value = it + .next() + .ok_or_else(|| "--web-root requires a path".to_owned())?; + web_root = Some(PathBuf::from(value)); + } "--help" | "-h" => return Err(Self::usage()), other => return Err(format!("unknown --serve argument: {other}")), } } + let web_root = resolve_web_root(web_root)?; Ok(Self { listen, @@ -143,6 +152,7 @@ impl ServerConfig { allow_remote, trust_reverse_proxy, app_data_dir, + web_root, }) } @@ -182,7 +192,46 @@ impl ServerConfig { } fn usage() -> String { - "usage: idea --serve [--listen IP:PORT] [--app-data-dir PATH] [--allow-remote --public-origin https://host --trust-reverse-proxy]".to_owned() + "usage: idea --serve [--listen IP:PORT] [--app-data-dir PATH] [--web-root PATH] [--allow-remote --public-origin https://host --trust-reverse-proxy]".to_owned() + } +} + +fn resolve_web_root(explicit: Option) -> Result { + if let Some(path) = explicit { + return validate_web_root(path, "--web-root"); + } + if let Some(path) = env::var_os("IDEA_WEB_ROOT").map(PathBuf::from) { + return validate_web_root(path, "IDEA_WEB_ROOT"); + } + + let mut candidates = Vec::new(); + if let Ok(exe) = env::current_exe() { + if let Some(dir) = exe.parent() { + candidates.push(dir.join("web")); + candidates.push(dir.join("frontend").join("dist")); + } + } + if let Ok(cwd) = env::current_dir() { + candidates.push(cwd.join("frontend").join("dist")); + } + + for candidate in candidates { + if candidate.join("index.html").is_file() { + return Ok(candidate); + } + } + + Err("web assets not found: build frontend/dist or pass --web-root PATH".to_owned()) +} + +fn validate_web_root(path: PathBuf, source: &str) -> Result { + if path.join("index.html").is_file() { + Ok(path) + } else { + Err(format!( + "{source} does not contain an index.html: {}", + path.display() + )) } } @@ -192,6 +241,7 @@ struct ServerState { pairing_code: String, sessions: Mutex>, ws_pty_bridge: Arc>, + security_logger: Arc, } impl ServerState { @@ -202,17 +252,28 @@ impl ServerState { pairing_code: new_pairing_code(), sessions: Mutex::new(HashSet::new()), ws_pty_bridge: Arc::new(OutputBridge::new()), + security_logger: Arc::new(StderrSecurityLogger), } } #[cfg(test)] fn new_for_test(config: ServerConfig, pairing_code: impl Into) -> Self { + Self::new_for_test_with_logger(config, pairing_code, Arc::new(NoopSecurityLogger)) + } + + #[cfg(test)] + fn new_for_test_with_logger( + config: ServerConfig, + pairing_code: impl Into, + security_logger: Arc, + ) -> Self { Self { app: AppState::build(config.app_data_dir.clone()), config, pairing_code: pairing_code.into(), sessions: Mutex::new(HashSet::new()), ws_pty_bridge: Arc::new(OutputBridge::new()), + security_logger, } } @@ -234,6 +295,93 @@ impl ServerState { .map(|sessions| sessions.contains(token)) .unwrap_or(false) } + + fn revoke_session(&self, token: &str) -> bool { + self.sessions + .lock() + .map(|mut sessions| sessions.remove(token)) + .unwrap_or(false) + } + + fn log_security(&self, event: SecurityLogEvent) { + self.security_logger.log(event); + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum SecurityLogEvent { + PairingSucceeded { + origin: Option, + }, + PairingFailed { + origin: Option, + reason: &'static str, + }, + OriginRejected { + origin: Option, + route: String, + }, + WsUpgradeRejected { + origin: Option, + reason: &'static str, + }, + SessionRevoked { + origin: Option, + }, +} + +trait SecurityLogger: Send + Sync { + fn log(&self, event: SecurityLogEvent); +} + +struct StderrSecurityLogger; + +impl SecurityLogger for StderrSecurityLogger { + fn log(&self, event: SecurityLogEvent) { + eprintln!("idea --serve security: {}", security_log_line(&event)); + } +} + +#[cfg(test)] +struct NoopSecurityLogger; + +#[cfg(test)] +impl SecurityLogger for NoopSecurityLogger { + fn log(&self, _event: SecurityLogEvent) {} +} + +fn security_log_line(event: &SecurityLogEvent) -> String { + match event { + SecurityLogEvent::PairingSucceeded { origin } => { + format!("pairing succeeded origin={}", origin_label(origin)) + } + SecurityLogEvent::PairingFailed { origin, reason } => { + format!( + "pairing failed reason={reason} origin={}", + origin_label(origin) + ) + } + SecurityLogEvent::OriginRejected { origin, route } => { + format!( + "origin rejected route={} origin={}", + route, + origin_label(origin) + ) + } + SecurityLogEvent::WsUpgradeRejected { origin, reason } => { + format!( + "websocket upgrade rejected reason={reason} origin={}", + origin_label(origin) + ) + } + SecurityLogEvent::SessionRevoked { origin } => { + format!("session revoked origin={}", origin_label(origin)) + } + } +} + +fn origin_label(origin: &Option) -> &str { + origin.as_deref().unwrap_or("") } async fn run_server(config: ServerConfig, state: Arc) -> Result<(), String> { @@ -444,8 +592,21 @@ fn validate_ws_upgrade( headers: &HeaderMap, state: &ServerState, ) -> Result>> { - let origin = validate_request_origin(headers, &state.config)?; + let origin = match validate_request_origin(headers, &state.config) { + Ok(origin) => origin, + Err(response) => { + state.log_security(SecurityLogEvent::OriginRejected { + origin: request_origin(headers), + route: WS_PATH.to_owned(), + }); + return Err(response); + } + }; let Some(token) = session_cookie(headers) else { + state.log_security(SecurityLogEvent::WsUpgradeRejected { + origin: origin.clone(), + reason: "missing_session", + }); return Err(Box::new(error_response( StatusCode::UNAUTHORIZED, "UNAUTHORIZED", @@ -454,6 +615,10 @@ fn validate_ws_upgrade( ))); }; if !state.has_session(&token) { + state.log_security(SecurityLogEvent::WsUpgradeRejected { + origin: origin.clone(), + reason: "invalid_session", + }); return Err(Box::new(error_response( StatusCode::UNAUTHORIZED, "UNAUTHORIZED", @@ -464,6 +629,10 @@ fn validate_ws_upgrade( if !header_contains_token(headers, "upgrade", "websocket") || !header_contains_token(headers, "connection", "upgrade") { + state.log_security(SecurityLogEvent::WsUpgradeRejected { + origin: origin.clone(), + reason: "invalid_upgrade", + }); return Err(Box::new(error_response( StatusCode::BAD_REQUEST, "INVALID", @@ -475,6 +644,10 @@ fn validate_ws_upgrade( .get("sec-websocket-key") .and_then(|value| value.to_str().ok()) else { + state.log_security(SecurityLogEvent::WsUpgradeRejected { + origin: origin.clone(), + reason: "missing_key", + }); return Err(Box::new(error_response( StatusCode::BAD_REQUEST, "INVALID", @@ -940,26 +1113,58 @@ async fn dispatch_http( 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(), + None, ); } + if !is_api_path(uri.path()) { + return serve_static(&state.config.web_root, method, uri.path()).await; + } + + let origin = match validate_request_origin(&headers, &state.config) { + Ok(origin) => origin, + Err(response) => { + state.log_security(SecurityLogEvent::OriginRejected { + origin: request_origin(&headers), + route: uri.path().to_owned(), + }); + return *response; + } + }; + 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/logout") => { + let Some(token) = session_cookie(&headers) else { + return error_response( + StatusCode::UNAUTHORIZED, + "UNAUTHORIZED", + "missing session cookie", + origin.as_deref(), + ); + }; + if !state.revoke_session(&token) { + return error_response( + StatusCode::UNAUTHORIZED, + "UNAUTHORIZED", + "invalid session cookie", + origin.as_deref(), + ); + } + state.log_security(SecurityLogEvent::SessionRevoked { + origin: origin.clone(), + }); + logout_response(&state, origin.as_deref()) + } (Method::POST, "/api/invoke") => { let Some(token) = session_cookie(&headers) else { return error_response( @@ -979,18 +1184,136 @@ async fn dispatch_http( } invoke(body, state, origin.as_deref()).await } - (_, "/api/pair" | "/api/invoke") => error_response( + (_, "/api/pair" | "/api/invoke" | "/api/logout") => error_response( StatusCode::METHOD_NOT_ALLOWED, "METHOD_NOT_ALLOWED", "method not allowed", origin.as_deref(), ), - _ => error_response( + _ if is_api_path(uri.path()) => error_response( StatusCode::NOT_FOUND, "NOT_FOUND", "route not found", origin.as_deref(), ), + _ => error_response(StatusCode::NOT_FOUND, "NOT_FOUND", "route not found", None), + } +} + +fn is_api_path(path: &str) -> bool { + path == "/api" || path.starts_with("/api/") +} + +async fn serve_static(web_root: &Path, method: Method, path: &str) -> Response { + if !matches!(method, Method::GET | Method::HEAD) { + return error_response( + StatusCode::METHOD_NOT_ALLOWED, + "METHOD_NOT_ALLOWED", + "method not allowed", + None, + ); + } + + let Some(route) = static_route(web_root, path) else { + return error_response(StatusCode::NOT_FOUND, "NOT_FOUND", "route not found", None); + }; + + let file = if route.candidate.is_file() { + route.candidate + } else if route.spa_fallback { + web_root.join("index.html") + } else { + return error_response(StatusCode::NOT_FOUND, "NOT_FOUND", "route not found", None); + }; + + let bytes = match tokio::fs::read(&file).await { + Ok(bytes) => bytes, + Err(_) => { + return error_response(StatusCode::NOT_FOUND, "NOT_FOUND", "route not found", None); + } + }; + static_file_response(&file, bytes, method == Method::HEAD) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct StaticRoute { + candidate: PathBuf, + spa_fallback: bool, +} + +fn static_route(web_root: &Path, request_path: &str) -> Option { + if !request_path.starts_with('/') || request_path.contains('\\') { + return None; + } + let lower = request_path.to_ascii_lowercase(); + if lower.contains("%2e") || lower.contains("%2f") || lower.contains("%5c") { + return None; + } + + let mut candidate = web_root.to_path_buf(); + let mut last = ""; + for segment in request_path + .split('/') + .filter(|segment| !segment.is_empty()) + { + if segment == "." || segment == ".." || segment.starts_with('.') || segment.contains('%') { + return None; + } + candidate.push(segment); + last = segment; + } + + if request_path == "/" { + return Some(StaticRoute { + candidate: web_root.join("index.html"), + spa_fallback: false, + }); + } + + Some(StaticRoute { + candidate, + spa_fallback: !last.contains('.'), + }) +} + +fn static_file_response(path: &Path, bytes: Vec, head_only: bool) -> Response { + let is_index = path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name == "index.html"); + let body = if head_only { Vec::new() } else { bytes }; + let mut response = Response::builder() + .status(StatusCode::OK) + .header(CONTENT_TYPE, content_type(path)) + .header("x-content-type-options", "nosniff") + .body(Full::new(Bytes::from(body))) + .expect("static response is valid"); + if is_index { + response.headers_mut().insert( + "content-security-policy", + HeaderValue::from_static( + "default-src 'self'; connect-src 'self' ws: wss:; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; script-src 'self'; base-uri 'self'; frame-ancestors 'none'", + ), + ); + } + response +} + +fn content_type(path: &Path) -> &'static str { + match path.extension().and_then(|ext| ext.to_str()).unwrap_or("") { + "html" => "text/html; charset=utf-8", + "js" | "mjs" => "text/javascript; charset=utf-8", + "css" => "text/css; charset=utf-8", + "json" => "application/json; charset=utf-8", + "svg" => "image/svg+xml", + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "gif" => "image/gif", + "webp" => "image/webp", + "ico" => "image/x-icon", + "wasm" => "application/wasm", + "txt" => "text/plain; charset=utf-8", + _ => "application/octet-stream", } } @@ -1017,6 +1340,10 @@ async fn pair( }; if request.code != state.pairing_code() { + state.log_security(SecurityLogEvent::PairingFailed { + origin: origin.map(str::to_owned), + reason: "wrong_code", + }); return error_response( StatusCode::FORBIDDEN, "FORBIDDEN", @@ -1026,6 +1353,9 @@ async fn pair( } let token = state.create_session(); + state.log_security(SecurityLogEvent::PairingSucceeded { + origin: origin.map(str::to_owned), + }); let cookie = Cookie::build((SESSION_COOKIE, token)) .path("/") .http_only(true) @@ -1040,6 +1370,21 @@ async fn pair( response } +fn logout_response(state: &ServerState, origin: Option<&str>) -> Response { + let mut response = json_response(StatusCode::OK, &json!({ "revoked": true }), origin); + let secure = if state.config.secure_cookie() { + "; Secure" + } else { + "" + }; + let cookie = format!("{SESSION_COOKIE}=; Path=/; HttpOnly{secure}; SameSite=Strict; Max-Age=0"); + response.headers_mut().insert( + SET_COOKIE, + HeaderValue::from_str(&cookie).expect("session clearing cookie is header-safe"), + ); + response +} + #[derive(Deserialize)] struct InvokeRequest { command: String, @@ -1313,6 +1658,13 @@ fn validate_request_origin( } } +fn request_origin(headers: &HeaderMap) -> Option { + headers + .get(ORIGIN) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned) +} + fn origin_allowed(origin: &str, config: &ServerConfig) -> bool { if let Some(public_origin) = &config.public_origin { return origin == public_origin; @@ -1948,6 +2300,36 @@ mod tests { use http::header::HeaderName; use std::time::Duration; + #[derive(Default)] + struct RecordingSecurityLogger { + events: Mutex>, + } + + impl RecordingSecurityLogger { + fn events(&self) -> Vec { + self.events.lock().unwrap().clone() + } + } + + impl SecurityLogger for RecordingSecurityLogger { + fn log(&self, event: SecurityLogEvent) { + self.events.lock().unwrap().push(event); + } + } + + fn create_web_root() -> PathBuf { + let root = std::env::temp_dir().join(format!("idea-web-root-{}", Uuid::new_v4())); + std::fs::create_dir_all(root.join("assets")).unwrap(); + std::fs::write( + root.join("index.html"), + r#"
    "#, + ) + .unwrap(); + std::fs::write(root.join("assets").join("app.js"), "console.log('idea');").unwrap(); + std::fs::write(root.join("assets").join("style.css"), "body{margin:0}").unwrap(); + root + } + fn test_config() -> ServerConfig { ServerConfig { listen: "127.0.0.1:17373".parse().unwrap(), @@ -1955,6 +2337,7 @@ mod tests { allow_remote: false, trust_reverse_proxy: false, app_data_dir: std::env::temp_dir().join(format!("idea-server-test-{}", Uuid::new_v4())), + web_root: create_web_root(), } } @@ -1998,6 +2381,13 @@ mod tests { (status, value, headers) } + async fn response_bytes(response: Response) -> (StatusCode, Bytes, HeaderMap) { + let status = response.status(); + let headers = response.headers().clone(); + let body = response.into_body().collect().await.unwrap().to_bytes(); + (status, body, headers) + } + async fn pair_and_cookie(state: Arc) -> String { let response = request( state, @@ -3017,6 +3407,130 @@ mod tests { assert_eq!(body["code"], "UNAUTHORIZED"); } + #[tokio::test] + async fn logout_revokes_session_for_http_and_websocket() { + let state = state(); + let cookie = pair_and_cookie(Arc::clone(&state)).await; + + let response = request( + Arc::clone(&state), + Method::POST, + "/api/logout", + json!({}), + &[("cookie", &cookie)], + ) + .await; + let (status, body, headers) = response_json(response).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(body, json!({ "revoked": true })); + assert!(headers + .get(SET_COOKIE) + .unwrap() + .to_str() + .unwrap() + .contains("Max-Age=0")); + + let response = request( + Arc::clone(&state), + Method::POST, + "/api/invoke", + json!({ "command": "health", "args": {} }), + &[("cookie", &cookie)], + ) + .await; + let (status, body, _) = response_json(response).await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + assert_eq!(body["code"], "UNAUTHORIZED"); + + let headers = ws_headers("http://127.0.0.1:17373", Some(&cookie)); + let response = validate_ws_upgrade(&headers, &state).unwrap_err(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn security_logs_pairing_origin_ws_and_revoke_without_secrets() { + let logger = Arc::new(RecordingSecurityLogger::default()); + let state = Arc::new(ServerState::new_for_test_with_logger( + test_config(), + "PAIR1234", + logger.clone(), + )); + + let bad_origin = handle_request( + 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(), + Arc::clone(&state), + ) + .await; + assert_eq!(bad_origin.status(), StatusCode::FORBIDDEN); + + let wrong_code = request( + Arc::clone(&state), + Method::POST, + "/api/pair", + json!({ "code": "WRONG999" }), + &[], + ) + .await; + assert_eq!(wrong_code.status(), StatusCode::FORBIDDEN); + + let cookie = pair_and_cookie(Arc::clone(&state)).await; + let _ = request( + Arc::clone(&state), + Method::POST, + "/api/logout", + json!({}), + &[("cookie", &cookie)], + ) + .await; + + let headers = ws_headers( + "http://127.0.0.1:17373", + Some("idea_session=invalid-token-value"), + ); + let _ = validate_ws_upgrade(&headers, &state); + + let events = logger.events(); + assert!(events.iter().any(|event| matches!( + event, + SecurityLogEvent::OriginRejected { route, .. } if route == "/api/pair" + ))); + assert!(events.iter().any(|event| matches!( + event, + SecurityLogEvent::PairingFailed { + reason: "wrong_code", + .. + } + ))); + assert!(events + .iter() + .any(|event| matches!(event, SecurityLogEvent::PairingSucceeded { .. }))); + assert!(events + .iter() + .any(|event| matches!(event, SecurityLogEvent::SessionRevoked { .. }))); + assert!(events.iter().any(|event| matches!( + event, + SecurityLogEvent::WsUpgradeRejected { + reason: "invalid_session", + .. + } + ))); + + let lines = events.iter().map(security_log_line).collect::>(); + assert!( + !lines.iter().any(|line| line.contains("PAIR1234") + || line.contains("WRONG999") + || line.contains("invalid-token-value")), + "security logs must not leak pairing codes or session tokens" + ); + } + #[tokio::test] async fn pairing_rejects_wrong_code() { let state = state(); @@ -3114,6 +3628,112 @@ mod tests { assert_eq!(body["code"], "INVALID"); } + #[tokio::test] + async fn serves_web_index_same_origin_with_csp() { + let state = state(); + + let response = request(state, Method::GET, "/", json!({}), &[]).await; + let (status, body, headers) = response_bytes(response).await; + + assert_eq!(status, StatusCode::OK); + assert!(std::str::from_utf8(&body).unwrap().contains("id=\"root\"")); + assert_eq!( + headers.get(CONTENT_TYPE).unwrap(), + "text/html; charset=utf-8" + ); + assert!(headers.contains_key("content-security-policy")); + assert_eq!(headers.get("x-content-type-options").unwrap(), "nosniff"); + } + + #[tokio::test] + async fn serves_static_assets_with_mime_types() { + let state = state(); + + let response = request(state, Method::GET, "/assets/app.js", json!({}), &[]).await; + let (status, body, headers) = response_bytes(response).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(std::str::from_utf8(&body).unwrap(), "console.log('idea');"); + assert_eq!( + headers.get(CONTENT_TYPE).unwrap(), + "text/javascript; charset=utf-8" + ); + } + + #[tokio::test] + async fn serves_spa_fallback_for_non_api_routes_without_extension() { + let state = state(); + + let response = request(state, Method::GET, "/projects/abc", json!({}), &[]).await; + let (status, body, headers) = response_bytes(response).await; + + assert_eq!(status, StatusCode::OK); + assert!(std::str::from_utf8(&body).unwrap().contains("id=\"root\"")); + assert!(headers.contains_key("content-security-policy")); + } + + #[tokio::test] + async fn missing_asset_and_api_routes_do_not_fallback_to_spa() { + let state = state(); + let cookie = pair_and_cookie(Arc::clone(&state)).await; + + let missing_asset = request( + Arc::clone(&state), + Method::GET, + "/assets/missing.js", + json!({}), + &[], + ) + .await; + let (asset_status, asset_body, _) = response_json(missing_asset).await; + assert_eq!(asset_status, StatusCode::NOT_FOUND); + assert_eq!(asset_body["code"], "NOT_FOUND"); + + let api_unknown = request( + state, + Method::GET, + "/api/unknown", + json!({}), + &[("cookie", &cookie)], + ) + .await; + let (api_status, api_body, _) = response_json(api_unknown).await; + assert_eq!(api_status, StatusCode::NOT_FOUND); + assert_eq!(api_body["code"], "NOT_FOUND"); + } + + #[test] + fn static_route_rejects_traversal_and_dotfiles() { + let root = PathBuf::from("/tmp/web"); + + assert!(static_route(&root, "/../secret").is_none()); + assert!(static_route(&root, "/%2e%2e/secret").is_none()); + assert!(static_route(&root, "/.env").is_none()); + assert_eq!( + static_route(&root, "/dashboard").unwrap(), + StaticRoute { + candidate: root.join("dashboard"), + spa_fallback: true + } + ); + assert_eq!( + static_route(&root, "/assets/app.js").unwrap(), + StaticRoute { + candidate: root.join("assets").join("app.js"), + spa_fallback: false + } + ); + } + + #[test] + fn explicit_web_root_requires_index_html() { + let missing = std::env::temp_dir().join(format!("idea-empty-web-{}", Uuid::new_v4())); + std::fs::create_dir_all(&missing).unwrap(); + + assert!(validate_web_root(missing, "--web-root").is_err()); + assert!(validate_web_root(create_web_root(), "--web-root").is_ok()); + } + #[tokio::test] async fn non_allowed_origin_is_refused() { let state = state(); diff --git a/docs/server-client-mode-remote.md b/docs/server-client-mode-remote.md new file mode 100644 index 0000000..03504b9 --- /dev/null +++ b/docs/server-client-mode-remote.md @@ -0,0 +1,45 @@ +# IdeA Server/Client Remote Deployment + +`idea --serve` serves the web UI and the API from the same origin. The frontend +build must be available as a web root containing `index.html`. + +## Local Development + +```bash +pnpm --dir frontend build +idea --serve --web-root frontend/dist +``` + +The default local listener is `127.0.0.1:17373`. Loopback development may use +plain HTTP; the session cookie is still `HttpOnly` and `SameSite=Strict`. + +## Packaged Artifact + +The release artifact is still a single IdeA binary/AppImage. Packaging must copy +the frontend build output (`frontend/dist`) into the packaged `web/` resource +next to the binary. `idea --serve` resolves assets in this order: + +1. `--web-root PATH` +2. `IDEA_WEB_ROOT` +3. packaged `web/`, then packaged `frontend/dist/` +4. development fallback `frontend/dist` relative to the current directory + +If no `index.html` is found, the server refuses to start. + +## Remote HTTPS + +Public exposure requires a reverse proxy that terminates HTTPS: + +```bash +idea --serve \ + --listen 127.0.0.1:17373 \ + --allow-remote \ + --public-origin https://idea.example.com \ + --trust-reverse-proxy +``` + +The proxy must forward the same origin for the SPA, `/api/*`, and `/api/ws`. +Do not expose the backend on a public non-loopback address without TLS proxying. +Secrets must never be placed in URLs; pairing uses `POST /api/pair` and then an +`HttpOnly`, `Secure`, `SameSite=Strict` session cookie. + From 611717795732b8c5e193dffc62a28e9ef0b3ba63 Mon Sep 17 00:00:00 2001 From: Blomios Date: Thu, 16 Jul 2026 08:23:11 +0200 Subject: [PATCH 08/12] =?UTF-8?q?feat(frontend):=20polish=20web=20(logout,?= =?UTF-8?q?=20401=E2=86=92pairing,=20reconnexion=20globale)=20(#13)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flow logout : POST /api/logout puis retour à l'écran de pairing et déconnexion du live. 401 renvoie au pairing sans boucle. Bannière de reconnexion couvrant le cas live-only et le serveur indisponible. Co-Authored-By: Claude Opus 4.8 --- frontend/src/adapters/http/index.ts | 27 ++++++-- frontend/src/adapters/http/webLive.ts | 9 +++ frontend/src/adapters/http/webSession.test.ts | 29 +++++++++ frontend/src/adapters/http/webSession.ts | 20 ++++++ frontend/src/adapters/http/wsLiveClient.ts | 48 +++++++++++++- .../http/wsLiveClientReconnect.test.ts | 62 +++++++++++++++++++ frontend/src/features/web/WebApp.test.tsx | 26 ++++++++ frontend/src/features/web/WebApp.tsx | 28 ++++++--- frontend/src/features/web/WebWorkspace.tsx | 24 +++++++ .../features/web/useLiveConnectionState.ts | 29 +++++++++ 10 files changed, 288 insertions(+), 14 deletions(-) create mode 100644 frontend/src/features/web/useLiveConnectionState.ts diff --git a/frontend/src/adapters/http/index.ts b/frontend/src/adapters/http/index.ts index 57af01d..0f1871b 100644 --- a/frontend/src/adapters/http/index.ts +++ b/frontend/src/adapters/http/index.ts @@ -79,14 +79,33 @@ function resolveEndpoints(config: HttpWsGatewaysConfig): { */ export function createHttpWsGateways(config: HttpWsGatewaysConfig = {}): Gateways { const { baseUrl, wsUrl } = resolveEndpoints(config); - // Route 401s back to the pairing screen via the shared web session (F2). + // Route 401s back to the pairing screen via the shared web session (F2/F6). const session = getWebSession(); + // `ws` is referenced by the invoker's 401 handler (declared below); the closure + // only runs after both are constructed, so the forward reference is safe. + let ws: WsLiveClient; const http = new HttpInvoker({ baseUrl, token: config.token, - onUnauthorized: () => session.notifyUnauthorized(), + onUnauthorized: () => { + // F6: a 401 means the session cookie is gone/revoked. Route back to pairing + // AND tear down the live socket so it stops its (now hopeless) reconnect + // loop. Re-pairing brings the same client back up cleanly. + session.notifyUnauthorized(); + ws?.disconnect(); + }, + }); + ws = new WsLiveClient({ + wsUrl, + token: config.token, + // F6: when a WS reconnect fails we cannot read its HTTP status, so probe with + // a cheap `health` call. A revoked session answers `401` → the invoker's + // `onUnauthorized` above bounces to pairing; a network error is swallowed and + // the WS backoff keeps retrying. + onReconnectFailed: () => { + void http.invoke("health", { request: null }).catch(() => {}); + }, }); - const ws = new WsLiveClient({ wsUrl, token: config.token }); // Share the live client with the web feature so live surfaces can re-sync on // reconnect (F5). Inert on desktop (never called there). setWebLiveClient(ws); @@ -120,5 +139,5 @@ export function createHttpWsGateways(config: HttpWsGatewaysConfig = {}): Gateway export { HttpInvoker } from "./httpInvoker"; export { WsLiveClient } from "./wsLiveClient"; export { WebSession, getWebSession, setWebSessionForTests } from "./webSession"; -export { getWebLiveClient, setWebLiveClient } from "./webLive"; +export { getWebLiveClient, setWebLiveClient, disconnectWebLive } from "./webLive"; export type { ConnectionState } from "./wsLiveClient"; diff --git a/frontend/src/adapters/http/webLive.ts b/frontend/src/adapters/http/webLive.ts index 0241beb..628d9dc 100644 --- a/frontend/src/adapters/http/webLive.ts +++ b/frontend/src/adapters/http/webLive.ts @@ -26,3 +26,12 @@ export function setWebLiveClient(client: WsLiveClient | null): void { export function getWebLiveClient(): WsLiveClient | null { return liveClient; } + +/** + * Soft-disconnects the shared live client (sign-out / auth bounce — F6): tears + * down the socket and stops the reconnect loop but keeps the client reusable, so + * re-pairing reconnects cleanly. Inert (no client registered) on desktop. + */ +export function disconnectWebLive(): void { + liveClient?.disconnect(); +} diff --git a/frontend/src/adapters/http/webSession.test.ts b/frontend/src/adapters/http/webSession.test.ts index afef6d2..d5092e7 100644 --- a/frontend/src/adapters/http/webSession.test.ts +++ b/frontend/src/adapters/http/webSession.test.ts @@ -90,3 +90,32 @@ describe("WebSession unauthorized handling", () => { expect(session.isPaired()).toBe(false); }); }); + +describe("WebSession logout", () => { + it("POSTs /api/logout same-origin and clears the flag on success", async () => { + const store = memStore(); + const { fetchImpl, calls } = fetchReturning(200, { revoked: true }); + const session = new WebSession({ baseUrl: "https://host/", fetchImpl, store }); + session.markPaired(); + + await session.logout(); + + expect(session.isPaired()).toBe(false); + expect(calls[0].url).toBe("https://host/api/logout"); + const init = calls[0].init as { method: string; credentials: string }; + expect(init.method).toBe("POST"); + expect(init.credentials).toBe("same-origin"); + }); + + it("still clears the flag when the server is unreachable (best-effort)", async () => { + const store = memStore(); + const fetchImpl: FetchLike = async () => { + throw new Error("network down"); + }; + const session = new WebSession({ baseUrl: "https://host", fetchImpl, store }); + session.markPaired(); + + await expect(session.logout()).resolves.toBeUndefined(); + expect(session.isPaired()).toBe(false); + }); +}); diff --git a/frontend/src/adapters/http/webSession.ts b/frontend/src/adapters/http/webSession.ts index 810e5f2..e3fdf67 100644 --- a/frontend/src/adapters/http/webSession.ts +++ b/frontend/src/adapters/http/webSession.ts @@ -96,6 +96,26 @@ export class WebSession { this.store.removeItem(PAIRED_FLAG_KEY); } + /** + * Full sign-out (F6): asks the server to revoke the session cookie + * (`POST /api/logout`, same-origin so the cookie rides along) and then clears + * the local flag. Best-effort — a network failure or an already-expired session + * still clears the flag locally, so the UI always returns to pairing without a + * dead end. Disconnecting the live WS is the caller's job (composition seam). + */ + async logout(): Promise { + try { + await this.fetchImpl(`${this.baseUrl}/api/logout`, { + method: "POST", + credentials: "same-origin", + }); + } catch { + // Server unreachable / already gone: fall through to the local clear. + } finally { + this.forget(); + } + } + /** * Performs the pairing handshake: `POST /api/pair {code}`. On success the * server sets the session cookie and this records the paired flag. A wrong code diff --git a/frontend/src/adapters/http/wsLiveClient.ts b/frontend/src/adapters/http/wsLiveClient.ts index 96056d2..ffa992b 100644 --- a/frontend/src/adapters/http/wsLiveClient.ts +++ b/frontend/src/adapters/http/wsLiveClient.ts @@ -78,6 +78,14 @@ export interface WsLiveClientConfig { setTimeoutImpl?: SetTimeoutLike; /** Injected `clearTimeout`. */ clearTimeoutImpl?: ClearTimeoutLike; + /** + * Called after a reconnection attempt fails. The browser cannot read the HTTP + * status of a rejected WS upgrade, so an auth-revoked socket looks like any + * other outage from here. F6 wires this to a cheap HTTP probe (`health`): a + * `401` there routes back to pairing (via the invoker's `onUnauthorized`), + * while a network error just lets the backoff keep retrying. + */ + onReconnectFailed?: () => void; } /** A live terminal subscription tracked for output routing + reconnection replay. */ @@ -137,6 +145,7 @@ export class WsLiveClient { private readonly reconnectMaxMs: number; private readonly setTimeoutImpl: SetTimeoutLike; private readonly clearTimeoutImpl: ClearTimeoutLike; + private readonly onReconnectFailed?: () => void; private socket: WebSocketLike | null = null; private opening: Promise | null = null; @@ -172,6 +181,7 @@ export class WsLiveClient { config.setTimeoutImpl ?? ((fn, ms) => setTimeout(fn, ms)); this.clearTimeoutImpl = config.clearTimeoutImpl ?? ((h) => clearTimeout(h as ReturnType)); + this.onReconnectFailed = config.onReconnectFailed; } // ------------------------------------------------------------------------- @@ -274,7 +284,7 @@ export class WsLiveClient { } /** Whether the socket should auto-reconnect (a terminal or a live subscription). */ - private needsReconnect(): boolean { + needsReconnect(): boolean { return this.terminals.size > 0 || this.domainEventHandler !== null; } @@ -297,7 +307,10 @@ export class WsLiveClient { await this.connect(); await this.reattachAll(); } catch { - // Still down: back off and retry (unless nothing needs the socket anymore). + // Still down: probe (an auth-revoked upgrade is indistinguishable from an + // outage here — the probe surfaces a 401 as a pairing bounce), then back + // off and retry (unless nothing needs the socket anymore). + this.onReconnectFailed?.(); if (this.needsReconnect()) this.scheduleReconnect(); } } @@ -479,6 +492,37 @@ export class WsLiveClient { return bytesToBase64(bytes); } + /** + * Soft teardown for a sign-out / auth bounce (F6): closes the socket, drops all + * live state and stops the reconnect loop, but — unlike {@link dispose} — leaves + * the client reusable. A later `ensureConnected` (after re-pairing) reconnects + * as a fresh `connecting`. The socket's own `onclose` is detached first so it + * cannot flip the state back to `reconnecting`. + */ + disconnect(): void { + if (this.reconnectHandle) { + this.clearTimeoutImpl(this.reconnectHandle); + this.reconnectHandle = null; + } + this.rejectAllPending({ code: "WS_CLOSED", message: "client disconnected" }); + this.outputSinks.clear(); + this.terminals.clear(); + this.domainEventHandler = null; + const socket = this.socket; + if (socket) { + socket.onopen = null; + socket.onmessage = null; + socket.onerror = null; + socket.onclose = null; + socket.close(); + } + this.socket = null; + this.opening = null; + this.everConnected = false; + this.reconnectAttempt = 0; + this.setConnState("closed"); + } + /** Closes the socket and rejects everything pending. */ dispose(): void { this.disposed = true; diff --git a/frontend/src/adapters/http/wsLiveClientReconnect.test.ts b/frontend/src/adapters/http/wsLiveClientReconnect.test.ts index d0084bf..c93803a 100644 --- a/frontend/src/adapters/http/wsLiveClientReconnect.test.ts +++ b/frontend/src/adapters/http/wsLiveClientReconnect.test.ts @@ -190,4 +190,66 @@ describe("WsLiveClient connection state + reconnection", () => { expect(h.ws.getConnectionState()).toBe("closed"); expect(h.hasTimer()).toBe(false); }); + + it("disconnect() closes and stops reconnecting but stays reusable (F6 sign-out)", async () => { + const h = harness(); + h.ws.setDomainEventHandler(() => {}); + await h.ws.ensureConnected(); + await vi.waitFor(() => expect(h.ws.getConnectionState()).toBe("connected")); + + // Sign-out: soft teardown. State is closed, the reconnect loop is dropped, and + // the detached socket's onclose cannot flip the state back to reconnecting. + h.ws.disconnect(); + expect(h.ws.getConnectionState()).toBe("closed"); + expect(h.hasTimer()).toBe(false); + expect(h.ws.needsReconnect()).toBe(false); + + // Reusable: a later ensureConnected reconnects as a fresh socket (re-pairing). + await h.ws.ensureConnected(); + await vi.waitFor(() => expect(h.sockets.length).toBe(2)); + await vi.waitFor(() => expect(h.ws.getConnectionState()).toBe("connected")); + }); + + it("calls onReconnectFailed when a reconnect attempt fails (auth probe seam)", async () => { + const failed = vi.fn(); + let openNextSocket = true; + let timerFn: (() => void) | null = null; + const sockets: FakeSocket[] = []; + const ws = new WsLiveClient({ + wsUrl: "wss://host", + reconnectBaseMs: 1, + onReconnectFailed: failed, + socketFactory: () => { + const s = new FakeSocket(); + sockets.push(s); + // First socket connects; the reconnect socket errors (upgrade rejected). + if (openNextSocket) queueMicrotask(() => s.onopen?.()); + else queueMicrotask(() => s.onerror?.(new Error("upgrade rejected"))); + return s; + }, + setTimeoutImpl: (fn) => { + timerFn = fn; + return 1; + }, + clearTimeoutImpl: () => { + timerFn = null; + }, + }); + + ws.setDomainEventHandler(() => {}); + await ws.ensureConnected(); + await vi.waitFor(() => expect(ws.getConnectionState()).toBe("connected")); + + // Drop the socket; the next connect attempt will fail the upgrade. + openNextSocket = false; + sockets[0].close(); + expect(ws.getConnectionState()).toBe("reconnecting"); + const fire = timerFn as (() => void) | null; + timerFn = null; + fire?.(); + + // The failed reconnect fires the probe hook and schedules another attempt. + await vi.waitFor(() => expect(failed).toHaveBeenCalled()); + expect(timerFn).not.toBeNull(); + }); }); diff --git a/frontend/src/features/web/WebApp.test.tsx b/frontend/src/features/web/WebApp.test.tsx index e85645c..8680705 100644 --- a/frontend/src/features/web/WebApp.test.tsx +++ b/frontend/src/features/web/WebApp.test.tsx @@ -31,6 +31,16 @@ const okFetch: FetchLike = async () => ({ text: async () => "{}", }); +/** Records fetch calls so a test can assert the logout POST. */ +function recordingFetch(): { fetchImpl: FetchLike; calls: string[] } { + const calls: string[] = []; + const fetchImpl: FetchLike = async (url) => { + calls.push(url); + return { ok: true, status: 200, json: async () => ({ ok: true }), text: async () => "{}" }; + }; + return { fetchImpl, calls }; +} + const rejectFetch: FetchLike = async () => ({ ok: false, status: 401, @@ -120,6 +130,22 @@ describe("WebApp pairing routing", () => { expect(await screen.findByTestId("web-agent-cell")).toBeTruthy(); }); + it("signs out via POST /api/logout and returns to pairing", async () => { + const { fetchImpl, calls } = recordingFetch(); + const session = new WebSession({ baseUrl: "https://h", fetchImpl, store: memStore() }); + session.markPaired(); + renderWebApp(session, await seededGateways()); + + expect(await screen.findByText("Projets")).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: "Se déconnecter" })); + + await waitFor(() => expect(screen.getByText("Appairer cet appareil")).toBeTruthy()); + expect(calls).toContain("https://h/api/logout"); + expect(session.isPaired()).toBe(false); + expect(screen.queryByText("Projets")).toBeNull(); + }); + it("returns to pairing when the session reports unauthorized (401)", async () => { const session = new WebSession({ baseUrl: "https://h", fetchImpl: okFetch, store: memStore() }); session.markPaired(); diff --git a/frontend/src/features/web/WebApp.tsx b/frontend/src/features/web/WebApp.tsx index 274f06d..b24e072 100644 --- a/frontend/src/features/web/WebApp.tsx +++ b/frontend/src/features/web/WebApp.tsx @@ -5,14 +5,15 @@ * Routes on the {@link WebSession} paired flag: not paired ⇒ {@link PairingScreen}; * paired ⇒ the read-only {@link WebWorkspace}. It subscribes to the session's * `onUnauthorized` signal so a `401` from any `/api/invoke` (expired/missing - * cookie) drops back to pairing automatically. A best-effort "Se déconnecter" - * clears the local flag (server-side cookie revocation is B8). + * cookie) drops back to pairing automatically. "Se déconnecter" (F6) revokes the + * server session (`POST /api/logout`), tears down the live WS singleton, and + * returns to pairing. */ import { useEffect, useState } from "react"; import { Button } from "@/shared"; -import { getWebSession, type WebSession } from "@/adapters/http"; +import { disconnectWebLive, getWebSession, type WebSession } from "@/adapters/http"; import { PairingScreen } from "./PairingScreen"; import { WebWorkspace } from "./WebWorkspace"; @@ -24,15 +25,26 @@ interface WebAppProps { export function WebApp({ session }: WebAppProps = {}) { const webSession = session ?? getWebSession(); const [paired, setPaired] = useState(() => webSession.isPaired()); + const [signingOut, setSigningOut] = useState(false); useEffect(() => { - // A 401 anywhere clears the flag and fires this: return to pairing. + // A 401 anywhere clears the flag and fires this: return to pairing. The live + // WS is torn down by the composition-root 401 handler (F6). return webSession.onUnauthorized(() => setPaired(false)); }, [webSession]); - function signOut(): void { - webSession.forget(); - setPaired(false); + async function signOut(): Promise { + if (signingOut) return; + setSigningOut(true); + try { + // Revoke the server session (best-effort), then drop the live socket so it + // stops reconnecting, and return to pairing regardless of the outcome. + await webSession.logout(); + } finally { + disconnectWebLive(); + setSigningOut(false); + setPaired(false); + } } return ( @@ -45,7 +57,7 @@ export function WebApp({ session }: WebAppProps = {}) { {paired && ( - )} diff --git a/frontend/src/features/web/WebWorkspace.tsx b/frontend/src/features/web/WebWorkspace.tsx index af45ade..3d978d2 100644 --- a/frontend/src/features/web/WebWorkspace.tsx +++ b/frontend/src/features/web/WebWorkspace.tsx @@ -28,6 +28,7 @@ import { useProjectWorkState } from "@/features/workstate/useProjectWorkState"; import { Button, Panel, Spinner, cn } from "@/shared"; import { WebAgentCell } from "./WebAgentCell"; import { useLiveReconnect } from "./useLiveReconnect"; +import { useLiveConnectionState } from "./useLiveConnectionState"; function describe(e: unknown): string { if (e && typeof e === "object" && "message" in e) { @@ -75,6 +76,7 @@ export function WebWorkspace() { return (
    +

    Projets