Skip to main content

netget/client/finger/
actions.rs

1//! Finger (RFC 1288) client protocol actions.
2//!
3//! The client's whole vocabulary is one query and two ways of ending the session. Finger's
4//! answer is **free text by design** — RFC 1288 specifies no format for it at all — so nothing
5//! here parses it. The text goes to the model verbatim and the model decides what it means;
6//! that is the entire point of NetGet. The one concession is a small `best_effort` block on the
7//! response event, and it is named that way because it is a guess: half the world's finger
8//! daemons print something a `Login:`/`Name:` reader would get wrong.
9
10use crate::llm::actions::{
11    client_trait::{Client, ClientActionResult},
12    protocol_trait::Protocol,
13    ActionDefinition, Parameter, ParameterDefinition,
14};
15use crate::protocol::log_template::LogTemplate;
16use crate::protocol::EventType;
17use crate::state::app_state::AppState;
18use anyhow::{bail, Context, Result};
19use serde_json::json;
20use std::sync::LazyLock;
21
22/// TCP port assigned to finger (`/etc/services`: `finger 79/tcp`).
23///
24/// Used only when `remote_addr` carries no port of its own; see the `port` startup parameter.
25pub const DEFAULT_FINGER_PORT: u16 = 79;
26
27/// Default for the `allow_forwarding` startup parameter.
28///
29/// RFC 1288 §3.2.1 names `user@host` forwarding a security risk. Asking a finger server to
30/// relay on your behalf is the client half of that risk, so it is refused unless the operator
31/// turns it on by name.
32pub const DEFAULT_ALLOW_FORWARDING: bool = false;
33
34/// Default for the `response_timeout_secs` startup parameter.
35pub const DEFAULT_RESPONSE_TIMEOUT_SECS: u64 = 30;
36
37/// Cap on one response.
38///
39/// A finger answer is a few hundred bytes; RFC 1288 sets no limit and a hostile server can
40/// stream forever, so the read stops here and the event says `truncated`.
41pub const MAX_RESPONSE_BYTES: usize = 65_536;
42
43/// How many times the model may answer a response with another query.
44///
45/// Every follow-up needs its own TCP connection (RFC 1288 is one query per connection), and
46/// each one raises `finger_response_received` again — which is exactly the self-referential
47/// shape that has run away in this repo before. The bound is the answer, not silence.
48pub const MAX_FOLLOWUP_DEPTH: usize = 4;
49
50/// Drop ASCII/Unicode control characters.
51///
52/// Applied to every value that reaches the wire and to every single-line value that reaches
53/// the event. In a line-oriented protocol a stray CR or LF **forges a line** — outbound it
54/// would forge a second query, inbound it would forge a record — and ESC would put terminal
55/// escapes on the operator's dashboard.
56pub fn strip_controls(s: &str) -> String {
57    crate::utils::sanitize::strip_controls(s)
58}
59
60/// Same, but keeping newlines and tabs: for the response text, which is inherently multi-line.
61///
62/// Line endings are normalised to LF first so a lone CR does not survive as a control
63/// character, and every other control — ESC included — is dropped.
64pub fn sanitize_response(s: &str) -> String {
65    crate::utils::sanitize::multiline(&s.replace("\r\n", "\n").replace('\r', "\n"))
66}
67
68/// One `{Q1}`/`{Q2}` query this client will put on the wire.
69///
70/// RFC 1288 §2.3:
71///
72/// ```text
73/// {Q1} ::= [{W}|{W}{S}{U}] {C}          -- local query
74/// {Q2} ::= [{W}{S}][{U}]{H}{C}          -- forwarding query
75/// {W}  ::= "/W"                         -- long format
76/// {U}  ::= username
77/// {H}  ::= @hostname
78/// {C}  ::= CRLF
79/// ```
80#[derive(Clone, Debug, Default, PartialEq, Eq)]
81pub struct FingerQuerySpec {
82    /// Login to ask about, or `None` for the empty "list everyone" query.
83    pub username: Option<String>,
84    /// Send `/W`, asking the server for the long format.
85    pub verbose: bool,
86    /// `@host`: ask the server to **relay** the query. Refused unless `allow_forwarding`.
87    pub forward_host: Option<String>,
88}
89
90impl FingerQuerySpec {
91    /// Validate one `send_finger_query` action into a spec.
92    ///
93    /// The validation is the security boundary, not decoration. A username carrying `@` would
94    /// make a forwarding query *implicitly*, slipping past the `allow_forwarding` gate
95    /// entirely; one carrying whitespace or a leading `/` could forge the `/W` token or a
96    /// second field. Each is refused by name so the model is told to use `forward_host`
97    /// rather than silently getting something else than it asked for.
98    pub fn from_action(action: &serde_json::Value) -> Result<Self> {
99        let username = match action.get("username").and_then(|v| v.as_str()) {
100            Some(raw) => {
101                let cleaned = strip_controls(raw).trim().to_string();
102                if cleaned.is_empty() {
103                    None
104                } else {
105                    if cleaned.contains('@') {
106                        bail!(
107                            "'username' must not contain '@' ({cleaned:?}). A 'user@host' query \
108                             asks the server to relay on your behalf, which RFC 1288 section \
109                             3.2.1 calls a security risk; put the host in the separate \
110                             'forward_host' parameter, which is refused unless the client was \
111                             started with allow_forwarding=true."
112                        );
113                    }
114                    if cleaned.starts_with('/') || cleaned.chars().any(|c| c.is_whitespace()) {
115                        bail!(
116                            "'username' must not contain whitespace or start with '/' \
117                             ({cleaned:?}): the query is a single line and either would forge \
118                             an extra field, such as the '/W' verbose token. Use the 'verbose' \
119                             parameter to ask for the long format."
120                        );
121                    }
122                    Some(cleaned)
123                }
124            }
125            None => None,
126        };
127
128        let forward_host = match action.get("forward_host").and_then(|v| v.as_str()) {
129            Some(raw) => {
130                let cleaned = strip_controls(raw)
131                    .trim()
132                    .trim_start_matches('@')
133                    .to_string();
134                if cleaned.is_empty() {
135                    None
136                } else {
137                    if cleaned.chars().any(|c| c.is_whitespace()) {
138                        bail!(
139                            "'forward_host' must not contain whitespace ({cleaned:?}): the \
140                             query is a single line and whitespace would forge an extra field."
141                        );
142                    }
143                    Some(cleaned)
144                }
145            }
146            None => None,
147        };
148
149        let verbose = action
150            .get("verbose")
151            .and_then(|v| v.as_bool())
152            .unwrap_or(false);
153
154        Ok(Self {
155            username,
156            verbose,
157            forward_host,
158        })
159    }
160
161    /// Round-trip form carried by [`ClientActionResult::Custom`] and by the response event.
162    pub fn to_json(&self) -> serde_json::Value {
163        json!({
164            "username": self.username,
165            "verbose": self.verbose,
166            "forward_host": self.forward_host,
167        })
168    }
169
170    /// Rebuild a spec produced by [`Self::to_json`]. Values are re-cleaned rather than
171    /// trusted, so the wire encoding has exactly one place that decides what is legal.
172    pub fn from_json(value: &serde_json::Value) -> Result<Self> {
173        Self::from_action(value)
174    }
175
176    /// The query line **without** its CRLF: what the response event reports as `query`.
177    pub fn query_text(&self) -> String {
178        let mut line = String::new();
179        if self.verbose {
180            line.push_str("/W");
181        }
182        let mut who = String::new();
183        if let Some(user) = &self.username {
184            who.push_str(user);
185        }
186        if let Some(host) = &self.forward_host {
187            who.push('@');
188            who.push_str(host);
189        }
190        if !who.is_empty() {
191            if self.verbose {
192                line.push(' ');
193            }
194            line.push_str(&who);
195        }
196        line
197    }
198
199    /// The bytes that go on the wire. RFC 1288 terminates every query with CRLF.
200    pub fn to_wire_line(&self) -> String {
201        format!("{}\r\n", self.query_text())
202    }
203
204    /// True when this query asks the server to relay to another host.
205    pub fn is_forwarding(&self) -> bool {
206        self.forward_host.is_some()
207    }
208}
209
210/// Best-effort structure over a response that has none.
211///
212/// **This is a guess and is named so.** `Login:` at the start of a line is the one convention
213/// BSD `fingerd` and its descendants share; plenty of daemons print a table, a `.plan` and
214/// nothing else, or a localised header. Whatever this returns, `response` carries the text the
215/// server actually sent and is the only authoritative field.
216pub fn best_effort_fields(text: &str) -> serde_json::Value {
217    let mut logins: Vec<String> = Vec::new();
218    for line in text.lines() {
219        if logins.len() >= 32 {
220            break;
221        }
222        if let Some(rest) = line.strip_prefix("Login:") {
223            if let Some(first) = rest.split_whitespace().next() {
224                if !logins.iter().any(|l| l == first) {
225                    logins.push(first.to_string());
226                }
227            }
228        }
229    }
230    json!({
231        "logins": logins,
232        "line_count": text.lines().count(),
233        "note": "GUESS ONLY. RFC 1288 defines no response format; 'logins' is scraped from \
234                 lines beginning 'Login:' and is wrong against any daemon that prints \
235                 something else. Read 'response' - it is the text the server actually sent.",
236    })
237}
238
239/// Raised once, when the TCP connection to the finger server is up and nothing has been sent.
240pub static FINGER_CLIENT_CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
241    EventType::new(
242        "finger_connected",
243        "Finger client connected; the server is waiting for one query line",
244        json!({
245            "type": "send_finger_query",
246            "username": "alice"
247        }),
248    )
249    .with_parameters(vec![
250        Parameter {
251            name: "remote_addr".to_string(),
252            type_hint: "string".to_string(),
253            description: "The finger server this client is connected to, host:port".to_string(),
254            required: true,
255        },
256        Parameter {
257            name: "allow_forwarding".to_string(),
258            type_hint: "boolean".to_string(),
259            description: "Whether this client will put a 'user@host' forwarding query on the \
260                          wire. False by default; a send_finger_query carrying forward_host is \
261                          refused and never sent when this is false."
262                .to_string(),
263            required: true,
264        },
265    ])
266    .with_log_template(
267        LogTemplate::new()
268            .with_info("FINGER client connected to {remote_addr}")
269            .with_debug(
270                "FINGER client connected to {remote_addr} allow_forwarding={allow_forwarding}",
271            ),
272    )
273    .with_actions(finger_client_actions())
274    .with_alternative_example(json!({
275        "type": "send_finger_query",
276        "verbose": true
277    }))
278});
279
280/// Raised once per query, when the server has answered and closed (or the read was cut short).
281pub static FINGER_CLIENT_RESPONSE_RECEIVED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
282    EventType::new(
283        "finger_response_received",
284        "The finger server answered and closed the connection",
285        json!({
286            "type": "disconnect"
287        }),
288    )
289    .with_parameters(vec![
290        Parameter {
291            name: "response".to_string(),
292            type_hint: "string".to_string(),
293            description: "The server's answer, verbatim apart from control characters being \
294                          stripped and line endings normalised to LF. RFC 1288 defines no \
295                          format for it - read it as prose. This field is authoritative."
296                .to_string(),
297            required: true,
298        },
299        Parameter {
300            name: "query".to_string(),
301            type_hint: "string".to_string(),
302            description: "The query line that produced this response, without its CRLF"
303                .to_string(),
304            required: true,
305        },
306        Parameter {
307            name: "username".to_string(),
308            type_hint: "string|null".to_string(),
309            description: "The login that was asked about, or null for the empty list-all query"
310                .to_string(),
311            required: false,
312        },
313        Parameter {
314            name: "verbose".to_string(),
315            type_hint: "boolean".to_string(),
316            description: "'/W' was sent with the query".to_string(),
317            required: true,
318        },
319        Parameter {
320            name: "forward_host".to_string(),
321            type_hint: "string|null".to_string(),
322            description: "The '@host' of the query, or null. Only ever non-null when the \
323                          client was started with allow_forwarding=true."
324                .to_string(),
325            required: false,
326        },
327        Parameter {
328            name: "bytes".to_string(),
329            type_hint: "number".to_string(),
330            description: "How many bytes were read from the server".to_string(),
331            required: true,
332        },
333        Parameter {
334            name: "eof".to_string(),
335            type_hint: "boolean".to_string(),
336            description: "The server closed the connection, so the answer is complete. False \
337                          means the read stopped first - see 'truncated' and the log."
338                .to_string(),
339            required: true,
340        },
341        Parameter {
342            name: "truncated".to_string(),
343            type_hint: "boolean".to_string(),
344            description: "The response hit the client's size cap and the rest was discarded"
345                .to_string(),
346            required: true,
347        },
348        Parameter {
349            name: "best_effort".to_string(),
350            type_hint: "object".to_string(),
351            description: "A GUESS at structure, nothing more: 'logins' scraped from lines \
352                          beginning 'Login:', plus 'line_count'. Wrong against any daemon that \
353                          prints a different layout. Always prefer 'response'."
354                .to_string(),
355            required: false,
356        },
357    ])
358    .with_log_template(
359        LogTemplate::new()
360            .with_info("FINGER client got {bytes}B for query {query}")
361            .with_debug("FINGER client response: query={query} bytes={bytes} eof={eof} truncated={truncated}")
362            .with_trace("FINGER client: {json_pretty(.)}"),
363    )
364    .with_actions(finger_client_actions())
365    .with_alternative_example(json!({
366        "type": "send_finger_query",
367        "username": "bob",
368        "verbose": true
369    }))
370});
371
372fn send_finger_query_action() -> ActionDefinition {
373    ActionDefinition {
374        name: "send_finger_query".to_string(),
375        description: "Ask the finger server about a user. Omit 'username' to send the empty \
376                      query, which RFC 1288 defines as 'list everyone'. The server answers with \
377                      free text and closes, so exactly one query fits on one connection - \
378                      sending this again in reply to finger_response_received opens a fresh \
379                      connection to the same server (bounded, so a chain cannot run away). \
380                      'username' must not contain '@': a 'user@host' query asks the server to \
381                      relay, which is refused unless the client was started with \
382                      allow_forwarding=true, and it must be requested through 'forward_host' so \
383                      it can never happen by accident."
384            .to_string(),
385        parameters: vec![
386            Parameter {
387                name: "username".to_string(),
388                type_hint: "string".to_string(),
389                description: "Login to ask about, e.g. 'alice'. Omit for the empty query, which \
390                              asks for every user. No '@', no whitespace, no leading '/'."
391                    .to_string(),
392                required: false,
393            },
394            Parameter {
395                name: "verbose".to_string(),
396                type_hint: "boolean".to_string(),
397                description: "Send RFC 1288's '/W' token, asking for the long format. Servers \
398                              are free to ignore it. Default false."
399                    .to_string(),
400                required: false,
401            },
402            Parameter {
403                name: "forward_host".to_string(),
404                type_hint: "string".to_string(),
405                description: "Ask the server to RELAY this query to another host ('user@host'). \
406                              RFC 1288 section 3.2.1 calls forwarding a security risk. Refused \
407                              and never sent unless the client was started with \
408                              allow_forwarding=true; leave it out for a normal query."
409                    .to_string(),
410                required: false,
411            },
412        ],
413        example: json!({
414            "type": "send_finger_query",
415            "username": "alice",
416            "verbose": false
417        }),
418        log_template: Some(
419            LogTemplate::new()
420                .with_info("-> FINGER query {username}")
421                .with_debug("FINGER send_finger_query: username={username} verbose={verbose} forward_host={forward_host}"),
422        ),
423    }
424}
425
426fn wait_for_more_action() -> ActionDefinition {
427    ActionDefinition {
428        name: "wait_for_more".to_string(),
429        description: "Send nothing and keep waiting. On finger_connected this leaves the \
430                      connection open with no query on it, which is useful only when a query is \
431                      going to be injected from the dashboard. On finger_response_received the \
432                      server has already closed, so this simply ends the session."
433            .to_string(),
434        parameters: vec![],
435        example: json!({"type": "wait_for_more"}),
436        log_template: Some(
437            LogTemplate::new()
438                .with_info("FINGER client waiting")
439                .with_debug("FINGER wait_for_more"),
440        ),
441    }
442}
443
444fn disconnect_action() -> ActionDefinition {
445    ActionDefinition {
446        name: "disconnect".to_string(),
447        description: "Close the connection to the finger server. A finger server closes on its \
448                      own once it has answered (RFC 1288), so this is mainly for hanging up \
449                      before sending any query."
450            .to_string(),
451        parameters: vec![],
452        example: json!({"type": "disconnect"}),
453        log_template: Some(
454            LogTemplate::new()
455                .with_info("FINGER client disconnecting")
456                .with_debug("FINGER disconnect"),
457        ),
458    }
459}
460
461/// The client's entire vocabulary, declared once.
462///
463/// Clients **union** async ∪ sync ∪ the firing event's actions
464/// ([`crate::llm::actions::client_trait::client_llm_action_set`]), so this list is declared as
465/// async actions and attached to the event types, and deliberately not duplicated into
466/// `get_sync_actions()`.
467pub fn finger_client_actions() -> Vec<ActionDefinition> {
468    vec![
469        send_finger_query_action(),
470        wait_for_more_action(),
471        disconnect_action(),
472    ]
473}
474
475/// Finger (RFC 1288) client protocol.
476pub struct FingerClientProtocol;
477
478impl Default for FingerClientProtocol {
479    fn default() -> Self {
480        Self
481    }
482}
483
484impl FingerClientProtocol {
485    pub fn new() -> Self {
486        Self
487    }
488}
489
490impl Protocol for FingerClientProtocol {
491    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
492        finger_client_actions()
493    }
494
495    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
496        // Deliberately empty. A client has one LLM entry point, so async/sync carries no
497        // meaning here and duplicating the list into both would just be noise -- see
498        // `client_llm_action_set`, which unions them.
499        Vec::new()
500    }
501
502    fn protocol_name(&self) -> &'static str {
503        "Finger"
504    }
505
506    fn get_event_types(&self) -> Vec<EventType> {
507        vec![
508            FINGER_CLIENT_CONNECTED_EVENT.clone(),
509            FINGER_CLIENT_RESPONSE_RECEIVED_EVENT.clone(),
510        ]
511    }
512
513    fn stack_name(&self) -> &'static str {
514        "ETH>IP>TCP>FINGER"
515    }
516
517    fn keywords(&self) -> Vec<&'static str> {
518        vec!["finger", "finger client", "rfc1288", "user lookup"]
519    }
520
521    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
522        vec![
523            ParameterDefinition {
524                name: "port".to_string(),
525                type_hint: "number".to_string(),
526                description: format!(
527                    "TCP port to connect to when 'remote_addr' carries no port of its own \
528                     (default {DEFAULT_FINGER_PORT}, the assigned finger port). An explicit \
529                     port in remote_addr always wins. This exists because the real finger(1) \
530                     has no port option at all and is locked to 79, so a test server on a high \
531                     port is only reachable through NetGet."
532                ),
533                required: false,
534                example: json!(79),
535                default: None,
536            },
537            ParameterDefinition {
538                name: "allow_forwarding".to_string(),
539                type_hint: "boolean".to_string(),
540                description: "Allow this client to send 'user@host' forwarding queries, asking \
541                              the server to relay on our behalf (default false). RFC 1288 \
542                              section 3.2.1 calls forwarding a security risk. While false, a \
543                              send_finger_query carrying 'forward_host' is refused and no bytes \
544                              reach the wire; the refusal is logged as decision=forward_refused."
545                    .to_string(),
546                required: false,
547                example: json!(false),
548                default: None,
549            },
550            ParameterDefinition {
551                name: "response_timeout_secs".to_string(),
552                type_hint: "number".to_string(),
553                description: format!(
554                    "How long to wait for the server's answer before giving up on the read \
555                     (default {DEFAULT_RESPONSE_TIMEOUT_SECS}). A finger client reads until the \
556                     server closes, so without this a server that never closes would park the \
557                     connection task forever. 0 means wait indefinitely."
558                ),
559                required: false,
560                example: json!(30),
561                default: None,
562            },
563        ]
564    }
565
566    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
567        use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
568
569        ProtocolMetadataV2::builder()
570            .state(DevelopmentState::Experimental)
571            .implementation(
572                "Hand-rolled tokio TCP client; the RFC 1288 {Q1}/{Q2} query line is built in \
573                 FingerQuerySpec::to_wire_line and the answer is read to EOF. No library, no \
574                 dependency. Connecting out needs no privilege even on port 79.",
575            )
576            .llm_control(
577                "The whole query (username, the /W verbose token, and - only when the operator \
578                 enabled it - the @host forwarding target), and what to do with the free-text \
579                 answer, including asking a follow-up question on a fresh connection.",
580            )
581            .e2e_testing(
582                "tests/client/finger/e2e_test.rs, 10 LLM calls. The round trip runs against \
583                 NetGet's own Finger server; the exact query bytes are asserted against a raw \
584                 TcpListener.",
585            )
586            .notes(
587                "EXPERIMENTAL, and specifically NOT validated against an independent finger \
588                 implementation. The peer in the round-trip test is NetGet's own Finger server, \
589                 so it is same-project evidence: it shows the two halves agree, not that either \
590                 matches RFC 1288. No fingerd exists on macOS (Apple ships finger(1) only) and \
591                 Homebrew has no formula for one, so no third-party daemon was available here. \
592                 Promoting this to Beta needs one run against a real finger daemon on loopback - \
593                 which, unlike the server's mirror-image problem, is achievable, because a \
594                 daemon is inetd-driven and can be told to listen on a high port even though \
595                 finger(1) cannot be told to connect to one. \
596                 Forwarding (user@host) is refused unless allow_forwarding=true, and the refusal \
597                 happens before anything is written. The response is delivered to the model as \
598                 text and is deliberately not parsed; the 'best_effort' block is a guess and \
599                 says so.",
600            )
601            .build()
602    }
603
604    fn description(&self) -> &'static str {
605        "Finger (RFC 1288) client for looking up user information on a remote host"
606    }
607
608    fn example_prompt(&self) -> &'static str {
609        "Connect to the finger server at 127.0.0.1:79 and ask about 'alice'"
610    }
611
612    fn group_name(&self) -> &'static str {
613        "Core"
614    }
615
616    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
617        use crate::llm::actions::StartupExamples;
618
619        StartupExamples::new(
620            // LLM mode: the model chooses the query and reads the answer.
621            json!({
622                "type": "open_client",
623                "protocol": "finger",
624                "remote_addr": "127.0.0.1:79",
625                "instruction": "Ask the finger server about 'alice'. Read the free-text answer and report her real name, terminal and plan if the server gave them; say so plainly if it did not."
626            }),
627            // Script mode: deterministic, no model round-trip per event.
628            json!({
629                "type": "open_client",
630                "protocol": "finger",
631                "remote_addr": "127.0.0.1:79",
632                "instruction": "Finger alice",
633                "event_handlers": [{
634                    "event_pattern": "finger_connected",
635                    "handler": {
636                        "type": "script",
637                        "language": "python",
638                        "code": "respond([{'type': 'send_finger_query', 'username': 'alice'}])"
639                    }
640                }]
641            }),
642            // Static mode: a fixed query on connect, then hang up on the answer.
643            json!({
644                "type": "open_client",
645                "protocol": "finger",
646                "remote_addr": "127.0.0.1:79",
647                "instruction": "Finger alice",
648                "event_handlers": [
649                    {
650                        "event_pattern": "finger_connected",
651                        "handler": {
652                            "type": "static",
653                            "actions": [{"type": "send_finger_query", "username": "alice"}]
654                        }
655                    },
656                    {
657                        "event_pattern": "finger_response_received",
658                        "handler": {
659                            "type": "static",
660                            "actions": [{"type": "disconnect"}]
661                        }
662                    }
663                ]
664            }),
665        )
666    }
667}
668
669impl Client for FingerClientProtocol {
670    fn connect(
671        &self,
672        ctx: crate::protocol::ConnectContext,
673    ) -> std::pin::Pin<
674        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
675    > {
676        Box::pin(async move {
677            use crate::client::finger::{FingerClient, FingerClientConfig};
678
679            // `?`, never unwrap: these values come from the model or an MCP client, and a
680            // panic here kills the request task before it can report anything.
681            let params = ctx.startup_params.as_ref();
682
683            let port = params
684                .map(|p| p.get_optional_u32("port"))
685                .transpose()?
686                .flatten();
687            let port = match port {
688                Some(p) if p == 0 || p > u16::MAX as u32 => {
689                    bail!("'port' must be between 1 and 65535, got {p}")
690                }
691                Some(p) => Some(p as u16),
692                None => None,
693            };
694
695            let allow_forwarding = params
696                .map(|p| p.get_optional_bool("allow_forwarding"))
697                .transpose()?
698                .flatten()
699                .unwrap_or(DEFAULT_ALLOW_FORWARDING);
700
701            let response_timeout_secs = params
702                .map(|p| p.get_optional_u64("response_timeout_secs"))
703                .transpose()?
704                .flatten()
705                .unwrap_or(DEFAULT_RESPONSE_TIMEOUT_SECS);
706
707            let config = FingerClientConfig {
708                default_port: port.unwrap_or(DEFAULT_FINGER_PORT),
709                allow_forwarding,
710                response_timeout_secs,
711            };
712
713            FingerClient::connect_with_llm_actions(
714                ctx.remote_addr,
715                config,
716                ctx.llm_client,
717                ctx.state,
718                ctx.status_tx,
719                ctx.client_id,
720            )
721            .await
722        })
723    }
724
725    fn execute_action(&self, action: serde_json::Value) -> Result<ClientActionResult> {
726        let action_type = action
727            .get("type")
728            .and_then(|v| v.as_str())
729            .context("Missing 'type' field in action")?;
730
731        match action_type {
732            "send_finger_query" => {
733                // Validated here so the injected path and the LLM path share exactly one
734                // definition of a legal query; the *forwarding policy* is applied by the
735                // connection loop, which is where the startup parameter lives.
736                let spec = FingerQuerySpec::from_action(&action)?;
737                Ok(ClientActionResult::Custom {
738                    name: "finger_query".to_string(),
739                    data: spec.to_json(),
740                })
741            }
742            "wait_for_more" => Ok(ClientActionResult::WaitForMore),
743            "disconnect" => Ok(ClientActionResult::Disconnect),
744            _ => Err(anyhow::anyhow!(
745                "Unknown Finger client action: {}",
746                action_type
747            )),
748        }
749    }
750}