1use crate::llm::actions::{
4 client_trait::{Client, ClientActionResult},
5 protocol_trait::Protocol,
6 ActionDefinition, Parameter, ParameterDefinition,
7};
8use crate::protocol::EventType;
9use crate::state::app_state::AppState;
10use anyhow::{Context, Result};
11use serde_json::json;
12use std::sync::LazyLock;
13
14pub fn decode_outbound_data(action: &serde_json::Value) -> Result<Vec<u8>> {
34 let data = action.get("data").and_then(|v| v.as_str());
35 let legacy = action.get("data_hex").and_then(|v| v.as_str());
36
37 match (data, legacy) {
38 (Some(_), Some(_)) => Err(anyhow::anyhow!(
39 "Both 'data' and 'data_hex' were supplied. Send exactly one: 'data' with an \
40 optional 'encoding' (\"utf8\" by default, or \"hex\"), or the deprecated \
41 'data_hex'. They are not combined and neither takes precedence."
42 )),
43 (Some(text), None) => decode_with_encoding(text, action),
44 (None, Some(h)) => decode_hex_field(h, "data_hex"),
45 (None, None) => Err(anyhow::anyhow!(
46 "Missing 'data' field. Put the payload in 'data' and, when it is binary, add \
47 \"encoding\": \"hex\"."
48 )),
49 }
50}
51
52fn decode_with_encoding(data: &str, action: &serde_json::Value) -> Result<Vec<u8>> {
53 match action
54 .get("encoding")
55 .and_then(|v| v.as_str())
56 .unwrap_or("utf8")
57 {
58 "utf8" => Ok(data.as_bytes().to_vec()),
59 "hex" => decode_hex_field(data, "data"),
60 other => Err(anyhow::anyhow!(
61 "Invalid 'encoding' value {other:?}. Valid values are \"utf8\" (default, send the \
62 string's characters as-is) and \"hex\" (decode the string as hex-encoded bytes)."
63 )),
64 }
65}
66
67fn decode_hex_field(value: &str, field: &str) -> Result<Vec<u8>> {
70 let cleaned: String = value
71 .chars()
72 .filter(|c| !c.is_ascii_whitespace() && *c != ':')
73 .collect();
74 let cleaned = cleaned.strip_prefix("0x").unwrap_or(&cleaned);
75
76 if cleaned.len() % 2 != 0 {
77 return Err(anyhow::anyhow!(
78 "Invalid hex in '{field}': expected an even number of hex digits, got {} \
79 ({value:?}). Each byte is two hex digits, e.g. \"48656c6c6f\" = \"Hello\".",
80 cleaned.len()
81 ));
82 }
83
84 hex::decode(cleaned).map_err(|e| {
85 anyhow::anyhow!(
86 "Invalid hex in '{field}' ({value:?}): {e}. Use only 0-9/a-f, two digits per byte, \
87 e.g. \"48656c6c6f\" = \"Hello\". To send this string as literal text, put it in \
88 'data' and omit 'encoding' (or set it to \"utf8\")."
89 )
90 })
91}
92
93pub fn inbound_event_fields(data: &[u8]) -> serde_json::Value {
101 let (text, encoding) = if data
102 .iter()
103 .all(|&b| b.is_ascii_graphic() || b.is_ascii_whitespace())
104 {
105 (String::from_utf8_lossy(data).to_string(), "utf8")
106 } else {
107 (hex::encode(data), "hex")
108 };
109
110 json!({
111 "data": text,
112 "encoding": encoding,
113 "data_length": data.len(),
114 })
115}
116
117const SEND_DATA_DESCRIPTION: &str = "Send a payload to the destination through the established \
122 Tor circuit. The 'data' field holds the payload and the optional 'encoding' field says \
123 how to turn it into bytes: omit 'encoding' (or use \"utf8\") to send the string's \
124 characters as-is, or set \"encoding\": \"hex\" to send 'data' decoded from hex. There \
125 is no auto-detection - a string like \"48656c6c6f\" is sent literally unless you set \
126 \"encoding\": \"hex\".";
127
128fn data_parameter() -> Parameter {
130 Parameter {
131 name: "data".to_string(),
132 type_hint: "string".to_string(),
133 description: "Payload to send to the destination through the Tor circuit. Interpreted \
134 according to 'encoding': as literal text by default, or as hex-encoded \
135 bytes when \"encoding\": \"hex\"."
136 .to_string(),
137 required: true,
138 }
139}
140
141fn encoding_parameter() -> Parameter {
143 Parameter {
144 name: "encoding".to_string(),
145 type_hint: "string".to_string(),
146 description: "How to convert 'data' into the bytes put on the wire. \"utf8\" (the \
147 default when omitted) sends the characters of 'data' unchanged - use it \
148 for text protocols such as HTTP. \"hex\" decodes 'data' as hex-encoded \
149 bytes, two hex digits per byte. There is no auto-detection: \
150 {\"data\": \"48656c6c6f\"} sends the 10 characters 4-8-6-5-6-c-6-c-6-f, \
151 and only {\"data\": \"48656c6c6f\", \"encoding\": \"hex\"} sends the \
152 5 bytes 'Hello'. No other values are accepted."
153 .to_string(),
154 required: false,
155 }
156 .with_choices(["utf8", "hex"])
157}
158
159pub static TOR_CLIENT_CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
161 EventType::new(
162 "tor_connected",
163 "Tor client successfully connected through Tor network",
164 json!({
165 "type": "send_tor_data",
166 "data": "GET / HTTP/1.1\r\nHost: example.com\r\n\r\n",
167 "encoding": "utf8"
168 }),
169 )
170 .with_parameters(vec![Parameter {
171 name: "target".to_string(),
172 type_hint: "string".to_string(),
173 description: "Target address (can be regular hostname:port or .onion:port)".to_string(),
174 required: true,
175 }])
176});
177
178pub static TOR_CLIENT_DATA_RECEIVED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
180 EventType::new(
181 "tor_data_received",
182 "Data received from destination through Tor",
183 json!({
184 "type": "send_tor_data",
185 "data": "GET /next HTTP/1.1\r\nHost: example.com\r\n\r\n",
186 "encoding": "utf8"
187 }),
188 )
189 .with_parameters(vec![
190 Parameter {
191 name: "data".to_string(),
192 type_hint: "string".to_string(),
193 description: "The data received from the destination. Read it according to the \
194 'encoding' field of this event."
195 .to_string(),
196 required: true,
197 },
198 Parameter {
199 name: "encoding".to_string(),
200 type_hint: "string".to_string(),
201 description: "How to read 'data': \"utf8\" means 'data' is the received bytes as \
202 literal text, \"hex\" means it is those bytes hex-encoded (two hex \
203 digits per byte, used whenever they are not all printable ASCII). To \
204 send the same bytes back unchanged, pass this 'data' and 'encoding' \
205 straight to send_tor_data."
206 .to_string(),
207 required: true,
208 },
209 Parameter {
210 name: "data_length".to_string(),
211 type_hint: "number".to_string(),
212 description: "Length of the received data in bytes, before any encoding".to_string(),
213 required: true,
214 },
215 ])
216});
217
218#[cfg(unix)]
220pub static TOR_BOOTSTRAP_COMPLETE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
221 EventType::new(
222 "tor_bootstrap_complete",
223 "Tor client finished bootstrapping and downloaded network consensus",
224 json!({
225 "type": "send_tor_data",
226 "data": "GET / HTTP/1.1\r\nHost: example.com\r\n\r\n",
227 "encoding": "utf8"
228 }),
229 )
230 .with_parameters(vec![
231 Parameter {
232 name: "relay_count".to_string(),
233 type_hint: "number".to_string(),
234 description: "Number of relays in consensus".to_string(),
235 required: true,
236 },
237 Parameter {
238 name: "valid_after".to_string(),
239 type_hint: "string".to_string(),
240 description: "Consensus valid-after timestamp".to_string(),
241 required: true,
242 },
243 ])
244});
245
246pub struct TorClientProtocol;
248
249impl TorClientProtocol {
250 pub fn new() -> Self {
251 Self
252 }
253}
254
255impl Protocol for TorClientProtocol {
257 fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
258 vec![
259 ParameterDefinition {
260 name: "directory_server".to_string(),
261 type_hint: "string".to_string(),
262 description: "Custom Tor relay address (e.g., '127.0.0.1:9001') to bootstrap from, using BEGIN_DIR (directory over circuit) instead of the public Tor network. Pair with a local tor_relay server for offline use. Exactly one of this or allow_public_tor_network must be given; without either the client refuses to start.".to_string(),
263 required: false,
264 example: json!("127.0.0.1:9001"),
265 default: None,
266 },
267 ParameterDefinition {
268 name: crate::client::tor::ALLOW_PUBLIC_TOR_NETWORK_PARAM.to_string(),
269 type_hint: "boolean".to_string(),
270 description: "Opt in to bootstrapping against the REAL Tor directory authorities on the public internet. Off by default: bootstrapping contacts third parties before the requested destination is even looked at, so opening a Tor client would otherwise reach the internet no matter what you asked it to connect to. Set true only when you intend outbound public traffic.".to_string(),
271 required: false,
272 example: json!(true),
273 default: None,
274 },
275 ]
276 }
277
278 fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
279 vec![
280 ActionDefinition {
281 name: "send_tor_data".to_string(),
282 description: SEND_DATA_DESCRIPTION.to_string(),
283 parameters: vec![data_parameter(), encoding_parameter()],
284 example: json!({
285 "type": "send_tor_data",
286 "data": "GET / HTTP/1.1\r\nHost: example.com\r\n\r\n",
287 "encoding": "utf8"
288 }),
289 log_template: None,
290 },
291 ActionDefinition {
292 name: "disconnect".to_string(),
293 description: "Disconnect from the Tor circuit".to_string(),
294 parameters: vec![],
295 example: json!({
296 "type": "disconnect"
297 }),
298 log_template: None,
299 },
300 #[cfg(unix)]
301 ActionDefinition {
302 name: "get_consensus_info".to_string(),
303 description: "Get network consensus metadata (relay count, validity times)"
304 .to_string(),
305 parameters: vec![],
306 example: json!({
307 "type": "get_consensus_info"
308 }),
309 log_template: None,
310 },
311 #[cfg(unix)]
312 ActionDefinition {
313 name: "list_relays".to_string(),
314 description: "List relays from the Tor network consensus".to_string(),
315 parameters: vec![Parameter {
316 name: "limit".to_string(),
317 type_hint: "number".to_string(),
318 description: "Maximum number of relays to return (default: 100)".to_string(),
319 required: false,
320 }],
321 example: json!({
322 "type": "list_relays",
323 "limit": 50
324 }),
325 log_template: None,
326 },
327 #[cfg(unix)]
328 ActionDefinition {
329 name: "search_relays".to_string(),
330 description: "Search for relays matching criteria (flags, nickname pattern)"
331 .to_string(),
332 parameters: vec![
333 Parameter {
334 name: "flags".to_string(),
335 type_hint: "array".to_string(),
336 description: "Required flags (e.g., [\"Guard\", \"Exit\", \"Fast\"])"
337 .to_string(),
338 required: false,
339 },
340 Parameter {
341 name: "nickname".to_string(),
342 type_hint: "string".to_string(),
343 description: "Nickname pattern to match".to_string(),
344 required: false,
345 },
346 Parameter {
347 name: "limit".to_string(),
348 type_hint: "number".to_string(),
349 description: "Maximum results (default: 100)".to_string(),
350 required: false,
351 },
352 ],
353 example: json!({
354 "type": "search_relays",
355 "flags": ["Exit", "Fast"],
356 "limit": 20
357 }),
358 log_template: None,
359 },
360 ]
361 }
362 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
363 vec![
364 ActionDefinition {
365 name: "send_tor_data".to_string(),
366 description: format!(
367 "Send data through the Tor circuit in response to data received from the \
368 destination. {SEND_DATA_DESCRIPTION}"
369 ),
370 parameters: vec![data_parameter(), encoding_parameter()],
371 example: json!({
372 "type": "send_tor_data",
373 "data": "GET /next HTTP/1.1\r\nHost: example.com\r\n\r\n",
374 "encoding": "utf8"
375 }),
376 log_template: None,
377 },
378 ActionDefinition {
379 name: "wait_for_more".to_string(),
380 description: "Wait for more data before responding".to_string(),
381 parameters: vec![],
382 example: json!({
383 "type": "wait_for_more"
384 }),
385 log_template: None,
386 },
387 ]
388 }
389 fn protocol_name(&self) -> &'static str {
390 "Tor"
391 }
392 fn get_event_types(&self) -> Vec<EventType> {
401 vec![
402 TOR_CLIENT_CONNECTED_EVENT.clone(),
403 TOR_CLIENT_DATA_RECEIVED_EVENT.clone(),
404 TOR_BOOTSTRAP_COMPLETE_EVENT.clone(),
408 ]
409 }
410 fn stack_name(&self) -> &'static str {
411 "Tor>TCP"
412 }
413 fn keywords(&self) -> Vec<&'static str> {
414 vec!["tor", "tor client", "onion", "anonymous", "privacy"]
415 }
416 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
417 use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
418
419 ProtocolMetadataV2::builder()
420 .state(DevelopmentState::Experimental)
421 .implementation("Arti Tor client (pure Rust Tor implementation)")
422 .llm_control("Full control over data sent/received through Tor circuits")
423 .e2e_testing("Connect to onion services or regular hosts through Tor")
424 .notes(
425 "REACHES THE PUBLIC INTERNET ONLY ON EXPLICIT OPT-IN. arti's \
426 create_bootstrapped() contacts the real Tor directory authorities BEFORE it \
427 looks at the requested address (~14s), so merely opening this client used to \
428 make outbound connections to third parties whatever the destination was. It \
429 now refuses to start unless the caller passes either `directory_server` (a \
430 directory to bootstrap from, e.g. a local `127.0.0.1:9001` tor_relay) or \
431 `allow_public_tor_network: true`. Passing both is refused rather than \
432 guessed. Bootstrap is 10-30s even when permitted.",
433 )
434 .build()
435 }
436 fn description(&self) -> &'static str {
437 "Tor client for anonymous connections through the Tor network"
438 }
439 fn example_prompt(&self) -> &'static str {
440 "Connect to example.onion:80 through Tor and send HTTP GET request"
441 }
442 fn group_name(&self) -> &'static str {
443 "VPN & Tunneling"
444 }
445 fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
446 use crate::llm::actions::StartupExamples;
447 use serde_json::json;
448
449 StartupExamples::new(
450 json!({
457 "type": "open_client",
458 "remote_addr": "example.com:80",
459 "base_stack": "tor",
460 "startup_params": {"allow_public_tor_network": true},
461 "instruction": "Connect to the destination through Tor and send an HTTP GET request"
462 }),
463 json!({
465 "type": "open_client",
466 "remote_addr": "example.com:80",
467 "base_stack": "tor",
468 "startup_params": {"allow_public_tor_network": true},
469 "event_handlers": [{
470 "event_pattern": "tor_connected",
471 "handler": {
472 "type": "script",
473 "language": "python",
474 "code": "<tor_client_handler>"
475 }
476 }]
477 }),
478 json!({
480 "type": "open_client",
481 "remote_addr": "example.com:80",
482 "base_stack": "tor",
483 "startup_params": {"allow_public_tor_network": true},
484 "event_handlers": [
485 {
486 "event_pattern": "tor_connected",
487 "handler": {
488 "type": "static",
489 "actions": [{
490 "type": "send_tor_data",
491 "data": "GET / HTTP/1.1\r\nHost: example.com\r\n\r\n",
492 "encoding": "utf8"
493 }]
494 }
495 },
496 {
497 "event_pattern": "tor_data_received",
498 "handler": {
499 "type": "static",
500 "actions": [{
501 "type": "disconnect"
502 }]
503 }
504 }
505 ]
506 }),
507 )
508 }
509}
510
511impl Client for TorClientProtocol {
513 fn connect(
514 &self,
515 ctx: crate::protocol::ConnectContext,
516 ) -> std::pin::Pin<
517 Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
518 > {
519 Box::pin(async move {
520 use crate::client::tor::TorClient;
521 TorClient::connect_with_llm_actions(
522 ctx.remote_addr,
523 ctx.llm_client,
524 ctx.state,
525 ctx.status_tx,
526 ctx.client_id,
527 ctx.startup_params,
528 )
529 .await
530 })
531 }
532 fn execute_action(&self, action: serde_json::Value) -> Result<ClientActionResult> {
533 let action_type = action
534 .get("type")
535 .and_then(|v| v.as_str())
536 .context("Missing 'type' field in action")?;
537
538 match action_type {
539 "send_tor_data" => Ok(ClientActionResult::SendData(decode_outbound_data(&action)?)),
540 "disconnect" => Ok(ClientActionResult::Disconnect),
541 "wait_for_more" => Ok(ClientActionResult::WaitForMore),
542
543 #[cfg(unix)]
545 "get_consensus_info" | "list_relays" | "search_relays" => {
546 Ok(ClientActionResult::Custom {
547 name: action_type.to_string(),
548 data: action.clone(),
549 })
550 }
551
552 _ => Err(anyhow::anyhow!(
553 "Unknown Tor client action: {}",
554 action_type
555 )),
556 }
557 }
558}