choreo_client_core/connection.rs
1use crate::error::ClientError;
2use choreo_proto::{
3 ClientMessage, DaemonMessage, ProtoError, UnixStream, connect_unix, read_message, write_message,
4};
5use choreo_transport::error::TransportError;
6use choreo_transport::handshake::{
7 PREAMBLE_IK, PREAMBLE_XX, handshake_initiator, handshake_initiator_xx,
8};
9use choreo_transport::key::ensure_transport_keypair;
10// In-process transport: raw channel ends from an `choreo_daemon::embedded::EmbeddedLink`.
11// Pure values — client-core never depends on choreo-daemon; the GUI creates
12// the link and stuffs the ends into `ConnectionMode::InProcess`.
13use crossbeam_channel::{Receiver as CrossbeamReceiver, Sender as CrossbeamSender};
14use std::fmt;
15use std::io::{BufRead, BufReader, BufWriter, Write};
16use std::sync::mpsc;
17use std::thread;
18use std::time::Duration;
19use tracing::{debug, error, info, warn};
20
21/// Poll step for the writer's shutdown/queue select loop.
22const SHUTDOWN_POLL_INTERVAL: Duration = Duration::from_millis(100);
23/// Read `DaemonMessages` from `reader` in a blocking loop, calling
24/// `handle_daemon_message` for each successfully decoded message.
25///
26/// Returns `Ok(())` when the stream ends cleanly (EOF / connection reset).
27/// Returns `Err` on protocol or I/O errors.
28/// Drain a `BufRead` of framed `DaemonMessages` until EOF.
29///
30/// # Errors
31///
32/// Returns [`ClientError`] when the stream ends (EOF/closed connection), a
33/// frame fails to decode, or an I/O error occurs mid-stream.
34pub fn run_daemon_reader<R: BufRead>(
35 mut reader: R,
36 mut handle_daemon_message: impl FnMut(DaemonMessage),
37) -> Result<(), ClientError> {
38 loop {
39 debug!("daemon reader waiting for message");
40 match read_message::<_, DaemonMessage>(&mut reader) {
41 Ok(message) => {
42 debug!("received daemon message");
43 handle_daemon_message(message);
44 }
45 // Clean termination: the daemon closed its side of the connection.
46 Err(ProtoError::Io(error))
47 if matches!(
48 error.kind(),
49 std::io::ErrorKind::UnexpectedEof | std::io::ErrorKind::ConnectionReset
50 ) =>
51 {
52 break;
53 }
54 // Non-EOF I/O errors (broken pipe, connection aborted, etc.)
55 // are also fatal — the transport is gone.
56 Err(ProtoError::Io(error)) => {
57 error!(kind = %error.kind(), "daemon reader I/O error");
58 return Err(error.into());
59 }
60 // Protocol-level decode errors (Postcard, FrameTooLarge,
61 // TrailingBytes, UnsupportedVersion) are per-message failures.
62 // Because we use length-prefixed framing, a corrupt payload
63 // never desynchronises the stream — log and carry on.
64 Err(error) => {
65 error!(%error, "skipping corrupt daemon message");
66 }
67 }
68 }
69 info!("daemon reader loop ended normally");
70 Ok(())
71}
72
73///
74/// # Errors
75///
76/// Returns [`ClientError`] if the unix socket cannot be connected, the
77/// Noise handshake fails, or the connection loop hits an I/O or protocol
78/// error.
79// needless_pass_by_value waived: the receivers are channel endpoints the
80// caller must move in; external TUI/GUI/IM callers rely on this signature.
81#[allow(clippy::needless_pass_by_value)]
82pub fn run_daemon_connection(
83 socket_path: &str,
84 handle_daemon_message: impl FnMut(DaemonMessage),
85 from_ui: mpsc::Receiver<choreo_proto::ClientMessage>,
86 shutdown_rx: Option<mpsc::Receiver<()>>,
87) -> Result<(), ClientError> {
88 info!("connecting to daemon at {socket_path}");
89 let stream = connect_unix(socket_path)?;
90 pump_connection(stream, handle_daemon_message, from_ui, shutdown_rx)
91}
92
93/// Connect to the daemon at `socket_path`, starting one via `ensure_daemon` if
94/// (and ONLY if) the initial dial itself finds nothing listening.
95///
96/// There is no pre-flight probe: the first [`connect_unix`] IS the real
97/// connection attempt, and when the daemon is up it is used directly — the
98/// stream is never thrown away. Only a dial failure classified as "nothing is
99/// listening" (via [`choreo_proto::dial_error_means_no_listener`], shared
100/// with the daemon side so the classification can never drift) invokes
101/// `ensure_daemon`, after which the connection is retried. Any other dial
102/// error is returned unchanged — autostart cannot fix a permission problem,
103/// for example.
104///
105/// Race note: between the failed first dial and the post-autostart retry, a
106/// third party could bind the socket, or an auto-exit daemon could be racing
107/// its own shutdown. The retry dial therefore surfaces its errors verbatim —
108/// the caller sees exactly what the retry saw, never a synthetic "autostart
109/// failed".
110///
111/// # Errors
112///
113/// Returns the error from the first dial if it is classified as more than
114/// "nothing listening", or the retry dial's error verbatim if the autostart
115/// hook ran but the connection still failed.
116pub fn run_daemon_connection_with_autostart(
117 socket_path: &str,
118 ensure_daemon: &mut dyn FnMut() -> Result<(), ClientError>,
119 handle_daemon_message: impl FnMut(DaemonMessage),
120 from_ui: mpsc::Receiver<choreo_proto::ClientMessage>,
121 shutdown_rx: Option<mpsc::Receiver<()>>,
122) -> Result<(), ClientError> {
123 info!("connecting to daemon at {socket_path}");
124 let stream = match connect_unix(socket_path) {
125 Ok(stream) => stream,
126 Err(error) if choreo_proto::dial_error_means_no_listener(&error) => {
127 info!(%error, "no daemon listening on the socket; requesting autostart");
128 ensure_daemon()?;
129 info!("autostart done; retrying connection to daemon at {socket_path}");
130 connect_unix(socket_path)?
131 }
132 Err(error) => return Err(error.into()),
133 };
134 pump_connection(stream, handle_daemon_message, from_ui, shutdown_rx)
135}
136
137/// Drive an ESTABLISHED daemon connection: spawn the writer thread, install
138/// the optional shutdown hook, and block in the reader loop until the daemon
139/// closes the stream. Split out of `run_daemon_connection` so the autostart
140/// variant can hand over a stream it already dialed (the successful first
141/// dial is never discarded).
142fn pump_connection(
143 stream: UnixStream,
144 handle_daemon_message: impl FnMut(DaemonMessage),
145 from_ui: mpsc::Receiver<choreo_proto::ClientMessage>,
146 shutdown_rx: Option<mpsc::Receiver<()>>,
147) -> Result<(), ClientError> {
148 let reader = BufReader::new(stream.try_clone()?);
149 let mut writer = BufWriter::new(stream);
150
151 // Channel to signal the writer thread to stop when the reader finishes.
152 let (writer_shutdown_tx, writer_shutdown_rx) = mpsc::channel::<()>();
153
154 let writer_handle = thread::spawn(move || {
155 loop {
156 match from_ui.recv_timeout(SHUTDOWN_POLL_INTERVAL) {
157 Ok(msg) => {
158 if let Err(e) = write_message(&mut writer, &msg) {
159 warn!("writer thread write error: {e}");
160 break;
161 }
162 let _ = writer.flush();
163 }
164 Err(mpsc::RecvTimeoutError::Timeout) => {
165 // Poll the shutdown signal periodically so we don't hang
166 // indefinitely on recv() when the daemon disconnects.
167 if writer_shutdown_rx.try_recv().is_ok() {
168 break;
169 }
170 }
171 Err(mpsc::RecvTimeoutError::Disconnected) => break,
172 }
173 }
174 });
175
176 if let Some(shutdown_rx) = shutdown_rx {
177 let shutdown_stream = reader.get_ref().try_clone()?;
178 thread::spawn(move || {
179 let _ = shutdown_rx.recv();
180 let _ = shutdown_stream.shutdown(std::net::Shutdown::Both);
181 });
182 }
183
184 let reader_result = run_daemon_reader(reader, handle_daemon_message);
185 // Signal the writer to stop and wait for it to flush pending writes.
186 let _ = writer_shutdown_tx.send(());
187 let _ = writer_handle.join();
188 reader_result
189}
190
191/// Selects the transport for connecting to a daemon.
192///
193// Clone stays derived: crossbeam channel ends are `Clone`, so a cloned mode
194// shares the same underlying channel pair (fine — cloning a mode is only used
195// to stash it for `connection_addr`-style display lookups).
196#[derive(Clone)]
197pub enum ConnectionMode {
198 /// Connect via Unix domain socket at the given path.
199 UnixSocket(String),
200 /// Connect via TCP/Noise IK at the given address with the server's
201 /// 32-byte X25519 public key (resolved before constructing this variant).
202 Tcp { addr: String, server_pk: [u8; 32] },
203 /// Connect via TCP/Noise IK against the key PINNED in
204 /// `known_servers.toml` for the address. The pin is loaded at connect
205 /// time; a handshake failure is reported WITH the pinned fingerprint and
206 /// the re-pair guidance, so a server key change is loud instead of an
207 /// opaque connection error (the `known_hosts` behavior).
208 TcpPinned(String),
209 /// Connect to an EMBEDDED daemon in the same process: `daemon_tx` is the
210 /// client→daemon channel end, `daemon_rx` the daemon→client end (the raw
211 /// pair from `choreo_daemon::embedded::EmbeddedLink`, moved here as
212 /// values). Messages travel as VALUES — no codec, no socket. Channel
213 /// close IS the EOF in both directions, exactly like the daemon-side
214 /// embedded path. client-core never links choreo-daemon: the GUI (the
215 /// owner of the `EmbeddedDaemon`) creates the link and hands over the ends.
216 InProcess {
217 daemon_tx: CrossbeamSender<ClientMessage>,
218 daemon_rx: CrossbeamReceiver<DaemonMessage>,
219 },
220}
221
222// Manual Debug instead of a derive: crossbeam channel ends are not `Debug`,
223// and the embedded link carries no printable identity — it is rendered as a
224// placeholder. Every other variant formats EXACTLY as the derive output would
225// (`UnixSocket("path")`, `Tcp { addr: "…", server_pk: [..] }`,
226// `TcpPinned("addr")`) so log/error messages keep their previous shape.
227impl fmt::Debug for ConnectionMode {
228 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
229 match self {
230 ConnectionMode::UnixSocket(path) => f.debug_tuple("UnixSocket").field(path).finish(),
231 ConnectionMode::Tcp { addr, server_pk } => f
232 .debug_struct("Tcp")
233 .field("addr", addr)
234 .field("server_pk", server_pk)
235 .finish(),
236 ConnectionMode::TcpPinned(addr) => f.debug_tuple("TcpPinned").field(addr).finish(),
237 // The ends are opaque by design: printing channel internals would
238 // leak nothing useful and might suggest a stable identity that
239 // does not exist.
240 ConnectionMode::InProcess { .. } => f.write_str("InProcess(<embedded link>)"),
241 }
242 }
243}
244
245impl Default for ConnectionMode {
246 fn default() -> Self {
247 ConnectionMode::UnixSocket(choreo_proto::socket_path())
248 }
249}
250
251/// Connect to a daemon via Noise IK over TCP.
252///
253/// Uses two blocking threads:
254/// - Reader thread: blocks on `NoiseStream::recv_daemon_message()`
255/// - Writer thread: blocks on `from_ui.recv_timeout()`
256/// - Shutdown: blocks on `shutdown_rx.recv()`, then shuts down the TCP stream
257///
258/// The reader thread has no read timeout — it blocks until a message arrives
259/// or the connection is closed. The writer thread uses a short timeout on its
260/// channel receive so it can also check for shutdown signals.
261///
262/// # Errors
263///
264/// Returns [`ClientError`] if dialing fails, the Noise IK handshake fails,
265/// or any reader/writer I/O error kills the connection.
266// needless_pass_by_value waived: same channel-endpoint ownership as
267// run_daemon_connection; external callers rely on this signature.
268#[allow(clippy::needless_pass_by_value)]
269pub fn run_daemon_tcp_connection(
270 addr: &str,
271 server_pk: &[u8; 32],
272 handle_daemon_message: impl FnMut(DaemonMessage),
273 from_ui: mpsc::Receiver<ClientMessage>,
274 shutdown_rx: Option<mpsc::Receiver<()>>,
275) -> Result<(), ClientError> {
276 info!("connecting to daemon at {addr}");
277
278 // Load the client transport keypair (generates one if absent).
279 let (client_sk, _client_pk) =
280 ensure_transport_keypair().map_err(|e| ClientError::Io(std::io::Error::other(e)))?;
281
282 // Dial FIRST, as its own step. Keeping the dial and the handshake
283 // separate is what lets the pinned-mode wrapper (see
284 // `run_daemon_tcp_connection_pinned`) attach trust guidance to
285 // HANDSHAKE failures only — a plain dial failure (daemon down, network
286 // down) is reported verbatim and must never suggest re-pairing.
287 let tcp = std::net::TcpStream::connect(addr).map_err(ClientError::Io)?;
288
289 ik_handshake_and_serve(
290 tcp,
291 client_sk.as_bytes(),
292 server_pk,
293 handle_daemon_message,
294 from_ui,
295 shutdown_rx,
296 )
297}
298
299/// The 1-byte handshake-mode preamble (TCP wire v5) goes out BEFORE any
300/// handshake message, then the Noise IK handshake runs. Returns the raw
301/// `TransportError` so callers can classify the failure (the
302/// `ConnectionRefused` wrapping lives one layer up, in
303/// [`ik_handshake_and_serve`]). Shared by the session-opening path and the
304/// [`verify_daemon_authorization`] preflight so both exercise the exact
305/// same wire sequence the daemon will judge.
306fn ik_handshake_raw(
307 mut tcp: std::net::TcpStream,
308 client_sk: &[u8; 32],
309 server_pk: &[u8; 32],
310) -> Result<choreo_transport::noise::NoiseStream, TransportError> {
311 // The preamble is unauthenticated by design — the daemon uses it
312 // only to pick which equally-authenticated handshake to run; the IK
313 // handshake itself authenticates both static keys and gates the ACL.
314 // A single-byte write to a fresh blocking socket cannot meaningfully
315 // block (it fits any socket buffer), so no timeout is armed for it.
316 tcp.write_all(&[PREAMBLE_IK])?;
317 handshake_initiator(tcp, client_sk, server_pk)
318}
319
320/// Preamble + Noise IK handshake over an ALREADY-DIALED TCP stream, then
321/// the encrypted session loop. Shared by [`run_daemon_tcp_connection`] and
322/// [`run_daemon_tcp_connection_pinned`] so both paths run the identical
323/// preamble+handshake sequence. A handshake failure surfaces as
324/// `ConnectionRefused` (the pre-existing wire convention) — which is why
325/// the caller must dial separately: only then does a `ConnectionRefused`
326/// returned from here unambiguously mean the HANDSHAKE failed, not the
327/// network.
328fn ik_handshake_and_serve(
329 tcp: std::net::TcpStream,
330 client_sk: &[u8; 32],
331 server_pk: &[u8; 32],
332 handle_daemon_message: impl FnMut(DaemonMessage),
333 from_ui: mpsc::Receiver<ClientMessage>,
334 shutdown_rx: Option<mpsc::Receiver<()>>,
335) -> Result<(), ClientError> {
336 let noise = ik_handshake_raw(tcp, client_sk, server_pk).map_err(|e| {
337 ClientError::Io(std::io::Error::new(
338 std::io::ErrorKind::ConnectionRefused,
339 e,
340 ))
341 })?;
342 serve_noise_connection(noise, handle_daemon_message, from_ui, shutdown_rx)
343}
344
345/// Connect to a daemon via Noise XX over TCP (first contact).
346///
347/// Use this when the client has NO pinned server public key for `addr`: the
348/// XX handshake reveals the server's static key, which is handed to
349/// `on_first_contact` BEFORE the encrypted session loop starts. The caller
350/// is expected to render the key's fingerprint for a human and return
351/// `true` only after an out-of-band confirmation (this is the trust gate —
352/// on `false` the connection is closed and NOTHING is sent, in particular
353/// no `Unlock`, so a first-contact MITM can never harvest the daemon's
354/// private key). The confirmed key is NOT stored here — pinning to
355/// `known_servers.toml` is the caller's job (phase 2), because the trust
356/// decision belongs to the UI layer, not the transport plumbing.
357///
358/// The writer thread only starts after `on_first_contact` returns `true`,
359/// so any `ClientMessage` already queued by the UI (including the TUI's
360/// auto-`Unlock`) is structurally held back until the trust decision is
361/// made — the gating is by construction, not by convention.
362///
363/// Otherwise identical to [`run_daemon_tcp_connection`] (same reader/writer
364/// thread shape, same shutdown semantics — see [`serve_noise_connection`]).
365///
366/// # Errors
367///
368/// Returns [`ClientError`] if dialing fails, the XX handshake fails, the
369/// learned server key is rejected by `on_first_contact`, or an I/O error
370/// kills the connection.
371pub fn run_daemon_tcp_connection_xx_first_contact(
372 addr: &str,
373 handle_daemon_message: impl FnMut(DaemonMessage),
374 from_ui: mpsc::Receiver<ClientMessage>,
375 shutdown_rx: Option<mpsc::Receiver<()>>,
376 on_first_contact: impl FnOnce([u8; 32]) -> bool,
377) -> Result<(), ClientError> {
378 info!("first-contact connect to daemon at {addr}");
379
380 // Load the client transport keypair (generates one if absent).
381 let (client_sk, _client_pk) =
382 ensure_transport_keypair().map_err(|e| ClientError::Io(std::io::Error::other(e)))?;
383
384 // Connect TCP, declare first-contact mode, run Noise XX.
385 let mut tcp = std::net::TcpStream::connect(addr).map_err(ClientError::Io)?;
386 // Same preamble reasoning as the IK path: unauthenticated mode selector,
387 // authenticated by nothing, needed for nothing — the handshake that
388 // follows carries all the cryptographic guarantees.
389 tcp.write_all(&[PREAMBLE_XX]).map_err(ClientError::Io)?;
390 let (noise, server_pk) = handshake_initiator_xx(tcp, client_sk.as_bytes()).map_err(|e| {
391 ClientError::Io(std::io::Error::new(
392 std::io::ErrorKind::ConnectionRefused,
393 e,
394 ))
395 })?;
396
397 // Trust gate: the caller verifies the learned key out-of-band. On
398 // refusal, drop the (already-established) encrypted transport without
399 // sending a single protocol message — the connection simply closes.
400 if !on_first_contact(server_pk) {
401 info!("first-contact trust rejected by caller; closing connection");
402 return Ok(());
403 }
404
405 serve_noise_connection(noise, handle_daemon_message, from_ui, shutdown_rx)
406}
407
408/// Shared tail of both TCP connection modes: the encrypted session loop over
409/// an already-established `NoiseStream`.
410///
411/// Uses two blocking threads:
412/// - Reader thread (the caller's): blocks on `NoiseStream::recv_daemon_message()`
413/// - Writer thread: blocks on `from_ui.recv_timeout()`
414/// - Shutdown: blocks on `shutdown_rx.recv()`, then shuts down the TCP stream
415///
416/// Extracted so [`run_daemon_tcp_connection`] (IK) and
417/// [`run_daemon_tcp_connection_xx_first_contact`] (XX) share one writer/reader
418/// implementation — the two modes differ ONLY in preamble + handshake, not in
419/// how the established transport is served.
420fn serve_noise_connection(
421 mut noise: choreo_transport::noise::NoiseStream,
422 mut handle_daemon_message: impl FnMut(DaemonMessage),
423 from_ui: mpsc::Receiver<ClientMessage>,
424 shutdown_rx: Option<mpsc::Receiver<()>>,
425) -> Result<(), ClientError> {
426 // Channel to signal writer thread to stop when reader finishes.
427 let (writer_shutdown_tx, writer_shutdown_rx) = mpsc::channel::<()>();
428
429 // Writer thread: blocks on from_ui.recv_timeout(), sends via NoiseStream.
430 // The timeout is only so the writer can check the shutdown signal —
431 // no socket-level timeout is set.
432 let mut writer = noise.try_clone().map_err(ClientError::Io)?;
433 let writer_handle = thread::spawn(move || {
434 loop {
435 match from_ui.recv_timeout(SHUTDOWN_POLL_INTERVAL) {
436 Ok(msg) => {
437 if let Err(e) = writer.send_client_message(&msg) {
438 warn!("writer thread error: {e}");
439 break;
440 }
441 }
442 Err(mpsc::RecvTimeoutError::Timeout) => {
443 // Check for shutdown signal so we don't hang on recv.
444 if writer_shutdown_rx.try_recv().is_ok() {
445 break;
446 }
447 }
448 Err(mpsc::RecvTimeoutError::Disconnected) => break,
449 }
450 }
451 });
452
453 // Optional shutdown signal: shuts down the TCP connection when triggered.
454 if let Some(shutdown_rx) = shutdown_rx {
455 let stream_ref = noise.get_ref().try_clone().map_err(ClientError::Io)?;
456 thread::spawn(move || {
457 let _ = shutdown_rx.recv();
458 let _ = stream_ref.shutdown(std::net::Shutdown::Both);
459 });
460 }
461
462 // Reader loop: blocks on noise.recv_daemon_message() (no read timeout).
463 loop {
464 match noise.recv_daemon_message() {
465 Ok(message) => {
466 handle_daemon_message(message);
467 }
468 Err(TransportError::ConnectionClosed) => {
469 info!("daemon closed Noise IK connection");
470 break;
471 }
472 // I/O errors from the underlying stream after shutdown:
473 // treat them the same as ConnectionClosed.
474 Err(TransportError::Io(ref e))
475 if matches!(
476 e.kind(),
477 std::io::ErrorKind::UnexpectedEof
478 | std::io::ErrorKind::ConnectionReset
479 | std::io::ErrorKind::ConnectionAborted
480 | std::io::ErrorKind::BrokenPipe
481 ) =>
482 {
483 info!("daemon connection closed: {e}");
484 break;
485 }
486 Err(e) => {
487 error!(error = %e, "daemon reader error");
488 break;
489 }
490 }
491 }
492
493 // Signal writer to stop and wait for it.
494 let _ = writer_shutdown_tx.send(());
495 let _ = writer_handle.join();
496 info!("daemon reader loop ended normally");
497 Ok(())
498}
499
500/// Probe a daemon's static public key without establishing a session.
501///
502/// Performs the XX first-contact handshake ONLY: connect, preamble, the
503/// three handshake messages, extract the learned server key, and DROP the
504/// stream — no data-plane message is ever sent or received. This is the
505/// building block UIs use to implement the trust flow synchronously (in a
506/// normal-mode terminal, before any TUI/GUI starts): learn the key, show
507/// the fingerprint, get the human's confirmation, pin, and only then open
508/// the real connection with [`ConnectionMode::Tcp`] / IK.
509///
510/// The probe itself authenticates NOTHING about the server (there is no pin
511/// yet — that is the point), but it does authenticate the probe TO the
512/// daemon, so the daemon's ACL applies: probing requires the client's key
513/// to be enrolled. The daemon ACL check completes inside the XX handshake
514/// (after message 3), so a not-yet-enrolled client gets `Err` here too.
515///
516/// # Errors
517///
518/// Returns [`ClientError`] on connect/handshake/I/O failures while probing.
519pub fn probe_server_key(addr: &str) -> Result<[u8; 32], ClientError> {
520 info!("probing server key at {addr} (XX first contact)");
521
522 let (client_sk, _client_pk) =
523 ensure_transport_keypair().map_err(|e| ClientError::Io(std::io::Error::other(e)))?;
524
525 let mut tcp = std::net::TcpStream::connect(addr).map_err(ClientError::Io)?;
526 // Same preamble contract as the session-opening XX path.
527 tcp.write_all(&[PREAMBLE_XX]).map_err(ClientError::Io)?;
528 let (noise, server_pk) = handshake_initiator_xx(tcp, client_sk.as_bytes()).map_err(|e| {
529 ClientError::Io(std::io::Error::new(
530 std::io::ErrorKind::ConnectionRefused,
531 e,
532 ))
533 })?;
534
535 // Drop the established transport immediately: the probe never speaks
536 // the protocol. Closing here is also what keeps the daemon's connection
537 // slot usage bounded — the real connection opens fresh afterwards.
538 drop(noise);
539 debug!(addr, "server key probe complete; transport dropped");
540 Ok(server_pk)
541}
542
543/// Why a preflight authorization check failed (see
544/// [`verify_daemon_authorization`]). The two cases need DIFFERENT
545/// remediation — "start the daemon / check the network" vs "get your key
546/// enrolled" — so they are distinguished at the type level instead of by
547/// string-matching an `io::ErrorKind` (a dial refusal and a handshake
548/// rejection both involve connection-level I/O and must not be conflated).
549#[derive(Debug, thiserror::Error)]
550pub enum PreflightError {
551 /// The daemon could not be reached at all (dial failure).
552 #[error("cannot reach the daemon: {0}")]
553 Unreachable(#[source] std::io::Error),
554 /// The daemon was reached but the Noise IK handshake failed: the
555 /// daemon either does not hold the expected server key, or its ACL
556 /// did not admit this client's transport key. (An IK handshake is the
557 /// ONLY probe that can detect the ACL rejection — the daemon aborts
558 /// the handshake before message 2 — which is why this preflight runs
559 /// IK even on a first-contact flow that already probed with XX.)
560 #[error("the daemon rejected the connection handshake: {0}")]
561 Rejected(#[source] TransportError),
562}
563
564/// The client's OWN Noise transport public key (generating the on-disk
565/// keypair first if absent). This is the identity the daemon's ACL judges:
566/// UIs embed it (and its fingerprint) in enrollment-remediation messages so
567/// a TUI-only user never needs the daemon binary just to read out their key.
568///
569/// # Errors
570///
571/// Returns [`ClientError`] if the local transport keypair cannot be loaded
572/// or generated.
573pub fn own_transport_pubkey() -> Result<[u8; 32], ClientError> {
574 let (_sk, pk) =
575 ensure_transport_keypair().map_err(|e| ClientError::Io(std::io::Error::other(e)))?;
576 Ok(pk)
577}
578
579/// Verify that the daemon at `addr` will actually admit this client BEFORE
580/// the caller commits to a full session (the TUI runs this before starting
581/// any UI).
582///
583/// Dials `addr`, runs the complete Noise IK handshake against `server_pk`,
584/// and immediately drops the established transport — no protocol message is
585/// ever sent. A successful IK handshake proves BOTH properties at once:
586/// the daemon holds the expected static key (the handshake authenticates
587/// it) AND the daemon's ACL admitted this client's key (the responder
588/// checks the ACL mid-handshake and closes the connection before completing
589/// it when the client is not enrolled). No other check can detect the
590/// enrollment case: the XX first-contact probe completes client-side before
591/// the daemon's ACL check runs, so it always "succeeds" for un-enrolled
592/// clients.
593///
594/// The cost is one extra handshake per connect — negligible against the
595/// session it gates, and it converts "TUI starts, then dies with a cryptic
596/// I/O error on first use" into a clear refusal before any UI exists.
597///
598/// # Errors
599///
600/// Returns [`PreflightError`] if the recorded key is missing, unreadable,
601/// corrupt, or does not match `server_pk`.
602pub fn verify_daemon_authorization(addr: &str, server_pk: &[u8; 32]) -> Result<(), PreflightError> {
603 info!(
604 addr,
605 "authorization preflight: probing daemon with IK handshake"
606 );
607
608 let (client_sk, _client_pk) = ensure_transport_keypair()
609 .map_err(|e| PreflightError::Unreachable(std::io::Error::other(e)))?;
610
611 // Dial failure = the daemon is down / unreachable — reported as-is so
612 // the caller's message can point at the network, not at enrollment.
613 let tcp = std::net::TcpStream::connect(addr).map_err(PreflightError::Unreachable)?;
614
615 // Handshake failure = rejection (wrong server key OR this client not
616 // enrolled in the daemon's ACL). Classified as `Rejected`; the caller
617 // renders the remediation.
618 let noise =
619 ik_handshake_raw(tcp, client_sk.as_bytes(), server_pk).map_err(PreflightError::Rejected)?;
620
621 // The handshake succeeded — the daemon will admit us. Drop the
622 // transport; the real session opens fresh (the daemon cleans the
623 // preflight connection up through its normal disconnect path).
624 drop(noise);
625 debug!(addr, "authorization preflight passed");
626 Ok(())
627}
628
629/// Connect via Noise IK against the key PINNED in `known_servers.toml` for
630/// `addr` (the [`ConnectionMode::TcpPinned`] path).
631///
632/// The whole point of this wrapper is the failure UX: a HANDSHAKE failure
633/// against the pinned key carries the pinned fingerprint and the explicit
634/// re-pair instructions, so a server key change is a loud, actionable
635/// message rather than an opaque error (the `known_hosts` behavior). The
636/// dial is performed HERE, as a separate step, so a network-down daemon is
637/// reported as a plain connect error WITHOUT the re-pair guidance — the
638/// remediation advice is reserved for the one failure it actually applies
639/// to (the server's key changed).
640///
641/// Errors if no pin exists for `addr` — callers must resolve first contact
642/// (probe + confirm + [`KnownServers::pin`]) before using this mode.
643///
644/// # Errors
645///
646/// Returns [`ClientError`] if dialing or the pinned-key IK handshake fails,
647/// or an I/O error kills the connection.
648pub fn run_daemon_tcp_connection_pinned(
649 addr: &str,
650 handle_daemon_message: impl FnMut(DaemonMessage),
651 from_ui: mpsc::Receiver<ClientMessage>,
652 shutdown_rx: Option<mpsc::Receiver<()>>,
653) -> Result<(), ClientError> {
654 let known = crate::known_servers::KnownServers::load()?;
655 let pinned = known
656 .lookup(addr)?
657 .ok_or_else(|| {
658 ClientError::Io(std::io::Error::other(format!(
659 "no pinned server key for {addr}: complete first contact (probe + fingerprint confirmation) before using the pinned mode"
660 )))
661 })?;
662
663 info!(
664 addr,
665 fingerprint = %choreo_transport::key::fingerprint(&pinned),
666 "connecting with pinned server key"
667 );
668
669 // Dial as its own step (plain error, no trust guidance — the daemon
670 // being down has nothing to do with the pin).
671 let tcp = std::net::TcpStream::connect(addr).map_err(ClientError::Io)?;
672
673 let (client_sk, _client_pk) =
674 ensure_transport_keypair().map_err(|e| ClientError::Io(std::io::Error::other(e)))?;
675
676 // The dial already succeeded, so a `ConnectionRefused` coming back from
677 // `ik_handshake_and_serve` can only be its handshake-failure wrapper —
678 // exactly the case where the re-pair guidance belongs. Later failures
679 // (a mid-session disconnect) still pass through untouched: a broken
680 // pipe has nothing to do with the pin and must not suggest re-pairing.
681 ik_handshake_and_serve(
682 tcp,
683 client_sk.as_bytes(),
684 &pinned,
685 handle_daemon_message,
686 from_ui,
687 shutdown_rx,
688 )
689 .map_err(|e| {
690 match &e {
691 ClientError::Io(io)
692 if io.kind() == std::io::ErrorKind::ConnectionRefused =>
693 {
694 // Multi-line guidance: the fingerprint is what the user
695 // compares against the daemon operator's out-of-band
696 // readout; the two follow-up lines separate the expected
697 // remediation from the warning when the change was NOT
698 // expected.
699 let msg = format!(
700 "handshake with the pinned server key failed: {e}\n\
701 pinned fingerprint for {addr}: {}\n\
702 if the server's key legitimately changed, remove the entry for {addr} from known_servers.toml and reconnect to re-confirm the new fingerprint;\n\
703 if you did NOT expect a change, do not reconnect — investigate the network first",
704 choreo_transport::key::fingerprint(&pinned)
705 );
706 ClientError::Io(std::io::Error::other(msg))
707 }
708 _ => e,
709 }
710 })
711}
712
713/// Serve a connection over an in-process channel pair (the
714/// [`ConnectionMode::InProcess`] mode).
715///
716/// Structure mirrors `serve_noise_connection` EXACTLY, with channels
717/// replacing the transport:
718/// - Reader (the CALLING thread — callers already run this on a spawned
719/// thread): `for message in daemon_rx` forwards each value straight to
720/// `handle_daemon_message`; the loop ends when the daemon drops its sender
721/// (channel close IS the EOF — mapped to the same clean `Ok(())` the unix
722/// path returns on EOF, so `UiEvent::ReaderClosed` semantics are identical).
723/// - Writer: a dedicated thread draining `from_ui` (std mpsc) into
724/// `daemon_tx` — the same `recv_timeout` + shutdown-flag structure the
725/// socket modes use. Dropping `from_ui` ends the writer, which drops the
726/// last client-side `daemon_tx` end — the daemon's embedded connection sees
727/// channel close (= EOF) and runs its normal cleanup.
728///
729/// Shutdown is COOPERATIVE in-process (a deliberate difference from the TCP
730/// path, where `Shutdown::Both` force-kills the socket): an external
731/// `shutdown_rx` signal stops the writer thread via the internal
732/// writer-shutdown channel, but the daemon-side channel can never be
733/// force-closed from here — the reader ends when the EMBEDDED DAEMON closes
734/// its end (its `EmbeddedDaemon::shutdown()` delivers `ShuttingDown` as a
735/// value, then closes the channel, which unblocks this reader exactly the
736/// way a daemon EOF unblocks the socket reader).
737fn run_daemon_connection_in_process(
738 daemon_tx: CrossbeamSender<ClientMessage>,
739 daemon_rx: CrossbeamReceiver<DaemonMessage>,
740 mut handle_daemon_message: impl FnMut(DaemonMessage),
741 from_ui: mpsc::Receiver<ClientMessage>,
742 shutdown_rx: Option<mpsc::Receiver<()>>,
743) {
744 info!("serving in-process (embedded daemon) connection");
745
746 // Internal writer-shutdown channel — same shape as the socket modes: the
747 // reader signals it when it finishes, and the optional external
748 // shutdown signal fans into it too (cooperative stop for the writer
749 // only; see the function doc for why the reader cannot be force-closed).
750 // crossbeam per the workspace's channel-selection convention (this is
751 // new code in choreo-client-core): a one-shot flag channel needs no
752 // payload or backpressure, but new channels default to crossbeam here.
753 // The `from_ui` parameter itself stays std `mpsc`: its type is the
754 // pre-existing public signature shared with the socket modes.
755 let (writer_shutdown_tx, writer_shutdown_rx) = crossbeam_channel::bounded::<()>(0);
756
757 // Writer thread: drains `from_ui` into `daemon_tx` — the identical
758 // recv_timeout + shutdown-check loop the socket writer threads run; a
759 // crossbeam send of a value replaces the socket write, and a failed send
760 // (all daemon-side receivers dropped) is the broken-pipe analogue.
761 let writer_handle = thread::spawn(move || {
762 loop {
763 match from_ui.recv_timeout(SHUTDOWN_POLL_INTERVAL) {
764 Ok(msg) => {
765 if daemon_tx.send(msg).is_err() {
766 warn!("writer thread: daemon receiver gone (embedded connection closed)");
767 break;
768 }
769 }
770 Err(mpsc::RecvTimeoutError::Timeout) => {
771 // Poll the shutdown signal periodically so we don't hang
772 // indefinitely on recv() when the daemon disconnects.
773 // `try_recv` on a zero-capacity channel is the rendezvous-
774 // free obvious check; a success (the reader's send landed)
775 // means stop. `Empty` is the normal in-service case.
776 match writer_shutdown_rx.try_recv() {
777 Ok(()) | Err(crossbeam_channel::TryRecvError::Disconnected) => break,
778 Err(crossbeam_channel::TryRecvError::Empty) => {}
779 }
780 }
781 // `from_ui` closed: the UI is done sending. Dropping
782 // `daemon_tx` (owned by this thread) is what delivers EOF to
783 // the daemon's embedded connection.
784 Err(mpsc::RecvTimeoutError::Disconnected) => break,
785 }
786 }
787 });
788
789 // Optional external shutdown: no socket exists to `Shutdown::Both`, so
790 // the signal only stops the WRITER (cooperative; documented above).
791 if let Some(shutdown_rx) = shutdown_rx {
792 // Clone the sender BEFORE the closure: the reader tail below keeps
793 // its own end to stop the writer when the loop ends.
794 let writer_tx = writer_shutdown_tx.clone();
795 thread::spawn(move || {
796 // A send failure here is benign: the reader finished first and
797 // dropped the writer-shutdown sender side.
798 let _ = shutdown_rx.recv().map(|()| writer_tx.send(()));
799 });
800 }
801
802 // Reader loop: the calling thread. Channel close = clean EOF, exactly the
803 // `Ok(())` the unix path returns on `UnexpectedEof`.
804 for message in daemon_rx {
805 handle_daemon_message(message);
806 }
807 info!("daemon reader loop ended normally (embedded daemon closed its channel)");
808
809 // Signal the writer to stop and wait for it (same tail as the unix path).
810 let _ = writer_shutdown_tx.send(());
811 let _ = writer_handle.join();
812}
813
814/// Connect to a daemon using the given connection mode.
815/// Dispatches to the appropriate connection function.
816///
817/// # Errors
818///
819/// Returns [`ClientError`] as raised by the connection mode actually used
820/// (unix, TCP variants, first-contact preflight, or in-process).
821pub fn run_daemon_connection_with_mode(
822 mode: ConnectionMode,
823 handle_daemon_message: impl FnMut(DaemonMessage),
824 from_ui: mpsc::Receiver<ClientMessage>,
825 shutdown_rx: Option<mpsc::Receiver<()>>,
826) -> Result<(), ClientError> {
827 match mode {
828 ConnectionMode::UnixSocket(path) => {
829 run_daemon_connection(&path, handle_daemon_message, from_ui, shutdown_rx)
830 }
831 ConnectionMode::Tcp { addr, server_pk } => run_daemon_tcp_connection(
832 &addr,
833 &server_pk,
834 handle_daemon_message,
835 from_ui,
836 shutdown_rx,
837 ),
838 ConnectionMode::TcpPinned(addr) => {
839 run_daemon_tcp_connection_pinned(&addr, handle_daemon_message, from_ui, shutdown_rx)
840 }
841 ConnectionMode::InProcess {
842 daemon_tx,
843 daemon_rx,
844 } => {
845 run_daemon_connection_in_process(
846 daemon_tx,
847 daemon_rx,
848 handle_daemon_message,
849 from_ui,
850 shutdown_rx,
851 );
852 Ok(())
853 }
854 }
855}
856
857#[cfg(test)]
858mod in_process_tests {
859 use super::*;
860 use choreo_proto::SessionSummary;
861
862 /// Bare in-process link — two crossbeam channels, NO real daemon. The
863 /// fake-daemon threads below play the daemon side of an `EmbeddedLink`:
864 /// they receive forwarded `ClientMessage`s from `client_rx`, reply
865 /// through their `daemon_tx` end (the daemon→client channel), and
866 /// DROPPING that end is the EOF the reader observes — exactly how the
867 /// real embedded daemon signals disconnect.
868 fn make_link() -> (
869 ConnectionMode,
870 mpsc::Sender<ClientMessage>,
871 mpsc::Receiver<ClientMessage>,
872 crossbeam_channel::Receiver<ClientMessage>,
873 crossbeam_channel::Sender<DaemonMessage>,
874 ) {
875 let (client_tx, client_rx) = crossbeam_channel::unbounded::<ClientMessage>();
876 let (daemon_tx, daemon_rx) = crossbeam_channel::unbounded::<DaemonMessage>();
877 let (from_ui_tx, from_ui_rx) = mpsc::channel::<ClientMessage>();
878 let mode = ConnectionMode::InProcess {
879 daemon_tx: client_tx,
880 daemon_rx,
881 };
882 (mode, from_ui_tx, from_ui_rx, client_rx, daemon_tx)
883 }
884
885 /// Run the connection on a dedicated thread (mirroring production: the
886 /// pump's READER is its calling thread) and return its result plus a
887 /// receiver of every `DaemonMessage` it handled, in order.
888 fn spawn_connection(
889 mode: ConnectionMode,
890 from_ui: mpsc::Receiver<ClientMessage>,
891 handle: impl FnMut(DaemonMessage) + Send + 'static,
892 ) -> thread::JoinHandle<(
893 Result<(), ClientError>,
894 crossbeam_channel::Receiver<DaemonMessage>,
895 )> {
896 // Bounded crossbeam channel between the handler closure and the test:
897 // join-driven, no sleeps anywhere. Capacity is generous enough that
898 // the reader never blocks on it before the daemon closes its end.
899 let (seen_tx, seen_rx) = crossbeam_channel::unbounded::<DaemonMessage>();
900 thread::spawn(move || {
901 let mut handle = handle;
902 let result = run_daemon_connection_with_mode(
903 mode,
904 |message| {
905 let _ = seen_tx.send(message.clone());
906 handle(message);
907 },
908 from_ui,
909 None,
910 );
911 (result, seen_rx)
912 })
913 }
914
915 /// One reply per request, in order: the fake daemon echoes a distinct
916 /// `DaemonMessage` per `ClientMessage` variant, then closes its channel
917 /// (EOF). The reader must receive every reply, in order, and end cleanly.
918 #[test]
919 fn in_process_replies_arrive_in_order_and_close_cleanly() {
920 let (mode, from_ui_tx, from_ui_rx, client_rx, daemon_tx) = make_link();
921 thread::spawn(move || {
922 for msg in client_rx {
923 let reply = match msg {
924 ClientMessage::Ping => DaemonMessage::Pong,
925 ClientMessage::ListModels => DaemonMessage::Models {
926 models: vec!["m1".to_string()],
927 selected_model: None,
928 },
929 ClientMessage::Lock => DaemonMessage::Locked,
930 _ => continue,
931 };
932 if daemon_tx.send(reply).is_err() {
933 break;
934 }
935 }
936 // Dropping `daemon_tx` (and the writer's client-side end) is the
937 // daemon-side EOF.
938 });
939
940 let requests = [
941 ClientMessage::Ping,
942 ClientMessage::ListModels,
943 ClientMessage::Lock,
944 ];
945 for request in &requests {
946 from_ui_tx.send(request.clone()).expect("from_ui open");
947 }
948 // Close `from_ui`: the writer drains everything, then exits, which
949 // closes the client→daemon channel and lets the daemon-side thread
950 // end and close the daemon→client channel.
951 drop(from_ui_tx);
952
953 let (result, seen_rx) = spawn_connection(mode, from_ui_rx, |_| {})
954 .join()
955 .expect("join");
956 result.expect("in-process connection must end cleanly on channel close");
957
958 // Every reply arrived, in order, before the clean EOF.
959 let replies: Vec<DaemonMessage> = seen_rx.into_iter().collect();
960 assert_eq!(
961 replies,
962 vec![
963 DaemonMessage::Pong,
964 DaemonMessage::Models {
965 models: vec!["m1".to_string()],
966 selected_model: None,
967 },
968 DaemonMessage::Locked,
969 ]
970 );
971 }
972
973 /// The writer forwards EVERY `from_ui` message — verified from the
974 /// daemon side: the fake daemon counts what it receives and reports the
975 /// sequence back as `Sessions` replies before closing.
976 #[test]
977 fn in_process_writer_forwards_every_message() {
978 let (mode, from_ui_tx, from_ui_rx, client_rx, daemon_tx) = make_link();
979 thread::spawn(move || {
980 let mut pings = 0usize;
981 for msg in client_rx {
982 if matches!(msg, ClientMessage::Ping) {
983 pings += 1;
984 let _ = daemon_tx.send(DaemonMessage::Sessions {
985 sessions: vec![SessionSummary {
986 session_id: pings as u64,
987 title: None,
988 selected_model: None,
989 parent_session_id: None,
990 working_dir: None,
991 created_at: 0,
992 last_modified: 0,
993 turn_count: 0,
994 status: choreo_proto::SessionStatus::Inactive,
995 active_tool_groups: vec![],
996 account_name: None,
997 reasoning_effort: None,
998 token_usage: None,
999 context_window: None,
1000 last_prompt_tokens: None,
1001 pinned: false,
1002 archived_at: None,
1003 }],
1004 });
1005 }
1006 }
1007 });
1008
1009 // Multiple messages through the writer while it is alive.
1010 for _ in 0..5 {
1011 from_ui_tx.send(ClientMessage::Ping).expect("from_ui open");
1012 }
1013 drop(from_ui_tx);
1014
1015 let (result, seen_rx) = spawn_connection(mode, from_ui_rx, |_| {})
1016 .join()
1017 .expect("join");
1018 result.expect("clean end after from_ui close");
1019 let replies: Vec<u64> = seen_rx
1020 .into_iter()
1021 .map(|m| match m {
1022 DaemonMessage::Sessions { sessions } => sessions[0].session_id,
1023 other => panic!("unexpected message: {other:?}"),
1024 })
1025 .collect();
1026 assert_eq!(
1027 replies,
1028 vec![1, 2, 3, 4, 5],
1029 "every forwarded Ping must be answered, in order"
1030 );
1031 }
1032
1033 /// Daemon-side close ends the connection cleanly even while the UI is
1034 /// still open: the reader ends on `daemon_rx` exhaustion (the EOF), the
1035 /// writer is stopped through the internal shutdown channel and joined.
1036 #[test]
1037 fn in_process_daemon_close_ends_connection_while_ui_open() {
1038 let (mode, from_ui_tx, from_ui_rx, client_rx, daemon_tx) = make_link();
1039 thread::spawn(move || {
1040 // One advisory message, then the daemon drops its end: the
1041 // channel-close EOF. `client_rx` is dropped un-received — the
1042 // daemon may vanish while client messages are still in flight.
1043 let _ = client_rx;
1044 let _ = daemon_tx.send(DaemonMessage::ShuttingDown);
1045 drop(daemon_tx);
1046 });
1047
1048 // The UI sender stays OPEN for the whole test: the connection must
1049 // still end because the daemon side is gone.
1050 from_ui_tx.send(ClientMessage::Ping).expect("from_ui open");
1051
1052 let (result, seen_rx) = spawn_connection(mode, from_ui_rx, |_| {})
1053 .join()
1054 .expect("join");
1055 result.expect("daemon-side close must be a clean EOF");
1056 let replies: Vec<DaemonMessage> = seen_rx.into_iter().collect();
1057 assert_eq!(replies, vec![DaemonMessage::ShuttingDown]);
1058 // The pump must NOT have closed `from_ui` itself (it only ever
1059 // drains it): a late send is delivered into the channel — it simply
1060 // has no consumer. It must not panic or error.
1061 let _ = from_ui_tx.send(ClientMessage::Ping);
1062 }
1063
1064 /// The manual `Debug` impl must keep the derived shapes for the socket
1065 /// variants (log/error messages rely on them) and render the embedded
1066 /// link as an opaque placeholder.
1067 #[test]
1068 fn debug_output_matches_previous_derive_shapes() {
1069 assert_eq!(
1070 format!("{:?}", ConnectionMode::UnixSocket("/tmp/sock".to_string())),
1071 r#"UnixSocket("/tmp/sock")"#
1072 );
1073 let mut pk = [0u8; 32];
1074 pk[0] = 1;
1075 pk[31] = 255;
1076 assert_eq!(
1077 format!(
1078 "{:?}",
1079 ConnectionMode::Tcp {
1080 addr: "127.0.0.1:9443".to_string(),
1081 server_pk: pk
1082 }
1083 ),
1084 format!("Tcp {{ addr: \"127.0.0.1:9443\", server_pk: {:?} }}", pk)
1085 );
1086 assert_eq!(
1087 format!("{:?}", ConnectionMode::TcpPinned("a:1".to_string())),
1088 r#"TcpPinned("a:1")"#
1089 );
1090 let (mode, _from_ui_tx, _from_ui_rx, _client_rx, daemon_tx) = make_link();
1091 assert_eq!(format!("{mode:?}"), "InProcess(<embedded link>)");
1092 // The ends themselves are opaque and dropped with the mode.
1093 drop(daemon_tx);
1094 }
1095
1096 /// The autostart hook must fire ONLY when the dial finds nothing
1097 /// listening (`NotFound` here: no socket file at all). The hook deliberately
1098 /// fails, so a successful run would be impossible — the pinned outcome is
1099 /// exactly one hook invocation and the hook's error surfacing as the
1100 /// connection result. (The live-listener half of this contract binds real
1101 /// sockets, so it lives in `tests/it/connection_autostart.rs` — no filesystem
1102 /// or IPC boundary in unit tests.)
1103 #[test]
1104 fn autostart_hook_invoked_when_nothing_listens() {
1105 let path = std::env::temp_dir().join(format!(
1106 "choreo-core-autostart-absent-{}-{}",
1107 std::process::id(),
1108 format!("{:?}", std::thread::current().id()).as_str()
1109 ));
1110 let _ = std::fs::remove_file(&path); // absent by construction
1111 let path = path.to_string_lossy().into_owned();
1112
1113 let mut hook_calls = 0;
1114 let mut ensure_daemon = || {
1115 hook_calls += 1;
1116 Err(ClientError::DaemonStart("test: no daemon".to_string()))
1117 };
1118 let (from_ui_tx, from_ui_rx) = mpsc::channel::<ClientMessage>();
1119 drop(from_ui_tx); // the pump's writer thread ends immediately
1120
1121 let error = run_daemon_connection_with_autostart(
1122 &path,
1123 &mut ensure_daemon,
1124 |_| {},
1125 from_ui_rx,
1126 None,
1127 )
1128 .expect_err("the failing hook must fail the connection");
1129
1130 assert!(matches!(error, ClientError::DaemonStart(_)));
1131 assert_eq!(hook_calls, 1, "the hook must run exactly once");
1132 let _ = std::fs::remove_file(&path);
1133 }
1134}