Skip to main content

netget/client/finger/
mod.rs

1//! Finger (RFC 1288) client.
2//!
3//! One TCP connection carries exactly one query line and one free-text answer, and the
4//! **server** closes when it is done. A finger client is therefore defined by reading to EOF:
5//! `run_query` does not stop at the first `read()`, and everything else in this file follows
6//! from that.
7//!
8//! Nothing here parses the answer. RFC 1288 specifies no format for it, so the text goes to the
9//! model and the model decides what it means — the `best_effort` block on the response event is
10//! a guess and says so in its own description.
11//!
12//! Two things are deliberate and worth reading before changing them:
13//!
14//! * **`user@host` forwarding is refused before anything is written.** RFC 1288 §3.2.1 calls
15//!   forwarding a security risk; as the *client* we would be the one asking a stranger's server
16//!   to relay on our behalf. `allow_forwarding` (startup parameter, default false) is the only
17//!   way to turn it on, and `send_finger_query` rejects an `@` inside `username`, so it can
18//!   never happen implicitly.
19//! * **A follow-up query opens a new connection, and raises the response event again.** RFC
20//!   1288 is one query per connection, so a second query on the same socket would be ignored by
21//!   every real server. The chain is bounded by [`MAX_FOLLOWUP_DEPTH`] and one query per turn,
22//!   and the recursive call is boxed — the shape that has run away in this repo before.
23
24pub mod actions;
25
26pub use actions::FingerClientProtocol;
27
28use anyhow::{Context, Result};
29use std::net::SocketAddr;
30use std::sync::Arc;
31use std::time::Duration;
32use tokio::io::{AsyncReadExt, AsyncWriteExt};
33use tokio::net::TcpStream;
34use tokio::sync::{mpsc, Mutex};
35use tracing::{debug, error, info, trace, warn};
36
37use crate::client::llm_budget::call_llm_for_client;
38use crate::llm::actions::client_trait::{Client, ClientActionResult};
39use crate::llm::actions::protocol_trait::Protocol;
40use crate::llm::ollama_client::OllamaClient;
41use crate::llm::ClientLlmResult;
42use crate::protocol::Event;
43use crate::state::app_state::AppState;
44use crate::state::client_handles::{ClientCommand, ClientSendOutcome};
45use crate::state::{AccessLogOwner, ClientId, ClientStatus};
46use crate::utils::truncate_for_log;
47
48use actions::{
49    best_effort_fields, sanitize_response, FingerQuerySpec, DEFAULT_FINGER_PORT,
50    FINGER_CLIENT_CONNECTED_EVENT, FINGER_CLIENT_RESPONSE_RECEIVED_EVENT, MAX_FOLLOWUP_DEPTH,
51    MAX_RESPONSE_BYTES,
52};
53
54/// Settings read from the declared startup parameters.
55#[derive(Clone, Copy, Debug)]
56pub struct FingerClientConfig {
57    /// Port used when `remote_addr` carries none of its own.
58    pub default_port: u16,
59    /// Whether `user@host` forwarding queries may reach the wire.
60    pub allow_forwarding: bool,
61    /// Seconds to wait for the server's answer; 0 means wait indefinitely.
62    pub response_timeout_secs: u64,
63}
64
65impl Default for FingerClientConfig {
66    fn default() -> Self {
67        Self {
68            default_port: DEFAULT_FINGER_PORT,
69            allow_forwarding: actions::DEFAULT_ALLOW_FORWARDING,
70            response_timeout_secs: actions::DEFAULT_RESPONSE_TIMEOUT_SECS,
71        }
72    }
73}
74
75impl FingerClientConfig {
76    fn response_timeout(&self) -> Option<Duration> {
77        if self.response_timeout_secs == 0 {
78            None
79        } else {
80            Some(Duration::from_secs(self.response_timeout_secs))
81        }
82    }
83}
84
85/// The query that actually went on the wire, whichever path put it there (the model's
86/// `send_finger_query` or one injected from the dashboard). Read once the server closes, so the
87/// response event names the query it answers.
88type SentQuery = Arc<std::sync::Mutex<Option<FingerQuerySpec>>>;
89
90/// What one executed action did.
91enum Applied {
92    /// Bytes written (0 when the action produced no wire output).
93    Sent(usize),
94    /// A forwarding query was refused by policy; nothing was written.
95    Refused(String),
96    /// The write side was shut down and the session should end.
97    Disconnect,
98}
99
100/// What one read-to-EOF produced.
101#[derive(Clone, Debug)]
102struct Response {
103    /// Sanitised text: control characters stripped, line endings normalised to LF.
104    text: String,
105    /// Bytes actually read off the socket.
106    bytes: usize,
107    /// The server closed the connection, so the answer is complete.
108    eof: bool,
109    /// [`MAX_RESPONSE_BYTES`] was reached and the rest discarded.
110    truncated: bool,
111}
112
113/// Everything a follow-up query needs. Owned and `'static`, so the boxed recursive future is
114/// `Send` and can live inside a `tokio::spawn`.
115struct SessionCtx {
116    protocol: Arc<FingerClientProtocol>,
117    llm_client: OllamaClient,
118    app_state: Arc<AppState>,
119    status_tx: mpsc::UnboundedSender<String>,
120    client_id: ClientId,
121    target: String,
122    instruction: String,
123    config: FingerClientConfig,
124}
125
126/// Resolve `remote_addr` to a `host:port` this client can connect to.
127///
128/// An explicit port in `remote_addr` always wins; the `port` startup parameter (or 79) fills in
129/// when there is none. Bare and bracketed IPv6 literals are handled, because `[::1]` and `::1`
130/// both contain colons and a naive "does it contain ':'" test reads the second as having a port.
131pub fn resolve_target(remote_addr: &str, default_port: u16) -> String {
132    let addr = remote_addr.trim();
133
134    if let Some(rest) = addr.strip_prefix('[') {
135        // Bracketed IPv6: "[::1]" or "[::1]:7979".
136        return match rest.find(']') {
137            Some(close) if addr.len() > close + 2 && addr.as_bytes()[close + 2] == b':' => {
138                addr.to_string()
139            }
140            Some(_) => format!("{addr}:{default_port}"),
141            None => format!("{addr}:{default_port}"),
142        };
143    }
144
145    match addr.matches(':').count() {
146        0 => format!("{addr}:{default_port}"),
147        1 => addr.to_string(),
148        // Bare IPv6 literal with no port: brackets are required before one can be appended.
149        _ => format!("[{addr}]:{default_port}"),
150    }
151}
152
153pub struct FingerClient;
154
155impl FingerClient {
156    /// Connect to a finger server and drive it with the LLM.
157    pub async fn connect_with_llm_actions(
158        remote_addr: String,
159        config: FingerClientConfig,
160        llm_client: OllamaClient,
161        app_state: Arc<AppState>,
162        status_tx: mpsc::UnboundedSender<String>,
163        client_id: ClientId,
164    ) -> Result<SocketAddr> {
165        let target = resolve_target(&remote_addr, config.default_port);
166
167        let stream = TcpStream::connect(&target)
168            .await
169            .with_context(|| format!("Failed to connect to Finger server at {target}"))?;
170
171        let local_addr = stream.local_addr()?;
172        let remote_sock_addr = stream.peer_addr()?;
173
174        info!(
175            "Finger client {} connected to {} (local: {})",
176            client_id, remote_sock_addr, local_addr
177        );
178        if config.allow_forwarding {
179            warn!(
180                "Finger client {} allow_forwarding=true: 'user@host' queries will be put on the \
181                 wire, asking {} to relay on our behalf (RFC 1288 3.2.1 calls this a security \
182                 risk)",
183                client_id, target
184            );
185            let _ = status_tx.send(format!(
186                "[CLIENT] ⚠ Finger client {} will send forwarding queries (allow_forwarding=true)",
187                client_id
188            ));
189        }
190
191        app_state
192            .update_client_status(client_id, ClientStatus::Connected)
193            .await;
194        let _ = status_tx.send(format!("[CLIENT] Finger client {} connected", client_id));
195        let _ = status_tx.send("__UPDATE_UI__".to_string());
196
197        let (read_half, write_half) = tokio::io::split(stream);
198        let write_half = Arc::new(Mutex::new(write_half));
199        let protocol = Arc::new(FingerClientProtocol::new());
200        let sent_query: SentQuery = Arc::new(std::sync::Mutex::new(None));
201
202        // Registered BEFORE the connected-event LLM call, which a manual `*` rule can park for
203        // as long as the operator takes. Until registration the dashboard reports "no command
204        // channel", which reads as a protocol limitation when it is only a queue.
205        let command_rx =
206            crate::client::command_support::register_command_channel(&app_state, client_id).await;
207        let cmd_task = tokio::spawn(Self::command_loop(
208            command_rx,
209            protocol.clone(),
210            write_half.clone(),
211            sent_query.clone(),
212            config,
213            client_id,
214            app_state.clone(),
215            status_tx.clone(),
216        ));
217        app_state.register_client_task(client_id, cmd_task).await;
218
219        let task_registrar = app_state.clone();
220        let session_state = app_state.clone();
221        let session_status = status_tx.clone();
222        let task_handle = tokio::spawn(async move {
223            Self::session(
224                read_half,
225                write_half,
226                protocol,
227                sent_query,
228                target,
229                config,
230                llm_client,
231                session_state.clone(),
232                session_status.clone(),
233                client_id,
234            )
235            .await;
236
237            // The session is over: drop the handle so the dashboard stops offering [ send ]
238            // and the command task ends with its channel.
239            session_state.remove_client_handle(client_id).await;
240            session_state
241                .update_client_status(client_id, ClientStatus::Disconnected)
242                .await;
243            let _ =
244                session_status.send(format!("[CLIENT] Finger client {} disconnected", client_id));
245            let _ = session_status.send("__UPDATE_UI__".to_string());
246        });
247        task_registrar
248            .register_client_task(client_id, task_handle)
249            .await;
250
251        Ok(local_addr)
252    }
253
254    /// connected event -> one query -> read until the server closes -> response event.
255    #[allow(clippy::too_many_arguments)]
256    async fn session<R, W>(
257        read_half: R,
258        write_half: Arc<Mutex<W>>,
259        protocol: Arc<FingerClientProtocol>,
260        sent_query: SentQuery,
261        target: String,
262        config: FingerClientConfig,
263        llm_client: OllamaClient,
264        app_state: Arc<AppState>,
265        status_tx: mpsc::UnboundedSender<String>,
266        client_id: ClientId,
267    ) where
268        R: tokio::io::AsyncRead + Unpin,
269        W: tokio::io::AsyncWrite + Unpin,
270    {
271        let Some(instruction) = app_state.get_instruction_for_client(client_id).await else {
272            warn!(
273                "Finger client {} has no instruction; nothing will be asked",
274                client_id
275            );
276            return;
277        };
278
279        let ctx = Arc::new(SessionCtx {
280            protocol: protocol.clone(),
281            llm_client,
282            app_state: app_state.clone(),
283            status_tx: status_tx.clone(),
284            client_id,
285            target: target.clone(),
286            instruction,
287            config,
288        });
289
290        let event = Event::new(
291            &FINGER_CLIENT_CONNECTED_EVENT,
292            serde_json::json!({
293                "remote_addr": target,
294                "allow_forwarding": config.allow_forwarding,
295            }),
296        );
297
298        let memory = app_state
299            .get_memory_for_client(client_id)
300            .await
301            .unwrap_or_default();
302
303        let mut hung_up = false;
304        match call_llm_for_client(
305            &ctx.llm_client,
306            &app_state,
307            client_id.to_string(),
308            &ctx.instruction,
309            &memory,
310            Some(&event),
311            protocol.as_ref(),
312            &status_tx,
313        )
314        .await
315        {
316            Ok(ClientLlmResult {
317                actions,
318                memory_updates,
319            }) => {
320                if let Some(mem) = memory_updates {
321                    app_state.set_memory_for_client(client_id, mem).await;
322                }
323
324                // The model's answer is executed, never counted and logged. A finger client
325                // that connects and asks nothing is the whole failure mode this guards.
326                for action in actions {
327                    let result = match protocol.execute_action(action.clone()) {
328                        Ok(result) => result,
329                        Err(e) => {
330                            error!("Finger client {} rejected action: {}", client_id, e);
331                            let _ = status_tx.send(format!(
332                                "[CLIENT] ✖ Finger client {} rejected an action: {}",
333                                client_id, e
334                            ));
335                            continue;
336                        }
337                    };
338                    match Self::apply_action(result, &write_half, &sent_query, config, client_id)
339                        .await
340                    {
341                        Ok(Applied::Sent(_)) => {}
342                        Ok(Applied::Refused(reason)) => {
343                            warn!("Finger client {} {}", client_id, reason);
344                            let _ = status_tx
345                                .send(format!("[CLIENT] ⚠ Finger client {} {}", client_id, reason));
346                        }
347                        Ok(Applied::Disconnect) => {
348                            info!(
349                                "Finger client {} disconnected before sending a query",
350                                client_id
351                            );
352                            hung_up = true;
353                            break;
354                        }
355                        Err(e) => {
356                            error!("Finger client {} failed to send query: {}", client_id, e);
357                            app_state
358                                .update_client_status(client_id, ClientStatus::Error(e.to_string()))
359                                .await;
360                            let _ = status_tx.send("__UPDATE_UI__".to_string());
361                            return;
362                        }
363                    }
364                }
365            }
366            Err(e) => {
367                // Stay connected: the operator can still inject a query from the dashboard,
368                // and a finger server waits for one rather than closing on its own.
369                error!("LLM error for Finger client {}: {}", client_id, e);
370                let _ = status_tx.send(format!(
371                    "[CLIENT] ⚠ Finger client {} LLM error: {} (still connected; a query can be \
372                     injected)",
373                    client_id, e
374                ));
375            }
376        }
377
378        if hung_up {
379            let _ = write_half.lock().await.shutdown().await;
380            return;
381        }
382
383        if sent_query.lock().map(|q| q.is_none()).unwrap_or(true) {
384            info!(
385                "Finger client {} has no query on the wire yet; waiting for an injected one or \
386                 for {} to close",
387                client_id, target
388            );
389        }
390
391        let response = Self::read_response(read_half, config.response_timeout(), client_id).await;
392        debug!(
393            "Finger client {} read {} bytes (eof={}, truncated={})",
394            client_id, response.bytes, response.eof, response.truncated
395        );
396        trace!(
397            "Finger client {} response: {}",
398            client_id,
399            truncate_for_log(&response.text, 4096)
400        );
401
402        // The socket is finished with, so the dashboard must stop offering [ send ] on it now
403        // rather than when the follow-up chain below runs out.
404        app_state.remove_client_handle(client_id).await;
405        let _ = status_tx.send("__UPDATE_UI__".to_string());
406
407        let spec = sent_query.lock().ok().and_then(|q| q.clone());
408        match spec {
409            Some(spec) => {
410                let _ = status_tx.send(format!(
411                    "[CLIENT] Finger client {} received {} bytes for {:?}",
412                    client_id,
413                    response.bytes,
414                    spec.query_text()
415                ));
416                Self::handle_response(ctx, spec, response, 0).await;
417            }
418            None => {
419                info!(
420                    "Finger client {} closed with no query ever sent ({} bytes read)",
421                    client_id, response.bytes
422                );
423            }
424        }
425    }
426
427    /// Raise `finger_response_received`, then act on the answer.
428    ///
429    /// The model may reply with another `send_finger_query`. RFC 1288 is one query per
430    /// connection — a second one on the same socket is ignored by every real server — so a
431    /// follow-up opens a **fresh** connection and raises this event again.
432    ///
433    /// That is self-referential, which is why the recursion is boxed (an `async fn` awaiting
434    /// itself has an infinitely-sized future) and bounded twice: [`MAX_FOLLOWUP_DEPTH`] limits
435    /// the chain, and at most one query per turn is followed so the chain cannot fan out.
436    fn handle_response(
437        ctx: Arc<SessionCtx>,
438        spec: FingerQuerySpec,
439        response: Response,
440        depth: usize,
441    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>> {
442        Box::pin(async move {
443            let client_id = ctx.client_id;
444
445            let event = Event::new(
446                &FINGER_CLIENT_RESPONSE_RECEIVED_EVENT,
447                serde_json::json!({
448                    "response": response.text,
449                    "query": spec.query_text(),
450                    "username": spec.username,
451                    "verbose": spec.verbose,
452                    "forward_host": spec.forward_host,
453                    "bytes": response.bytes,
454                    "eof": response.eof,
455                    "truncated": response.truncated,
456                    "best_effort": best_effort_fields(&response.text),
457                }),
458            );
459
460            let memory = ctx
461                .app_state
462                .get_memory_for_client(client_id)
463                .await
464                .unwrap_or_default();
465
466            let result = call_llm_for_client(
467                &ctx.llm_client,
468                &ctx.app_state,
469                client_id.to_string(),
470                &ctx.instruction,
471                &memory,
472                Some(&event),
473                ctx.protocol.as_ref(),
474                &ctx.status_tx,
475            )
476            .await;
477
478            let ClientLlmResult {
479                actions,
480                memory_updates,
481            } = match result {
482                Ok(result) => result,
483                Err(e) => {
484                    error!(
485                        "LLM error for Finger client {} on response event: {}",
486                        client_id, e
487                    );
488                    return;
489                }
490            };
491
492            if let Some(mem) = memory_updates {
493                ctx.app_state.set_memory_for_client(client_id, mem).await;
494            }
495
496            // Execute the answer. A follow-up is a whole new connection, so only the first
497            // query in one answer is followed; the rest are refused out loud rather than
498            // quietly dropped, because k queries per turn at depth d is k^d connections.
499            let mut followed = false;
500            for action in actions {
501                let result = match ctx.protocol.execute_action(action.clone()) {
502                    Ok(result) => result,
503                    Err(e) => {
504                        error!("Finger client {} rejected action: {}", client_id, e);
505                        continue;
506                    }
507                };
508
509                match result {
510                    ClientActionResult::Custom { name, data } if name == "finger_query" => {
511                        let next = match FingerQuerySpec::from_json(&data) {
512                            Ok(next) => next,
513                            Err(e) => {
514                                error!("Finger client {} built an invalid query: {}", client_id, e);
515                                continue;
516                            }
517                        };
518
519                        if next.is_forwarding() && !ctx.config.allow_forwarding {
520                            warn!(
521                                "Finger client {} decision=forward_refused query={:?} \
522                                 forward_host={:?}: RFC 1288 3.2.1 calls forwarding a security \
523                                 risk; start the client with allow_forwarding=true to permit it",
524                                client_id,
525                                next.query_text(),
526                                next.forward_host
527                            );
528                            let _ = ctx.status_tx.send(format!(
529                                "[CLIENT] ⚠ Finger client {} refused a forwarding follow-up",
530                                client_id
531                            ));
532                            continue;
533                        }
534
535                        if followed {
536                            warn!(
537                                "Finger client {} ignoring extra follow-up query {:?}: one query \
538                                 per turn, because each needs its own connection",
539                                client_id,
540                                next.query_text()
541                            );
542                            continue;
543                        }
544                        if depth + 1 >= MAX_FOLLOWUP_DEPTH {
545                            warn!(
546                                "Finger client {} reached MAX_FOLLOWUP_DEPTH ({}); not following \
547                                 up with {:?}",
548                                client_id,
549                                MAX_FOLLOWUP_DEPTH,
550                                next.query_text()
551                            );
552                            let _ = ctx.status_tx.send(format!(
553                                "[CLIENT] ⚠ Finger client {} hit the follow-up depth limit",
554                                client_id
555                            ));
556                            continue;
557                        }
558                        followed = true;
559
560                        info!(
561                            "Finger client {} following up with {:?} on a new connection to {} \
562                             (depth {})",
563                            client_id,
564                            next.query_text(),
565                            ctx.target,
566                            depth + 1
567                        );
568
569                        match Self::run_query(&ctx.target, &next, ctx.config.response_timeout())
570                            .await
571                        {
572                            Ok(next_response) => {
573                                let _ = ctx.status_tx.send(format!(
574                                    "[CLIENT] Finger client {} received {} bytes for {:?}",
575                                    client_id,
576                                    next_response.bytes,
577                                    next.query_text()
578                                ));
579                                Self::handle_response(ctx.clone(), next, next_response, depth + 1)
580                                    .await;
581                            }
582                            Err(e) => {
583                                error!(
584                                    "Finger client {} follow-up query {:?} failed: {}",
585                                    client_id,
586                                    next.query_text(),
587                                    e
588                                );
589                                let _ = ctx.status_tx.send(format!(
590                                    "[CLIENT] ✖ Finger client {} follow-up failed: {}",
591                                    client_id, e
592                                ));
593                            }
594                        }
595                    }
596                    ClientActionResult::Disconnect => {
597                        debug!(
598                            "Finger client {} asked to disconnect; the server already closed",
599                            client_id
600                        );
601                    }
602                    ClientActionResult::WaitForMore => {
603                        debug!(
604                            "Finger client {} waiting; the server already closed this connection",
605                            client_id
606                        );
607                    }
608                    other => {
609                        trace!(
610                            "Finger client {} produced an inert result: {:?}",
611                            client_id,
612                            other
613                        );
614                    }
615                }
616            }
617        })
618    }
619
620    /// Open a fresh connection, ask one question, read the answer to EOF, close.
621    ///
622    /// The new connection is not incidental: RFC 1288 gives the server one query and then has
623    /// it close, so a follow-up genuinely needs its own.
624    async fn run_query(
625        target: &str,
626        spec: &FingerQuerySpec,
627        timeout: Option<Duration>,
628    ) -> Result<Response> {
629        let stream = TcpStream::connect(target)
630            .await
631            .with_context(|| format!("Finger follow-up could not reach {target}"))?;
632        let (read_half, mut write_half) = tokio::io::split(stream);
633
634        write_half
635            .write_all(spec.to_wire_line().as_bytes())
636            .await
637            .context("Finger follow-up failed to write the query")?;
638        write_half
639            .flush()
640            .await
641            .context("Finger follow-up failed to flush the query")?;
642
643        Ok(Self::read_response_inner(read_half, timeout).await)
644    }
645
646    /// Read until the server closes, bounded by size and by time.
647    ///
648    /// A finger client is *defined* by reading to EOF — the server closes when its answer is
649    /// finished and there is no length anywhere in the protocol. Both bounds exist because a
650    /// server that never closes would otherwise park this task forever and a server that
651    /// streams would fill memory.
652    async fn read_response<R>(
653        read_half: R,
654        timeout: Option<Duration>,
655        client_id: ClientId,
656    ) -> Response
657    where
658        R: tokio::io::AsyncRead + Unpin,
659    {
660        let response = Self::read_response_inner(read_half, timeout).await;
661        if !response.eof && !response.truncated {
662            warn!(
663                "Finger client {} stopped reading after {} bytes without seeing EOF; the server \
664                 did not close",
665                client_id, response.bytes
666            );
667        }
668        if response.truncated {
669            warn!(
670                "Finger client {} truncated the response at {} bytes",
671                client_id, MAX_RESPONSE_BYTES
672            );
673        }
674        response
675    }
676
677    async fn read_response_inner<R>(mut read_half: R, timeout: Option<Duration>) -> Response
678    where
679        R: tokio::io::AsyncRead + Unpin,
680    {
681        let mut raw: Vec<u8> = Vec::new();
682        let mut truncated = false;
683
684        let read_loop = async {
685            let mut buf = vec![0u8; 4096];
686            loop {
687                match read_half.read(&mut buf).await {
688                    Ok(0) => return true,
689                    Ok(n) => {
690                        let room = MAX_RESPONSE_BYTES.saturating_sub(raw.len());
691                        if n >= room {
692                            raw.extend_from_slice(&buf[..room]);
693                            truncated = true;
694                            return false;
695                        }
696                        raw.extend_from_slice(&buf[..n]);
697                    }
698                    Err(e) => {
699                        debug!("Finger client read error: {}", e);
700                        return false;
701                    }
702                }
703            }
704        };
705
706        let eof = match timeout {
707            Some(duration) => tokio::time::timeout(duration, read_loop)
708                .await
709                .unwrap_or(false),
710            None => read_loop.await,
711        };
712
713        let bytes = raw.len();
714        Response {
715            text: sanitize_response(&String::from_utf8_lossy(&raw)),
716            bytes,
717            eof,
718            truncated,
719        }
720    }
721
722    /// Put one executed action on the wire.
723    ///
724    /// Shared by the LLM path and the dashboard's injected commands, so the query encoding —
725    /// and the forwarding refusal — exist in exactly one place. The refusal lives here rather
726    /// than in `execute_action` because this is where the startup parameter is: the executor
727    /// decides what a *legal* query is, the loop decides what this client is *allowed* to send.
728    async fn apply_action<W>(
729        result: ClientActionResult,
730        write_half: &Arc<Mutex<W>>,
731        sent_query: &SentQuery,
732        config: FingerClientConfig,
733        client_id: ClientId,
734    ) -> Result<Applied>
735    where
736        W: tokio::io::AsyncWrite + Unpin,
737    {
738        match result {
739            ClientActionResult::Custom { name, data } if name == "finger_query" => {
740                let spec = FingerQuerySpec::from_json(&data)?;
741
742                if spec.is_forwarding() && !config.allow_forwarding {
743                    let reason = format!(
744                        "decision=forward_refused query={:?} forward_host={:?}: RFC 1288 3.2.1 \
745                         calls 'user@host' forwarding a security risk, and this client was not \
746                         started with allow_forwarding=true. Nothing was sent.",
747                        spec.query_text(),
748                        spec.forward_host
749                    );
750                    return Ok(Applied::Refused(reason));
751                }
752
753                let line = spec.to_wire_line();
754                debug!(
755                    "Finger client {} querying: {:?}",
756                    client_id,
757                    spec.query_text()
758                );
759                {
760                    let mut writer = write_half.lock().await;
761                    writer.write_all(line.as_bytes()).await?;
762                    writer.flush().await?;
763                }
764                if let Ok(mut slot) = sent_query.lock() {
765                    // RFC 1288 is one query per connection: the first is the one the response
766                    // answers, and a second on the same socket is ignored by real servers.
767                    slot.get_or_insert(spec);
768                }
769                Ok(Applied::Sent(line.len()))
770            }
771            ClientActionResult::Disconnect => {
772                debug!("Finger client {} disconnecting", client_id);
773                // Half-close: the server reads EOF and closes, and the read loop then sees 0
774                // and runs its normal path.
775                let _ = write_half.lock().await.shutdown().await;
776                Ok(Applied::Disconnect)
777            }
778            // WaitForMore, NoAction, SendData, an unknown Custom, a nested Multiple.
779            _ => Ok(Applied::Sent(0)),
780        }
781    }
782
783    /// Drain injected commands until the channel closes (session over, or client removed) or
784    /// an injected `disconnect` ends the session.
785    ///
786    /// The generic `command_support::handle_stream_client_command` cannot run this client's
787    /// vocabulary, because `send_finger_query` yields `ClientActionResult::Custom` and the
788    /// generic arm has no way to encode one. So the action goes through
789    /// [`Self::apply_action`] — the same function the LLM path uses — and the outcome is
790    /// recorded and replied exactly the way the generic arm does it.
791    #[allow(clippy::too_many_arguments)]
792    async fn command_loop<W>(
793        mut command_rx: mpsc::Receiver<ClientCommand>,
794        protocol: Arc<FingerClientProtocol>,
795        write_half: Arc<Mutex<W>>,
796        sent_query: SentQuery,
797        config: FingerClientConfig,
798        client_id: ClientId,
799        app_state: Arc<AppState>,
800        status_tx: mpsc::UnboundedSender<String>,
801    ) where
802        W: tokio::io::AsyncWrite + Unpin,
803    {
804        while let Some(command) = command_rx.recv().await {
805            let action = command.action.clone();
806            let outcome = match protocol.execute_action(action.clone()) {
807                Err(e) => Ok(ClientSendOutcome::Rejected {
808                    error: e.to_string(),
809                }),
810                Ok(result) => {
811                    Self::apply_action(result, &write_half, &sent_query, config, client_id)
812                        .await
813                        .map(|applied| match applied {
814                            Applied::Disconnect => ClientSendOutcome::Disconnected,
815                            Applied::Refused(reason) => {
816                                ClientSendOutcome::Rejected { error: reason }
817                            }
818                            Applied::Sent(0) => ClientSendOutcome::Executed {
819                                detail: "executed (nothing to write)".to_string(),
820                            },
821                            Applied::Sent(bytes_sent) => ClientSendOutcome::Sent { bytes_sent },
822                        })
823                }
824            };
825
826            let outcome_json = match &outcome {
827                Ok(outcome) => serde_json::to_value(outcome).unwrap_or(serde_json::Value::Null),
828                Err(e) => serde_json::json!({"error": e.to_string()}),
829            };
830            app_state
831                .record_access_log(
832                    AccessLogOwner::Client(client_id.as_u32()),
833                    protocol.protocol_name(),
834                    None,
835                    "injected_action",
836                    action,
837                    vec![outcome_json],
838                )
839                .await;
840
841            let disconnect = matches!(outcome, Ok(ClientSendOutcome::Disconnected));
842            match &outcome {
843                Ok(ClientSendOutcome::Rejected { error }) => {
844                    warn!(
845                        "Finger client {} refused an injected action: {}",
846                        client_id, error
847                    );
848                    let _ = status_tx.send(format!(
849                        "[CLIENT] ⚠ Finger client {} refused an injected action: {}",
850                        client_id, error
851                    ));
852                }
853                Err(e) => {
854                    error!("Finger client {} injected action failed: {}", client_id, e);
855                    let _ = status_tx.send(format!(
856                        "[CLIENT] ✖ Finger client {} injected action failed: {}",
857                        client_id, e
858                    ));
859                }
860                _ => {}
861            }
862            let _ = status_tx.send("__UPDATE_UI__".to_string());
863            crate::client::command_support::reply(command, outcome);
864
865            if disconnect {
866                // Do not wait for the server to answer the half-close with its own FIN: the
867                // rail must stop offering [ send ] on this client now.
868                app_state.remove_client_handle(client_id).await;
869                let _ = status_tx.send("__UPDATE_UI__".to_string());
870                break;
871            }
872        }
873    }
874}