Skip to main content

netget/client/telnet/
actions.rs

1//! Telnet client protocol actions implementation
2
3use crate::llm::actions::{
4    client_trait::{Client, ClientActionResult},
5    protocol_trait::Protocol,
6    ActionDefinition, Parameter,
7};
8use crate::protocol::EventType;
9use crate::state::app_state::AppState;
10use anyhow::{Context, Result};
11use serde_json::json;
12use std::sync::LazyLock;
13
14/// Send a line, with the newline appended for you.
15fn send_command_action() -> ActionDefinition {
16    ActionDefinition {
17        name: "send_command".to_string(),
18        description: "Send a text command to the Telnet server (a newline is appended)".to_string(),
19        parameters: vec![Parameter {
20            name: "command".to_string(),
21            type_hint: "string".to_string(),
22            description: "The command text to send (newline will be appended)".to_string(),
23            required: true,
24        }],
25        example: json!({
26            "type": "send_command",
27            "command": "ls -la"
28        }),
29        log_template: None,
30    }
31}
32
33/// Send exact bytes, for a prompt that does not want a line ending.
34fn send_text_action() -> ActionDefinition {
35    ActionDefinition {
36        name: "send_text".to_string(),
37        description: "Send raw text to the Telnet server, exactly as given and with no newline \
38                      added. Use it where the server is waiting mid-line."
39            .to_string(),
40        parameters: vec![Parameter {
41            name: "text".to_string(),
42            type_hint: "string".to_string(),
43            description: "The text to send".to_string(),
44            required: true,
45        }],
46        example: json!({
47            "type": "send_text",
48            "text": "yes"
49        }),
50        log_template: None,
51    }
52}
53
54/// Say nothing and read again.
55fn wait_for_more_action() -> ActionDefinition {
56    ActionDefinition {
57        name: "wait_for_more".to_string(),
58        description: "Send nothing and wait for the server's next write. Use it when what \
59                      arrived is only part of a prompt or a banner."
60            .to_string(),
61        parameters: vec![],
62        example: json!({
63            "type": "wait_for_more"
64        }),
65        log_template: None,
66    }
67}
68
69/// Hang up.
70fn disconnect_action() -> ActionDefinition {
71    ActionDefinition {
72        name: "disconnect".to_string(),
73        description: "Disconnect from the Telnet server".to_string(),
74        parameters: vec![],
75        example: json!({
76            "type": "disconnect"
77        }),
78        log_template: None,
79    }
80}
81
82/// Telnet client connected event
83pub static TELNET_CLIENT_CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
84    EventType::new(
85        "telnet_connected",
86        "Telnet client successfully connected to server. Nothing has arrived yet - many \
87         servers send a banner or a login prompt unprompted, so wait_for_more is often the \
88         right first answer.",
89        json!({
90            "type": "wait_for_more"
91        }),
92    )
93    .with_parameters(vec![Parameter {
94        name: "remote_addr".to_string(),
95        type_hint: "string".to_string(),
96        description: "Remote server address".to_string(),
97        required: true,
98    }])
99    .with_actions(vec![
100        send_command_action(),
101        send_text_action(),
102        wait_for_more_action(),
103        disconnect_action(),
104    ])
105});
106
107/// Telnet client data received event
108pub static TELNET_CLIENT_DATA_RECEIVED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
109    EventType::new(
110        "telnet_data_received",
111        "Data received from Telnet server, with option negotiation already stripped and \
112         answered. Reply with send_command or send_text, or wait_for_more if this looks like \
113         part of a prompt you have not finished reading.",
114        // Rendered verbatim into the documentation the model reads, so it has to be an action
115        // the executor accepts. This was `{"type": "placeholder"}`.
116        json!({
117            "type": "send_command",
118            "command": "whoami"
119        }),
120    )
121    // `raw_hex` used to be here too — the whole read, hex-encoded, up to 16 KB of hex per
122    // turn. The repo's action/event rules forbid it ("never put raw bytes or base64 in action
123    // parameters or event data"): models cannot reliably parse hex, the negotiation it
124    // exposed is answered in Rust rather than by the model, and it doubled the prompt for
125    // nothing.
126    .with_parameters(vec![Parameter {
127        name: "data".to_string(),
128        type_hint: "string".to_string(),
129        description: "The text the server sent, with Telnet IAC sequences removed".to_string(),
130        required: true,
131    }])
132    .with_actions(vec![
133        send_command_action(),
134        send_text_action(),
135        wait_for_more_action(),
136        disconnect_action(),
137    ])
138});
139
140/// Telnet client protocol action handler
141pub struct TelnetClientProtocol;
142
143impl TelnetClientProtocol {
144    pub fn new() -> Self {
145        Self
146    }
147}
148
149// Implement Protocol trait (common functionality)
150impl Protocol for TelnetClientProtocol {
151    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
152        vec![
153            send_command_action(),
154            send_text_action(),
155            wait_for_more_action(),
156            disconnect_action(),
157        ]
158    }
159    /// The same four.
160    ///
161    /// A client has one LLM entry point, so the async/sync split cannot express a narrowing,
162    /// and the two readers that matter union the lists: `client_llm_action_set` for the model
163    /// and `client_action_names_for_pattern` for `event_handlers` validation, which no longer
164    /// reads the sync list alone — `disconnect` was async-only and a static handler naming it
165    /// was rejected as an unknown action until that was fixed centrally.
166    ///
167    /// The copy stays because a third reader still takes the sync list by itself:
168    /// `cli::tasks`'s `execute_single_task` builds a **client-scoped scheduled task**'s
169    /// action list from `get_sync_actions()` alone, and `ConversationHandler` rejects
170    /// everything outside it. Union there too and this list can go.
171    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
172        vec![
173            send_command_action(),
174            send_text_action(),
175            wait_for_more_action(),
176            disconnect_action(),
177        ]
178    }
179    fn protocol_name(&self) -> &'static str {
180        "Telnet"
181    }
182    /// The statics above, which are the ones `mod.rs` emits.
183    ///
184    /// This used to build three `EventType`s inline: two duplicating the statics' ids but
185    /// with no parameters and `{"type": "placeholder"}` as the example action — so the model
186    /// was shown `placeholder` as the way to answer, and `remote_addr`/`data` were documented
187    /// nowhere it could read — plus a third, `telnet_option_negotiated`, that **nothing in
188    /// `src/` ever raised**. An `event_handlers` rule on it could never match and the model
189    /// was told to expect something that does not exist. It is gone rather than emitted:
190    /// option negotiation is answered here in Rust, deliberately, and the model has no say in
191    /// it, so an event per option would be noise with nothing to decide.
192    ///
193    /// `event_emit_sites_test` misses a case like that precisely because the `EventType` is
194    /// built inline instead of being a named static it can find.
195    fn get_event_types(&self) -> Vec<EventType> {
196        vec![
197            TELNET_CLIENT_CONNECTED_EVENT.clone(),
198            TELNET_CLIENT_DATA_RECEIVED_EVENT.clone(),
199        ]
200    }
201    fn stack_name(&self) -> &'static str {
202        "ETH>IP>TCP>Telnet"
203    }
204    fn keywords(&self) -> Vec<&'static str> {
205        vec![
206            "telnet",
207            "telnet client",
208            "connect to telnet",
209            "remote shell",
210        ]
211    }
212    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
213        use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
214
215        ProtocolMetadataV2::builder()
216            .state(DevelopmentState::Experimental)
217            .implementation("Raw TCP with Telnet option negotiation")
218            .llm_control("Send commands and respond to server output, automatic option negotiation")
219            .e2e_testing("telnetd or netcat as test server")
220            .build()
221    }
222    fn description(&self) -> &'static str {
223        "Telnet client for connecting to Telnet servers and executing commands"
224    }
225    fn example_prompt(&self) -> &'static str {
226        "Connect to Telnet at localhost:23 and run 'whoami' command"
227    }
228    fn group_name(&self) -> &'static str {
229        "Infrastructure"
230    }
231    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
232        use crate::llm::actions::StartupExamples;
233        use serde_json::json;
234
235        StartupExamples::new(
236            // LLM mode: LLM controls Telnet session
237            json!({
238                "type": "open_client",
239                "remote_addr": "localhost:23",
240                "base_stack": "telnet",
241                "instruction": "Login and execute 'whoami' command"
242            }),
243            // Script mode: Code-based command handling
244            json!({
245                "type": "open_client",
246                "remote_addr": "localhost:23",
247                "base_stack": "telnet",
248                "event_handlers": [{
249                    "event_pattern": "telnet_data_received",
250                    "handler": {
251                        "type": "script",
252                        "language": "python",
253                        "code": "<telnet_client_handler>"
254                    }
255                }]
256            }),
257            // Static mode: Fixed command sequence
258            json!({
259                "type": "open_client",
260                "remote_addr": "localhost:23",
261                "base_stack": "telnet",
262                "event_handlers": [
263                    {
264                        "event_pattern": "telnet_connected",
265                        "handler": {
266                            "type": "static",
267                            "actions": [{
268                                "type": "send_command",
269                                "command": "whoami"
270                            }]
271                        }
272                    },
273                    {
274                        "event_pattern": "telnet_data_received",
275                        "handler": {
276                            "type": "static",
277                            "actions": [{
278                                "type": "disconnect"
279                            }]
280                        }
281                    }
282                ]
283            }),
284        )
285    }
286}
287
288// Implement Client trait (client-specific functionality)
289impl Client for TelnetClientProtocol {
290    fn connect(
291        &self,
292        ctx: crate::protocol::ConnectContext,
293    ) -> std::pin::Pin<
294        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
295    > {
296        Box::pin(async move {
297            use crate::client::telnet::TelnetClient;
298            TelnetClient::connect_with_llm_actions(
299                ctx.remote_addr,
300                ctx.llm_client,
301                ctx.state,
302                ctx.status_tx,
303                ctx.client_id,
304            )
305            .await
306        })
307    }
308    fn execute_action(&self, action: serde_json::Value) -> Result<ClientActionResult> {
309        let action_type = action
310            .get("type")
311            .and_then(|v| v.as_str())
312            .context("Missing 'type' field in action")?;
313
314        match action_type {
315            "send_command" => {
316                let command = action
317                    .get("command")
318                    .and_then(|v| v.as_str())
319                    .context("Missing 'command' field")?;
320
321                // Append newline for command
322                let data = format!("{}\r\n", command).into_bytes();
323                Ok(ClientActionResult::SendData(data))
324            }
325            "send_text" => {
326                let text = action
327                    .get("text")
328                    .and_then(|v| v.as_str())
329                    .context("Missing 'text' field")?;
330
331                let data = text.as_bytes().to_vec();
332                Ok(ClientActionResult::SendData(data))
333            }
334            "disconnect" => Ok(ClientActionResult::Disconnect),
335            "wait_for_more" => Ok(ClientActionResult::WaitForMore),
336            _ => Err(anyhow::anyhow!(
337                "Unknown Telnet client action: {}",
338                action_type
339            )),
340        }
341    }
342}