netget-tool 0.3.4

Network protocol stubbing toolkit: ~280 protocol servers and clients whose responses come from an LLM, a script, a static rule or a person.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
//! Finger (RFC 1288) client protocol actions.
//!
//! The client's whole vocabulary is one query and two ways of ending the session. Finger's
//! answer is **free text by design** — RFC 1288 specifies no format for it at all — so nothing
//! here parses it. The text goes to the model verbatim and the model decides what it means;
//! that is the entire point of NetGet. The one concession is a small `best_effort` block on the
//! response event, and it is named that way because it is a guess: half the world's finger
//! daemons print something a `Login:`/`Name:` reader would get wrong.

use crate::llm::actions::{
    client_trait::{Client, ClientActionResult},
    protocol_trait::Protocol,
    ActionDefinition, Parameter, ParameterDefinition,
};
use crate::protocol::log_template::LogTemplate;
use crate::protocol::EventType;
use crate::state::app_state::AppState;
use anyhow::{bail, Context, Result};
use serde_json::json;
use std::sync::LazyLock;

/// TCP port assigned to finger (`/etc/services`: `finger 79/tcp`).
///
/// Used only when `remote_addr` carries no port of its own; see the `port` startup parameter.
pub const DEFAULT_FINGER_PORT: u16 = 79;

/// Default for the `allow_forwarding` startup parameter.
///
/// RFC 1288 §3.2.1 names `user@host` forwarding a security risk. Asking a finger server to
/// relay on your behalf is the client half of that risk, so it is refused unless the operator
/// turns it on by name.
pub const DEFAULT_ALLOW_FORWARDING: bool = false;

/// Default for the `response_timeout_secs` startup parameter.
pub const DEFAULT_RESPONSE_TIMEOUT_SECS: u64 = 30;

/// Cap on one response.
///
/// A finger answer is a few hundred bytes; RFC 1288 sets no limit and a hostile server can
/// stream forever, so the read stops here and the event says `truncated`.
pub const MAX_RESPONSE_BYTES: usize = 65_536;

/// How many times the model may answer a response with another query.
///
/// Every follow-up needs its own TCP connection (RFC 1288 is one query per connection), and
/// each one raises `finger_response_received` again — which is exactly the self-referential
/// shape that has run away in this repo before. The bound is the answer, not silence.
pub const MAX_FOLLOWUP_DEPTH: usize = 4;

/// Drop ASCII/Unicode control characters.
///
/// Applied to every value that reaches the wire and to every single-line value that reaches
/// the event. In a line-oriented protocol a stray CR or LF **forges a line** — outbound it
/// would forge a second query, inbound it would forge a record — and ESC would put terminal
/// escapes on the operator's dashboard.
pub fn strip_controls(s: &str) -> String {
    crate::utils::sanitize::strip_controls(s)
}

/// Same, but keeping newlines and tabs: for the response text, which is inherently multi-line.
///
/// Line endings are normalised to LF first so a lone CR does not survive as a control
/// character, and every other control — ESC included — is dropped.
pub fn sanitize_response(s: &str) -> String {
    crate::utils::sanitize::multiline(&s.replace("\r\n", "\n").replace('\r', "\n"))
}

/// One `{Q1}`/`{Q2}` query this client will put on the wire.
///
/// RFC 1288 §2.3:
///
/// ```text
/// {Q1} ::= [{W}|{W}{S}{U}] {C}          -- local query
/// {Q2} ::= [{W}{S}][{U}]{H}{C}          -- forwarding query
/// {W}  ::= "/W"                         -- long format
/// {U}  ::= username
/// {H}  ::= @hostname
/// {C}  ::= CRLF
/// ```
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct FingerQuerySpec {
    /// Login to ask about, or `None` for the empty "list everyone" query.
    pub username: Option<String>,
    /// Send `/W`, asking the server for the long format.
    pub verbose: bool,
    /// `@host`: ask the server to **relay** the query. Refused unless `allow_forwarding`.
    pub forward_host: Option<String>,
}

impl FingerQuerySpec {
    /// Validate one `send_finger_query` action into a spec.
    ///
    /// The validation is the security boundary, not decoration. A username carrying `@` would
    /// make a forwarding query *implicitly*, slipping past the `allow_forwarding` gate
    /// entirely; one carrying whitespace or a leading `/` could forge the `/W` token or a
    /// second field. Each is refused by name so the model is told to use `forward_host`
    /// rather than silently getting something else than it asked for.
    pub fn from_action(action: &serde_json::Value) -> Result<Self> {
        let username = match action.get("username").and_then(|v| v.as_str()) {
            Some(raw) => {
                let cleaned = strip_controls(raw).trim().to_string();
                if cleaned.is_empty() {
                    None
                } else {
                    if cleaned.contains('@') {
                        bail!(
                            "'username' must not contain '@' ({cleaned:?}). A 'user@host' query \
                             asks the server to relay on your behalf, which RFC 1288 section \
                             3.2.1 calls a security risk; put the host in the separate \
                             'forward_host' parameter, which is refused unless the client was \
                             started with allow_forwarding=true."
                        );
                    }
                    if cleaned.starts_with('/') || cleaned.chars().any(|c| c.is_whitespace()) {
                        bail!(
                            "'username' must not contain whitespace or start with '/' \
                             ({cleaned:?}): the query is a single line and either would forge \
                             an extra field, such as the '/W' verbose token. Use the 'verbose' \
                             parameter to ask for the long format."
                        );
                    }
                    Some(cleaned)
                }
            }
            None => None,
        };

        let forward_host = match action.get("forward_host").and_then(|v| v.as_str()) {
            Some(raw) => {
                let cleaned = strip_controls(raw)
                    .trim()
                    .trim_start_matches('@')
                    .to_string();
                if cleaned.is_empty() {
                    None
                } else {
                    if cleaned.chars().any(|c| c.is_whitespace()) {
                        bail!(
                            "'forward_host' must not contain whitespace ({cleaned:?}): the \
                             query is a single line and whitespace would forge an extra field."
                        );
                    }
                    Some(cleaned)
                }
            }
            None => None,
        };

        let verbose = action
            .get("verbose")
            .and_then(|v| v.as_bool())
            .unwrap_or(false);

        Ok(Self {
            username,
            verbose,
            forward_host,
        })
    }

    /// Round-trip form carried by [`ClientActionResult::Custom`] and by the response event.
    pub fn to_json(&self) -> serde_json::Value {
        json!({
            "username": self.username,
            "verbose": self.verbose,
            "forward_host": self.forward_host,
        })
    }

    /// Rebuild a spec produced by [`Self::to_json`]. Values are re-cleaned rather than
    /// trusted, so the wire encoding has exactly one place that decides what is legal.
    pub fn from_json(value: &serde_json::Value) -> Result<Self> {
        Self::from_action(value)
    }

    /// The query line **without** its CRLF: what the response event reports as `query`.
    pub fn query_text(&self) -> String {
        let mut line = String::new();
        if self.verbose {
            line.push_str("/W");
        }
        let mut who = String::new();
        if let Some(user) = &self.username {
            who.push_str(user);
        }
        if let Some(host) = &self.forward_host {
            who.push('@');
            who.push_str(host);
        }
        if !who.is_empty() {
            if self.verbose {
                line.push(' ');
            }
            line.push_str(&who);
        }
        line
    }

    /// The bytes that go on the wire. RFC 1288 terminates every query with CRLF.
    pub fn to_wire_line(&self) -> String {
        format!("{}\r\n", self.query_text())
    }

    /// True when this query asks the server to relay to another host.
    pub fn is_forwarding(&self) -> bool {
        self.forward_host.is_some()
    }
}

/// Best-effort structure over a response that has none.
///
/// **This is a guess and is named so.** `Login:` at the start of a line is the one convention
/// BSD `fingerd` and its descendants share; plenty of daemons print a table, a `.plan` and
/// nothing else, or a localised header. Whatever this returns, `response` carries the text the
/// server actually sent and is the only authoritative field.
pub fn best_effort_fields(text: &str) -> serde_json::Value {
    let mut logins: Vec<String> = Vec::new();
    for line in text.lines() {
        if logins.len() >= 32 {
            break;
        }
        if let Some(rest) = line.strip_prefix("Login:") {
            if let Some(first) = rest.split_whitespace().next() {
                if !logins.iter().any(|l| l == first) {
                    logins.push(first.to_string());
                }
            }
        }
    }
    json!({
        "logins": logins,
        "line_count": text.lines().count(),
        "note": "GUESS ONLY. RFC 1288 defines no response format; 'logins' is scraped from \
                 lines beginning 'Login:' and is wrong against any daemon that prints \
                 something else. Read 'response' - it is the text the server actually sent.",
    })
}

/// Raised once, when the TCP connection to the finger server is up and nothing has been sent.
pub static FINGER_CLIENT_CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
    EventType::new(
        "finger_connected",
        "Finger client connected; the server is waiting for one query line",
        json!({
            "type": "send_finger_query",
            "username": "alice"
        }),
    )
    .with_parameters(vec![
        Parameter {
            name: "remote_addr".to_string(),
            type_hint: "string".to_string(),
            description: "The finger server this client is connected to, host:port".to_string(),
            required: true,
        },
        Parameter {
            name: "allow_forwarding".to_string(),
            type_hint: "boolean".to_string(),
            description: "Whether this client will put a 'user@host' forwarding query on the \
                          wire. False by default; a send_finger_query carrying forward_host is \
                          refused and never sent when this is false."
                .to_string(),
            required: true,
        },
    ])
    .with_log_template(
        LogTemplate::new()
            .with_info("FINGER client connected to {remote_addr}")
            .with_debug(
                "FINGER client connected to {remote_addr} allow_forwarding={allow_forwarding}",
            ),
    )
    .with_actions(finger_client_actions())
    .with_alternative_example(json!({
        "type": "send_finger_query",
        "verbose": true
    }))
});

/// Raised once per query, when the server has answered and closed (or the read was cut short).
pub static FINGER_CLIENT_RESPONSE_RECEIVED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
    EventType::new(
        "finger_response_received",
        "The finger server answered and closed the connection",
        json!({
            "type": "disconnect"
        }),
    )
    .with_parameters(vec![
        Parameter {
            name: "response".to_string(),
            type_hint: "string".to_string(),
            description: "The server's answer, verbatim apart from control characters being \
                          stripped and line endings normalised to LF. RFC 1288 defines no \
                          format for it - read it as prose. This field is authoritative."
                .to_string(),
            required: true,
        },
        Parameter {
            name: "query".to_string(),
            type_hint: "string".to_string(),
            description: "The query line that produced this response, without its CRLF"
                .to_string(),
            required: true,
        },
        Parameter {
            name: "username".to_string(),
            type_hint: "string|null".to_string(),
            description: "The login that was asked about, or null for the empty list-all query"
                .to_string(),
            required: false,
        },
        Parameter {
            name: "verbose".to_string(),
            type_hint: "boolean".to_string(),
            description: "'/W' was sent with the query".to_string(),
            required: true,
        },
        Parameter {
            name: "forward_host".to_string(),
            type_hint: "string|null".to_string(),
            description: "The '@host' of the query, or null. Only ever non-null when the \
                          client was started with allow_forwarding=true."
                .to_string(),
            required: false,
        },
        Parameter {
            name: "bytes".to_string(),
            type_hint: "number".to_string(),
            description: "How many bytes were read from the server".to_string(),
            required: true,
        },
        Parameter {
            name: "eof".to_string(),
            type_hint: "boolean".to_string(),
            description: "The server closed the connection, so the answer is complete. False \
                          means the read stopped first - see 'truncated' and the log."
                .to_string(),
            required: true,
        },
        Parameter {
            name: "truncated".to_string(),
            type_hint: "boolean".to_string(),
            description: "The response hit the client's size cap and the rest was discarded"
                .to_string(),
            required: true,
        },
        Parameter {
            name: "best_effort".to_string(),
            type_hint: "object".to_string(),
            description: "A GUESS at structure, nothing more: 'logins' scraped from lines \
                          beginning 'Login:', plus 'line_count'. Wrong against any daemon that \
                          prints a different layout. Always prefer 'response'."
                .to_string(),
            required: false,
        },
    ])
    .with_log_template(
        LogTemplate::new()
            .with_info("FINGER client got {bytes}B for query {query}")
            .with_debug("FINGER client response: query={query} bytes={bytes} eof={eof} truncated={truncated}")
            .with_trace("FINGER client: {json_pretty(.)}"),
    )
    .with_actions(finger_client_actions())
    .with_alternative_example(json!({
        "type": "send_finger_query",
        "username": "bob",
        "verbose": true
    }))
});

fn send_finger_query_action() -> ActionDefinition {
    ActionDefinition {
        name: "send_finger_query".to_string(),
        description: "Ask the finger server about a user. Omit 'username' to send the empty \
                      query, which RFC 1288 defines as 'list everyone'. The server answers with \
                      free text and closes, so exactly one query fits on one connection - \
                      sending this again in reply to finger_response_received opens a fresh \
                      connection to the same server (bounded, so a chain cannot run away). \
                      'username' must not contain '@': a 'user@host' query asks the server to \
                      relay, which is refused unless the client was started with \
                      allow_forwarding=true, and it must be requested through 'forward_host' so \
                      it can never happen by accident."
            .to_string(),
        parameters: vec![
            Parameter {
                name: "username".to_string(),
                type_hint: "string".to_string(),
                description: "Login to ask about, e.g. 'alice'. Omit for the empty query, which \
                              asks for every user. No '@', no whitespace, no leading '/'."
                    .to_string(),
                required: false,
            },
            Parameter {
                name: "verbose".to_string(),
                type_hint: "boolean".to_string(),
                description: "Send RFC 1288's '/W' token, asking for the long format. Servers \
                              are free to ignore it. Default false."
                    .to_string(),
                required: false,
            },
            Parameter {
                name: "forward_host".to_string(),
                type_hint: "string".to_string(),
                description: "Ask the server to RELAY this query to another host ('user@host'). \
                              RFC 1288 section 3.2.1 calls forwarding a security risk. Refused \
                              and never sent unless the client was started with \
                              allow_forwarding=true; leave it out for a normal query."
                    .to_string(),
                required: false,
            },
        ],
        example: json!({
            "type": "send_finger_query",
            "username": "alice",
            "verbose": false
        }),
        log_template: Some(
            LogTemplate::new()
                .with_info("-> FINGER query {username}")
                .with_debug("FINGER send_finger_query: username={username} verbose={verbose} forward_host={forward_host}"),
        ),
    }
}

fn wait_for_more_action() -> ActionDefinition {
    ActionDefinition {
        name: "wait_for_more".to_string(),
        description: "Send nothing and keep waiting. On finger_connected this leaves the \
                      connection open with no query on it, which is useful only when a query is \
                      going to be injected from the dashboard. On finger_response_received the \
                      server has already closed, so this simply ends the session."
            .to_string(),
        parameters: vec![],
        example: json!({"type": "wait_for_more"}),
        log_template: Some(
            LogTemplate::new()
                .with_info("FINGER client waiting")
                .with_debug("FINGER wait_for_more"),
        ),
    }
}

fn disconnect_action() -> ActionDefinition {
    ActionDefinition {
        name: "disconnect".to_string(),
        description: "Close the connection to the finger server. A finger server closes on its \
                      own once it has answered (RFC 1288), so this is mainly for hanging up \
                      before sending any query."
            .to_string(),
        parameters: vec![],
        example: json!({"type": "disconnect"}),
        log_template: Some(
            LogTemplate::new()
                .with_info("FINGER client disconnecting")
                .with_debug("FINGER disconnect"),
        ),
    }
}

/// The client's entire vocabulary, declared once.
///
/// Clients **union** async ∪ sync ∪ the firing event's actions
/// ([`crate::llm::actions::client_trait::client_llm_action_set`]), so this list is declared as
/// async actions and attached to the event types, and deliberately not duplicated into
/// `get_sync_actions()`.
pub fn finger_client_actions() -> Vec<ActionDefinition> {
    vec![
        send_finger_query_action(),
        wait_for_more_action(),
        disconnect_action(),
    ]
}

/// Finger (RFC 1288) client protocol.
pub struct FingerClientProtocol;

impl Default for FingerClientProtocol {
    fn default() -> Self {
        Self
    }
}

impl FingerClientProtocol {
    pub fn new() -> Self {
        Self
    }
}

impl Protocol for FingerClientProtocol {
    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
        finger_client_actions()
    }

    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
        // Deliberately empty. A client has one LLM entry point, so async/sync carries no
        // meaning here and duplicating the list into both would just be noise -- see
        // `client_llm_action_set`, which unions them.
        Vec::new()
    }

    fn protocol_name(&self) -> &'static str {
        "Finger"
    }

    fn get_event_types(&self) -> Vec<EventType> {
        vec![
            FINGER_CLIENT_CONNECTED_EVENT.clone(),
            FINGER_CLIENT_RESPONSE_RECEIVED_EVENT.clone(),
        ]
    }

    fn stack_name(&self) -> &'static str {
        "ETH>IP>TCP>FINGER"
    }

    fn keywords(&self) -> Vec<&'static str> {
        vec!["finger", "finger client", "rfc1288", "user lookup"]
    }

    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
        vec![
            ParameterDefinition {
                name: "port".to_string(),
                type_hint: "number".to_string(),
                description: format!(
                    "TCP port to connect to when 'remote_addr' carries no port of its own \
                     (default {DEFAULT_FINGER_PORT}, the assigned finger port). An explicit \
                     port in remote_addr always wins. This exists because the real finger(1) \
                     has no port option at all and is locked to 79, so a test server on a high \
                     port is only reachable through NetGet."
                ),
                required: false,
                example: json!(79),
                default: None,
            },
            ParameterDefinition {
                name: "allow_forwarding".to_string(),
                type_hint: "boolean".to_string(),
                description: "Allow this client to send 'user@host' forwarding queries, asking \
                              the server to relay on our behalf (default false). RFC 1288 \
                              section 3.2.1 calls forwarding a security risk. While false, a \
                              send_finger_query carrying 'forward_host' is refused and no bytes \
                              reach the wire; the refusal is logged as decision=forward_refused."
                    .to_string(),
                required: false,
                example: json!(false),
                default: None,
            },
            ParameterDefinition {
                name: "response_timeout_secs".to_string(),
                type_hint: "number".to_string(),
                description: format!(
                    "How long to wait for the server's answer before giving up on the read \
                     (default {DEFAULT_RESPONSE_TIMEOUT_SECS}). A finger client reads until the \
                     server closes, so without this a server that never closes would park the \
                     connection task forever. 0 means wait indefinitely."
                ),
                required: false,
                example: json!(30),
                default: None,
            },
        ]
    }

    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
        use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};

        ProtocolMetadataV2::builder()
            .state(DevelopmentState::Experimental)
            .implementation(
                "Hand-rolled tokio TCP client; the RFC 1288 {Q1}/{Q2} query line is built in \
                 FingerQuerySpec::to_wire_line and the answer is read to EOF. No library, no \
                 dependency. Connecting out needs no privilege even on port 79.",
            )
            .llm_control(
                "The whole query (username, the /W verbose token, and - only when the operator \
                 enabled it - the @host forwarding target), and what to do with the free-text \
                 answer, including asking a follow-up question on a fresh connection.",
            )
            .e2e_testing(
                "tests/client/finger/e2e_test.rs, 10 LLM calls. The round trip runs against \
                 NetGet's own Finger server; the exact query bytes are asserted against a raw \
                 TcpListener.",
            )
            .notes(
                "EXPERIMENTAL, and specifically NOT validated against an independent finger \
                 implementation. The peer in the round-trip test is NetGet's own Finger server, \
                 so it is same-project evidence: it shows the two halves agree, not that either \
                 matches RFC 1288. No fingerd exists on macOS (Apple ships finger(1) only) and \
                 Homebrew has no formula for one, so no third-party daemon was available here. \
                 Promoting this to Beta needs one run against a real finger daemon on loopback - \
                 which, unlike the server's mirror-image problem, is achievable, because a \
                 daemon is inetd-driven and can be told to listen on a high port even though \
                 finger(1) cannot be told to connect to one. \
                 Forwarding (user@host) is refused unless allow_forwarding=true, and the refusal \
                 happens before anything is written. The response is delivered to the model as \
                 text and is deliberately not parsed; the 'best_effort' block is a guess and \
                 says so.",
            )
            .build()
    }

    fn description(&self) -> &'static str {
        "Finger (RFC 1288) client for looking up user information on a remote host"
    }

    fn example_prompt(&self) -> &'static str {
        "Connect to the finger server at 127.0.0.1:79 and ask about 'alice'"
    }

    fn group_name(&self) -> &'static str {
        "Core"
    }

    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
        use crate::llm::actions::StartupExamples;

        StartupExamples::new(
            // LLM mode: the model chooses the query and reads the answer.
            json!({
                "type": "open_client",
                "protocol": "finger",
                "remote_addr": "127.0.0.1:79",
                "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."
            }),
            // Script mode: deterministic, no model round-trip per event.
            json!({
                "type": "open_client",
                "protocol": "finger",
                "remote_addr": "127.0.0.1:79",
                "instruction": "Finger alice",
                "event_handlers": [{
                    "event_pattern": "finger_connected",
                    "handler": {
                        "type": "script",
                        "language": "python",
                        "code": "respond([{'type': 'send_finger_query', 'username': 'alice'}])"
                    }
                }]
            }),
            // Static mode: a fixed query on connect, then hang up on the answer.
            json!({
                "type": "open_client",
                "protocol": "finger",
                "remote_addr": "127.0.0.1:79",
                "instruction": "Finger alice",
                "event_handlers": [
                    {
                        "event_pattern": "finger_connected",
                        "handler": {
                            "type": "static",
                            "actions": [{"type": "send_finger_query", "username": "alice"}]
                        }
                    },
                    {
                        "event_pattern": "finger_response_received",
                        "handler": {
                            "type": "static",
                            "actions": [{"type": "disconnect"}]
                        }
                    }
                ]
            }),
        )
    }
}

impl Client for FingerClientProtocol {
    fn connect(
        &self,
        ctx: crate::protocol::ConnectContext,
    ) -> std::pin::Pin<
        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
    > {
        Box::pin(async move {
            use crate::client::finger::{FingerClient, FingerClientConfig};

            // `?`, never unwrap: these values come from the model or an MCP client, and a
            // panic here kills the request task before it can report anything.
            let params = ctx.startup_params.as_ref();

            let port = params
                .map(|p| p.get_optional_u32("port"))
                .transpose()?
                .flatten();
            let port = match port {
                Some(p) if p == 0 || p > u16::MAX as u32 => {
                    bail!("'port' must be between 1 and 65535, got {p}")
                }
                Some(p) => Some(p as u16),
                None => None,
            };

            let allow_forwarding = params
                .map(|p| p.get_optional_bool("allow_forwarding"))
                .transpose()?
                .flatten()
                .unwrap_or(DEFAULT_ALLOW_FORWARDING);

            let response_timeout_secs = params
                .map(|p| p.get_optional_u64("response_timeout_secs"))
                .transpose()?
                .flatten()
                .unwrap_or(DEFAULT_RESPONSE_TIMEOUT_SECS);

            let config = FingerClientConfig {
                default_port: port.unwrap_or(DEFAULT_FINGER_PORT),
                allow_forwarding,
                response_timeout_secs,
            };

            FingerClient::connect_with_llm_actions(
                ctx.remote_addr,
                config,
                ctx.llm_client,
                ctx.state,
                ctx.status_tx,
                ctx.client_id,
            )
            .await
        })
    }

    fn execute_action(&self, action: serde_json::Value) -> Result<ClientActionResult> {
        let action_type = action
            .get("type")
            .and_then(|v| v.as_str())
            .context("Missing 'type' field in action")?;

        match action_type {
            "send_finger_query" => {
                // Validated here so the injected path and the LLM path share exactly one
                // definition of a legal query; the *forwarding policy* is applied by the
                // connection loop, which is where the startup parameter lives.
                let spec = FingerQuerySpec::from_action(&action)?;
                Ok(ClientActionResult::Custom {
                    name: "finger_query".to_string(),
                    data: spec.to_json(),
                })
            }
            "wait_for_more" => Ok(ClientActionResult::WaitForMore),
            "disconnect" => Ok(ClientActionResult::Disconnect),
            _ => Err(anyhow::anyhow!(
                "Unknown Finger client action: {}",
                action_type
            )),
        }
    }
}