Skip to main content

netget/client/gopher/
actions.rs

1//! Gopher (RFC 1436) client protocol actions.
2//!
3//! The model browses gopherspace: it asks for a selector, is handed the reply **already
4//! parsed** — a menu as structured items, a document as text, a type-3 item as an error —
5//! and decides what to fetch next. Nothing here carries wire framing: no tabs, no CRLFs, no
6//! terminating `.` line, and nothing base64.
7//!
8//! ## Why every action is declared once, in `get_async_actions`
9//!
10//! A client has one LLM entry point (`call_llm_for_client`), which advertises
11//! `client_llm_action_set` = async ∪ sync ∪ the firing event's own list. A client therefore
12//! cannot express a narrowing, and duplicating the list into `get_sync_actions()` — which
13//! roughly forty clients in this tree do — buys nothing. `get_sync_actions()` is empty and
14//! the event types attach no actions of their own; the union is exactly this list either way.
15
16use crate::llm::actions::{
17    client_trait::{Client, ClientActionResult},
18    protocol_trait::Protocol,
19    ActionDefinition, Parameter,
20};
21use crate::protocol::log_template::LogTemplate;
22use crate::protocol::EventType;
23use crate::state::app_state::AppState;
24use anyhow::{bail, Context, Result};
25use serde_json::json;
26use std::sync::LazyLock;
27
28/// The `ClientActionResult::Custom` name both fetch verbs produce.
29///
30/// `send_gopher_request` and `send_gopher_search` differ only in whether a query is attached,
31/// so they converge on one custom result and the connection loop has a single fetch path.
32pub const GOPHER_FETCH_RESULT: &str = "gopher_fetch";
33
34/// Default item type when the model does not say what it expects.
35///
36/// `1` (a directory/menu) is the right default because the only request whose type is
37/// genuinely unknown in advance is the very first one, and that is the root menu.
38pub const DEFAULT_ITEM_TYPE: char = '1';
39
40/// A menu arrived and was parsed into items.
41pub static GOPHER_MENU_RECEIVED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
42    EventType::new(
43        "gopher_menu_received",
44        "A Gopher menu was fetched and parsed into items",
45        json!({
46            "type": "send_gopher_request",
47            "selector": "/about.txt",
48            "item_type": "0",
49            "host": "127.0.0.1",
50            "port": 70
51        }),
52    )
53    .with_parameters(vec![
54        Parameter {
55            name: "selector".to_string(),
56            type_hint: "string".to_string(),
57            description: "The selector that was asked for (empty means the root menu)".to_string(),
58            required: true,
59        },
60        Parameter {
61            name: "host".to_string(),
62            type_hint: "string".to_string(),
63            description: "Host the menu came from".to_string(),
64            required: true,
65        },
66        Parameter {
67            name: "port".to_string(),
68            type_hint: "number".to_string(),
69            description: "Port the menu came from".to_string(),
70            required: true,
71        },
72        Parameter {
73            name: "item_count".to_string(),
74            type_hint: "number".to_string(),
75            description: "How many items the menu contains".to_string(),
76            required: true,
77        },
78        Parameter {
79            name: "items".to_string(),
80            type_hint: "array".to_string(),
81            description:
82                "The menu items in order. Each is an object with 'item_type' (one character), \
83                 'item_type_name' (what that character means), 'display' (the text a user \
84                 sees), 'selector', 'host' and 'port'. To follow an item, echo its selector, \
85                 host, port and item_type back in a send_gopher_request. Items whose \
86                 'item_type' is 'i' are informational text, not links - there is nothing to \
87                 follow."
88                    .to_string(),
89            required: true,
90        },
91        Parameter {
92            name: "malformed_lines".to_string(),
93            type_hint: "array".to_string(),
94            description:
95                "Lines the server sent that are not well-formed menu lines, kept verbatim so \
96                 nothing is silently dropped. Absent when there were none."
97                    .to_string(),
98            required: false,
99        },
100        Parameter {
101            name: "truncated".to_string(),
102            type_hint: "boolean".to_string(),
103            description: "True when the reply hit the client's size cap and was cut short"
104                .to_string(),
105            required: false,
106        },
107    ])
108    .with_log_template(
109        LogTemplate::new()
110            .with_info("<- Gopher menu {selector} ({item_count} items)")
111            .with_debug("Gopher menu from {host}:{port} selector={selector} items={item_count}")
112            .with_trace("Gopher menu items: {json_pretty(items)}"),
113    )
114});
115
116/// A document arrived; the terminator is gone and the doubled leading dots are undone.
117pub static GOPHER_DOCUMENT_RECEIVED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
118    EventType::new(
119        "gopher_document_received",
120        "A Gopher text document was fetched",
121        json!({
122            "type": "send_gopher_request",
123            "selector": "",
124            "item_type": "1"
125        }),
126    )
127    .with_parameters(vec![
128        Parameter {
129            name: "selector".to_string(),
130            type_hint: "string".to_string(),
131            description: "The selector that was asked for".to_string(),
132            required: true,
133        },
134        Parameter {
135            name: "host".to_string(),
136            type_hint: "string".to_string(),
137            description: "Host the document came from".to_string(),
138            required: true,
139        },
140        Parameter {
141            name: "port".to_string(),
142            type_hint: "number".to_string(),
143            description: "Port the document came from".to_string(),
144            required: true,
145        },
146        Parameter {
147            name: "requested_item_type".to_string(),
148            type_hint: "string".to_string(),
149            description: "The item type this request asked for. If it is '1' or '7' the reply was \
150                 expected to be a menu and no line of it parsed as one, so it is being \
151                 reported as a document instead."
152                .to_string(),
153            required: true,
154        },
155        Parameter {
156            name: "text".to_string(),
157            type_hint: "string".to_string(),
158            description:
159                "The document body as plain text: CRLF turned into newlines, the terminating \
160                 '.' line removed, and any doubled leading period undone"
161                    .to_string(),
162            required: true,
163        },
164        Parameter {
165            name: "line_count".to_string(),
166            type_hint: "number".to_string(),
167            description: "How many lines the document has".to_string(),
168            required: true,
169        },
170        Parameter {
171            name: "truncated".to_string(),
172            type_hint: "boolean".to_string(),
173            description: "True when the reply hit the client's size cap and was cut short"
174                .to_string(),
175            required: false,
176        },
177    ])
178    .with_log_template(
179        LogTemplate::new()
180            .with_info("<- Gopher document {selector} ({line_count} lines)")
181            .with_debug("Gopher document from {host}:{port} selector={selector}")
182            .with_trace("Gopher document: {preview(text, 400)}"),
183    )
184});
185
186/// The server answered with a type-3 item, which is the only error Gopher has.
187pub static GOPHER_ERROR_RECEIVED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
188    EventType::new(
189        "gopher_error_received",
190        "The Gopher server answered with a type-3 error item",
191        json!({
192            "type": "send_gopher_request",
193            "selector": "",
194            "item_type": "1"
195        }),
196    )
197    .with_parameters(vec![
198        Parameter {
199            name: "selector".to_string(),
200            type_hint: "string".to_string(),
201            description: "The selector that was asked for".to_string(),
202            required: true,
203        },
204        Parameter {
205            name: "host".to_string(),
206            type_hint: "string".to_string(),
207            description: "Host that reported the error".to_string(),
208            required: true,
209        },
210        Parameter {
211            name: "port".to_string(),
212            type_hint: "number".to_string(),
213            description: "Port that reported the error".to_string(),
214            required: true,
215        },
216        Parameter {
217            name: "message".to_string(),
218            type_hint: "string".to_string(),
219            description: "The display text of the type-3 item".to_string(),
220            required: true,
221        },
222    ])
223    .with_log_template(
224        LogTemplate::new()
225            .with_info("<- Gopher error for {selector}: {message}")
226            .with_debug("Gopher error from {host}:{port} selector={selector}: {message}"),
227    )
228});
229
230pub fn get_gopher_client_event_types() -> Vec<EventType> {
231    vec![
232        GOPHER_MENU_RECEIVED_EVENT.clone(),
233        GOPHER_DOCUMENT_RECEIVED_EVENT.clone(),
234        GOPHER_ERROR_RECEIVED_EVENT.clone(),
235    ]
236}
237
238/// Gopher client protocol handler.
239pub struct GopherClientProtocol;
240
241impl GopherClientProtocol {
242    pub fn new() -> Self {
243        Self
244    }
245}
246
247impl Default for GopherClientProtocol {
248    fn default() -> Self {
249        Self::new()
250    }
251}
252
253/// Reject a field that would forge extra request structure.
254///
255/// A selector is written to the wire as `<selector>\r\n`, and a type-7 search as
256/// `<selector>\t<query>\r\n`. A CR or LF inside either would end the request line early and
257/// leave the rest as a second request; a tab inside a selector would turn a plain fetch into
258/// a search. Both are the request-side equivalent of the header-injection bug the server side
259/// guards against, so they are refused rather than quietly rewritten - the model has to see
260/// that its selector was wrong.
261fn reject_wire_breaks(field: &str, value: &str, tab_allowed: bool) -> Result<()> {
262    if value.contains('\r') || value.contains('\n') {
263        bail!("'{field}' must not contain a carriage return or a line feed");
264    }
265    if !tab_allowed && value.contains('\t') {
266        bail!("'{field}' must not contain a tab; a tab is what turns a fetch into a search");
267    }
268    Ok(())
269}
270
271/// Pull the optional `host` / `port` override out of a fetch action.
272///
273/// Absent means "the address this client was opened against", which is what makes the first
274/// request easy to write. A menu item carries its own host and port precisely so that a
275/// gopherspace can span servers, so both are settable.
276fn fetch_target(action: &serde_json::Value) -> Result<(Option<String>, Option<u16>)> {
277    let host = match action.get("host").and_then(|v| v.as_str()) {
278        Some(h) if !h.is_empty() => {
279            reject_wire_breaks("host", h, false)?;
280            Some(h.to_string())
281        }
282        _ => None,
283    };
284    let port = match action.get("port") {
285        Some(serde_json::Value::Null) | None => None,
286        Some(v) => {
287            let n = v
288                .as_u64()
289                .or_else(|| v.as_str().and_then(|s| s.parse::<u64>().ok()))
290                .context("'port' must be a number between 1 and 65535")?;
291            if n == 0 || n > u16::MAX as u64 {
292                bail!("'port' must be a number between 1 and 65535");
293            }
294            Some(n as u16)
295        }
296    };
297    Ok((host, port))
298}
299
300impl Protocol for GopherClientProtocol {
301    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
302        vec![
303            send_gopher_request_action(),
304            send_gopher_search_action(),
305            wait_for_more_action(),
306            disconnect_action(),
307        ]
308    }
309
310    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
311        // Deliberately empty: a client cannot narrow, so the union above is the whole
312        // vocabulary at every point in the session. See the module docs.
313        Vec::new()
314    }
315
316    fn protocol_name(&self) -> &'static str {
317        "Gopher"
318    }
319
320    fn get_event_types(&self) -> Vec<EventType> {
321        get_gopher_client_event_types()
322    }
323
324    fn stack_name(&self) -> &'static str {
325        "ETH>IP>TCP>GOPHER"
326    }
327
328    fn keywords(&self) -> Vec<&'static str> {
329        vec!["gopher", "gopher client", "gopherspace", "gopher hole"]
330    }
331
332    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
333        use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
334
335        ProtocolMetadataV2::builder()
336            .state(DevelopmentState::Experimental)
337            .implementation(
338                "Hand-rolled RFC 1436 over plain tokio TCP; no library. Gopher is one request \
339                 per connection, so every fetch opens its own socket, writes one selector \
340                 line and reads to EOF - the close is the framing. The reply is parsed \
341                 according to the item type the request asked for, which is the only thing \
342                 that says whether it is a menu or a document.",
343            )
344            .llm_control(
345                "Which selector to fetch, on which host and port, and what the reply was \
346                 expected to be. Menus arrive parsed into structured items, so following a \
347                 link is echoing an item's fields back in a send_gopher_request.",
348            )
349            .e2e_testing(
350                "tests/client/gopher/e2e_test.rs. Its peer is NetGet's own Gopher server, so \
351                 the exchange is same-project evidence: it shows the two halves agree, not \
352                 that either matches RFC 1436.",
353            )
354            .notes(
355                "Experimental, and the reason is the evidence rather than the code: no \
356                 third-party Gopher server has been pointed at it. The one sniff is \
357                 deliberate - a type-3 item is recognised whatever type was asked for, \
358                 because it is the protocol's only error channel. Not implemented: Gopher+, \
359                 TLS ('gophers'), binary item transfer (types 4/5/6/9/g/I are listed but not \
360                 downloaded, since that would mean base64 in an event) and CSO (type 2).",
361            )
362            .build()
363    }
364
365    fn description(&self) -> &'static str {
366        "Gopher (RFC 1436) client: fetch menus, documents and searches from a gopher hole"
367    }
368
369    fn example_prompt(&self) -> &'static str {
370        "Connect to the Gopher server at 127.0.0.1:70, fetch the root menu and then read the \
371         first text file it lists"
372    }
373
374    fn group_name(&self) -> &'static str {
375        "Application"
376    }
377
378    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
379        use crate::llm::actions::StartupExamples;
380        StartupExamples::new(
381            // LLM-driven browsing.
382            json!({
383                "type": "open_client",
384                "remote_addr": "127.0.0.1:70",
385                "base_stack": "gopher",
386                "instruction": "Fetch the root menu, then read every text file it lists and \
387                                summarise them."
388            }),
389            // Script-based: decide the next fetch in code, no model call.
390            json!({
391                "type": "open_client",
392                "remote_addr": "127.0.0.1:70",
393                "base_stack": "gopher",
394                "event_handlers": [{
395                    "event_pattern": "gopher_menu_received",
396                    "handler": {
397                        "type": "script",
398                        "language": "python",
399                        "code": "items = event.get('items', [])\ntexts = [i for i in items if i.get('item_type') == '0']\nif texts:\n    first = texts[0]\n    respond([{'type': 'send_gopher_request', 'selector': first['selector'], 'host': first['host'], 'port': first['port'], 'item_type': '0'}])\nelse:\n    respond([{'type': 'disconnect'}])"
400                    }
401                }]
402            }),
403            // Static: read one document and stop.
404            json!({
405                "type": "open_client",
406                "remote_addr": "127.0.0.1:70",
407                "base_stack": "gopher",
408                "event_handlers": [{
409                    "event_pattern": "gopher_document_received",
410                    "handler": {
411                        "type": "static",
412                        "actions": [{"type": "disconnect"}]
413                    }
414                }]
415            }),
416        )
417    }
418}
419
420impl Client for GopherClientProtocol {
421    fn connect(
422        &self,
423        ctx: crate::protocol::ConnectContext,
424    ) -> std::pin::Pin<
425        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
426    > {
427        Box::pin(async move {
428            use crate::client::gopher::GopherClient;
429            GopherClient::connect_with_llm_actions(
430                ctx.remote_addr,
431                ctx.llm_client,
432                ctx.state,
433                ctx.status_tx,
434                ctx.client_id,
435            )
436            .await
437        })
438    }
439
440    fn execute_action(&self, action: serde_json::Value) -> Result<ClientActionResult> {
441        let action_type = action
442            .get("type")
443            .and_then(|v| v.as_str())
444            .context("Missing 'type' field in action")?;
445
446        match action_type {
447            "send_gopher_request" => {
448                // An absent selector is the root menu, which is the single most common
449                // request there is - requiring the empty string would be a trap.
450                let selector = action
451                    .get("selector")
452                    .and_then(|v| v.as_str())
453                    .unwrap_or("")
454                    .to_string();
455                reject_wire_breaks("selector", &selector, false)?;
456
457                let item_type = parse_item_type(&action)?;
458                let (host, port) = fetch_target(&action)?;
459
460                Ok(ClientActionResult::Custom {
461                    name: GOPHER_FETCH_RESULT.to_string(),
462                    data: json!({
463                        "selector": selector,
464                        "item_type": item_type.to_string(),
465                        "host": host,
466                        "port": port,
467                        "query": serde_json::Value::Null,
468                    }),
469                })
470            }
471            "send_gopher_search" => {
472                let selector = action
473                    .get("selector")
474                    .and_then(|v| v.as_str())
475                    .unwrap_or("")
476                    .to_string();
477                reject_wire_breaks("selector", &selector, false)?;
478
479                let query = action
480                    .get("query")
481                    .and_then(|v| v.as_str())
482                    .context("send_gopher_search needs a 'query' - the words to search for")?
483                    .to_string();
484                // A tab inside a query is legal: RFC 1436 splits the request line on the
485                // first tab only, so everything after it is the query.
486                reject_wire_breaks("query", &query, true)?;
487
488                let (host, port) = fetch_target(&action)?;
489
490                Ok(ClientActionResult::Custom {
491                    name: GOPHER_FETCH_RESULT.to_string(),
492                    data: json!({
493                        "selector": selector,
494                        // A search always answers with a menu of results.
495                        "item_type": "7",
496                        "host": host,
497                        "port": port,
498                        "query": query,
499                    }),
500                })
501            }
502            "wait_for_more" => Ok(ClientActionResult::WaitForMore),
503            "disconnect" => Ok(ClientActionResult::Disconnect),
504            other => Err(anyhow::anyhow!("Unknown Gopher client action: {}", other)),
505        }
506    }
507}
508
509/// `item_type` is one character. Anything longer is a mistake worth reporting rather than
510/// truncating, because the character decides how the reply is read.
511fn parse_item_type(action: &serde_json::Value) -> Result<char> {
512    let Some(raw) = action.get("item_type").and_then(|v| v.as_str()) else {
513        return Ok(DEFAULT_ITEM_TYPE);
514    };
515    if raw.is_empty() {
516        return Ok(DEFAULT_ITEM_TYPE);
517    }
518    let mut chars = raw.chars();
519    match (chars.next(), chars.next()) {
520        (Some(c), None) => Ok(c),
521        _ => bail!("'item_type' is exactly one character, not {raw:?}"),
522    }
523}
524
525fn send_gopher_request_action() -> ActionDefinition {
526    ActionDefinition {
527        name: "send_gopher_request".to_string(),
528        description:
529            "Fetch one Gopher item. Gopher is one request per connection: this opens its own \
530             connection, sends the selector, reads until the server hangs up, and reports the \
531             reply back to you as a gopher_menu_received, gopher_document_received or \
532             gopher_error_received event. To follow a link from a menu, copy that item's \
533             'selector', 'host', 'port' and 'item_type' straight from the menu you were given."
534                .to_string(),
535        parameters: vec![
536            Parameter {
537                name: "selector".to_string(),
538                type_hint: "string".to_string(),
539                description:
540                    "The selector to fetch. Leave it out or use the empty string for the root \
541                     menu. Never include the item-type character here: a Gopher URL such as \
542                     gopher://host/0/about.txt has selector '/about.txt'."
543                        .to_string(),
544                required: false,
545            },
546            Parameter {
547                name: "item_type".to_string(),
548                type_hint: "string".to_string(),
549                description:
550                    "One character saying what you expect back, because the reply itself does \
551                     not say: '1' a menu (the default), '7' a search menu, '0' a text \
552                     document. Anything else is read as a document. A menu item tells you its \
553                     own type - use that."
554                        .to_string(),
555                required: false,
556            },
557            Parameter {
558                name: "host".to_string(),
559                type_hint: "string".to_string(),
560                description: "Host to fetch from. Defaults to the address this client was opened \
561                     against; menu items carry their own host so a gopherspace can span servers."
562                    .to_string(),
563                required: false,
564            },
565            Parameter {
566                name: "port".to_string(),
567                type_hint: "number".to_string(),
568                description: "Port to fetch from. Defaults to the port this client was opened \
569                              against."
570                    .to_string(),
571                required: false,
572            },
573        ],
574        example: json!({
575            "type": "send_gopher_request",
576            "selector": "/about.txt",
577            "item_type": "0",
578            "host": "127.0.0.1",
579            "port": 70
580        }),
581        log_template: Some(
582            LogTemplate::new()
583                .with_info("-> Gopher fetch {selector}")
584                .with_debug("Gopher send_gopher_request: selector={selector} type={item_type}"),
585        ),
586    }
587}
588
589fn send_gopher_search_action() -> ActionDefinition {
590    ActionDefinition {
591        name: "send_gopher_search".to_string(),
592        description:
593            "Run a search against a type-7 item. Same as send_gopher_request except that the \
594             search terms are sent with the selector, and the reply is always a menu of \
595             results. Use the selector of the '7' item you found in a menu."
596                .to_string(),
597        parameters: vec![
598            Parameter {
599                name: "selector".to_string(),
600                type_hint: "string".to_string(),
601                description: "Selector of the type-7 search item".to_string(),
602                required: true,
603            },
604            Parameter {
605                name: "query".to_string(),
606                type_hint: "string".to_string(),
607                description: "The words to search for".to_string(),
608                required: true,
609            },
610            Parameter {
611                name: "host".to_string(),
612                type_hint: "string".to_string(),
613                description: "Host to search. Defaults to the address this client was opened \
614                              against."
615                    .to_string(),
616                required: false,
617            },
618            Parameter {
619                name: "port".to_string(),
620                type_hint: "number".to_string(),
621                description: "Port to search. Defaults to the port this client was opened \
622                              against."
623                    .to_string(),
624                required: false,
625            },
626        ],
627        example: json!({
628            "type": "send_gopher_search",
629            "selector": "/search",
630            "query": "burrow"
631        }),
632        log_template: Some(
633            LogTemplate::new()
634                .with_info("-> Gopher search {selector} for {query}")
635                .with_debug("Gopher send_gopher_search: selector={selector} query={query}"),
636        ),
637    }
638}
639
640fn wait_for_more_action() -> ActionDefinition {
641    ActionDefinition {
642        name: "wait_for_more".to_string(),
643        description:
644            "Stop here and do nothing further on your own. Gopher replies are never partial - \
645             the server closing the connection is what marks a reply complete - so this does \
646             not wait for more bytes. It ends the automatic browse and leaves the client idle \
647             until someone sends it another request."
648                .to_string(),
649        parameters: vec![],
650        example: json!({"type": "wait_for_more"}),
651        log_template: Some(
652            LogTemplate::new()
653                .with_info("Gopher client idle")
654                .with_debug("Gopher wait_for_more: browse stopped, client idle"),
655        ),
656    }
657}
658
659fn disconnect_action() -> ActionDefinition {
660    ActionDefinition {
661        name: "disconnect".to_string(),
662        description:
663            "End the browsing session. Gopher holds no connection between requests, so there \
664             is no socket to close - this marks the client finished, stops it acting on its \
665             own, and takes it out of the dashboard's send list."
666                .to_string(),
667        parameters: vec![],
668        example: json!({"type": "disconnect"}),
669        log_template: Some(
670            LogTemplate::new()
671                .with_info("Gopher browsing session ended")
672                .with_debug("Gopher disconnect"),
673        ),
674    }
675}