Skip to main content

netget/client/smb/
mod.rs

1//! SMB client implementation
2pub mod actions;
3
4pub use actions::SmbClientProtocol;
5
6use crate::llm::actions::client_trait::Client;
7use anyhow::{Context, Result};
8use std::net::SocketAddr;
9use std::sync::Arc;
10use tokio::sync::mpsc;
11use tracing::{debug, error, info};
12
13use crate::client::llm_budget::call_llm_for_client;
14use crate::client::smb::actions::{
15    SMB_CLIENT_CONNECTED_EVENT, SMB_CLIENT_DIR_LISTED_EVENT, SMB_CLIENT_ERROR_EVENT,
16    SMB_CLIENT_FILE_READ_EVENT, SMB_CLIENT_FILE_WRITTEN_EVENT,
17};
18use crate::llm::ollama_client::OllamaClient;
19use crate::llm::ClientLlmResult;
20use crate::protocol::{Event, StartupParams};
21use crate::state::app_state::AppState;
22use crate::state::{ClientId, ClientStatus};
23
24use pavao::{
25    SmbClient as PavaoSmbClient, SmbCredentials, SmbDirent, SmbDirentType, SmbOpenOptions,
26    SmbOptions,
27};
28use std::io::{Read as IoRead, Write as IoWrite};
29use tokio::sync::Mutex;
30
31/// The live libsmbclient handle, shared by the LLM task and the command loop.
32///
33/// Every native call runs on a blocking worker; its file handles are created and
34/// dropped on that worker. The async mutex serializes operations before spawning
35/// work, and no native handle or guard survives into a model event dispatch.
36type SharedSmb = Arc<Mutex<NativeSmb>>;
37// pavao 0.2 owns one process-global native context; dropping any SmbClient frees it.
38// Refuse a second live owner rather than letting it invalidate the first session.
39static NATIVE_SESSION: std::sync::LazyLock<Arc<tokio::sync::Semaphore>> =
40    std::sync::LazyLock::new(|| Arc::new(tokio::sync::Semaphore::new(1)));
41struct NativeSmb {
42    client: Option<PavaoSmbClient>,
43    lease: Option<tokio::sync::OwnedSemaphorePermit>,
44}
45impl Drop for NativeSmb {
46    fn drop(&mut self) {
47        let client = self.client.take();
48        let lease = self.lease.take();
49        let cleanup = move || {
50            drop(client);
51            drop(lease);
52        };
53        if let Ok(runtime) = tokio::runtime::Handle::try_current() {
54            runtime.spawn_blocking(cleanup);
55        } else {
56            std::thread::spawn(cleanup);
57        }
58    }
59}
60
61/// Buffered file contents forwarded into model events are capped independently of SMB packets.
62pub const MAX_FILE_BYTES: usize = 8 * 1024 * 1024;
63
64/// Read at most one byte beyond the cap, refusing partial data instead of claiming success.
65pub fn read_file_bounded(reader: impl IoRead) -> std::io::Result<Vec<u8>> {
66    let mut data = Vec::new();
67    reader
68        .take(MAX_FILE_BYTES as u64 + 1)
69        .read_to_end(&mut data)?;
70    if data.len() > MAX_FILE_BYTES {
71        return Err(std::io::Error::new(
72            std::io::ErrorKind::InvalidData,
73            "SMB file exceeds buffered byte cap",
74        ));
75    }
76    Ok(data)
77}
78
79async fn blocking_smb<T: Send + 'static>(
80    client: &SharedSmb,
81    operation: impl FnOnce(&PavaoSmbClient) -> Result<T> + Send + 'static,
82) -> Result<T> {
83    let client = client.clone().lock_owned().await;
84    tokio::task::spawn_blocking(move || {
85        operation(client.client.as_ref().context("SMB client is closed")?)
86    })
87    .await
88    .context("SMB worker panicked")?
89}
90
91/// What applying one action against the SMB share actually did.
92///
93/// SMB never yields [`crate::state::client_handles::ClientSendOutcome::Sent`]:
94/// libsmbclient owns the transport and may sign or encrypt it, so NetGet never
95/// sees a byte count on the wire. A write reports the number of payload bytes it
96/// really put into the file, inside `Executed`.
97pub enum SmbApplied {
98    /// The action ran; the string describes what it did.
99    Executed(String),
100    /// The client was disconnected.
101    Disconnected,
102}
103
104/// SMB client that connects to an SMB/CIFS server
105pub struct SmbClient;
106
107impl SmbClient {
108    /// Connect to an SMB server with integrated LLM actions
109    pub async fn connect_with_llm_actions(
110        remote_addr: String,
111        llm_client: OllamaClient,
112        app_state: Arc<AppState>,
113        status_tx: mpsc::UnboundedSender<String>,
114        client_id: ClientId,
115        startup_params: Option<StartupParams>,
116    ) -> Result<SocketAddr> {
117        info!(
118            "SMB client {} initializing connection to {}",
119            client_id, remote_addr
120        );
121
122        // Parse startup parameters for credentials
123        let (username, password, domain, workgroup) = if let Some(params) = startup_params {
124            let username = params
125                .get_optional_string("username")?
126                .unwrap_or_else(|| "guest".to_string());
127            let password = params
128                .get_optional_string("password")?
129                .unwrap_or_else(|| "".to_string());
130            let domain = params.get_optional_string("domain")?;
131            let workgroup = params.get_optional_string("workgroup")?;
132
133            (username, password, domain, workgroup)
134        } else {
135            ("guest".to_string(), "".to_string(), None, None)
136        };
137
138        info!(
139            "SMB client {} using credentials - username: {}, domain: {:?}, workgroup: {:?}",
140            client_id, username, domain, workgroup
141        );
142
143        // Create SMB credentials using builder pattern
144        let mut creds = SmbCredentials::default()
145            .server(&remote_addr)
146            .username(&username)
147            .password(&password);
148
149        // Note: pavao API uses workgroup() for both domain and workgroup
150        if let Some(w) = workgroup.or(domain) {
151            creds = creds.workgroup(&w);
152        }
153
154        // Acquire ownership before native initialization: pavao's context is global.
155        let lease = NATIVE_SESSION
156            .clone()
157            .try_acquire_owned()
158            .context("the pavao SMB backend supports one active native session per process")?;
159        // Create SMB client
160        let smb_client: SharedSmb = Arc::new(Mutex::new(
161            tokio::task::spawn_blocking(move || {
162                let client = PavaoSmbClient::new(creds, SmbOptions::default())
163                    .context("Failed to create SMB client")?;
164                client.set_timeout(std::time::Duration::from_secs(30))?;
165                Ok::<_, anyhow::Error>(NativeSmb {
166                    client: Some(client),
167                    lease: Some(lease),
168                })
169            })
170            .await
171            .context("SMB initialization worker panicked")??,
172        ));
173
174        // For SMB, we use a dummy local address since it's a library-based client
175        // The actual connection happens per-operation
176        let local_addr = "127.0.0.1:0".parse::<SocketAddr>()?;
177
178        // Update client state
179        app_state
180            .update_client_status(client_id, ClientStatus::Connected)
181            .await;
182        let _ = status_tx.send(format!("[CLIENT] SMB client {} connected", client_id));
183        let _ = status_tx.send("__UPDATE_UI__".to_string());
184
185        info!("SMB client {} connected to {}", client_id, remote_addr);
186
187        // Command channel for injected actions (the dashboard's [ send ]).
188        // Registered BEFORE the connected-event LLM call below: a dashboard-created
189        // client defaults to a `*` -> manual rule, so that call can park for minutes
190        // waiting for a human, and the operator must be able to reach the share while
191        // it waits.
192        let command_rx =
193            crate::client::command_support::register_command_channel(&app_state, client_id).await;
194        let cmd_task = tokio::spawn({
195            let smb_client = smb_client.clone();
196            let llm_client = llm_client.clone();
197            let app_state = app_state.clone();
198            let status_tx = status_tx.clone();
199            async move {
200                Self::command_loop(
201                    command_rx, smb_client, client_id, llm_client, app_state, status_tx,
202                )
203                .await;
204            }
205        });
206        app_state.register_client_task(client_id, cmd_task).await;
207
208        // Spawn task to handle LLM interactions
209        let app_state_clone = app_state.clone();
210        let status_tx_clone = status_tx.clone();
211        let remote_addr_clone = remote_addr.clone();
212
213        // Registered with AppState so stop_client can abort this task —
214        // dropping a JoinHandle only detaches it in Tokio.
215        let task_registrar = app_state.clone();
216        let task_handle = tokio::spawn(async move {
217            // Send initial connected event to LLM
218            if let Some(instruction) = app_state_clone.get_instruction_for_client(client_id).await {
219                let protocol = Arc::new(SmbClientProtocol::new());
220                let event = Event::new(
221                    &SMB_CLIENT_CONNECTED_EVENT,
222                    serde_json::json!({
223                        "share_url": format!("smb://{}", remote_addr_clone),
224                    }),
225                );
226
227                let memory = app_state_clone
228                    .get_memory_for_client(client_id)
229                    .await
230                    .unwrap_or_default();
231
232                match call_llm_for_client(
233                    &llm_client,
234                    &app_state_clone,
235                    client_id.to_string(),
236                    &instruction,
237                    &memory,
238                    Some(&event),
239                    protocol.as_ref(),
240                    &status_tx_clone,
241                )
242                .await
243                {
244                    Ok(ClientLlmResult {
245                        actions,
246                        memory_updates,
247                    }) => {
248                        // Update memory
249                        if let Some(mem) = memory_updates {
250                            app_state_clone.set_memory_for_client(client_id, mem).await;
251                        }
252
253                        // Execute actions
254                        for action in actions {
255                            if let Err(e) = Self::execute_smb_action(
256                                &smb_client,
257                                action,
258                                client_id,
259                                &protocol,
260                                &llm_client,
261                                &app_state_clone,
262                                &status_tx_clone,
263                            )
264                            .await
265                            {
266                                error!("SMB client {} action error: {}", client_id, e);
267
268                                // Send error event to LLM
269                                let error_event = Event::new(
270                                    &SMB_CLIENT_ERROR_EVENT,
271                                    serde_json::json!({
272                                        "error": e.to_string(),
273                                        "operation": "action_execution",
274                                    }),
275                                );
276
277                                let _ = Self::call_llm_with_event(
278                                    &error_event,
279                                    client_id,
280                                    &protocol,
281                                    &llm_client,
282                                    &app_state_clone,
283                                    &status_tx_clone,
284                                    &smb_client,
285                                )
286                                .await;
287                            }
288                        }
289                    }
290                    Err(e) => {
291                        error!("LLM error for SMB client {}: {}", client_id, e);
292                    }
293                }
294            }
295        });
296        task_registrar
297            .register_client_task(client_id, task_handle)
298            .await;
299
300        Ok(local_addr)
301    }
302
303    /// Drain injected commands until the channel closes (the client was removed,
304    /// which drops the handle) or an injected `disconnect` ends the session.
305    ///
306    /// The generic `command_support::handle_stream_client_command` cannot serve this
307    /// client: it owns no socket, and every SMB verb yields a
308    /// `ClientActionResult::Custom` that only libsmbclient can carry out. So the
309    /// action goes through [`Self::execute_smb_action`] — the exact function the LLM
310    /// path uses, including the follow-up events — and the outcome is recorded and
311    /// replied the way the generic arm does it.
312    async fn command_loop(
313        mut command_rx: tokio::sync::mpsc::Receiver<crate::state::client_handles::ClientCommand>,
314        smb_client: SharedSmb,
315        client_id: ClientId,
316        llm_client: OllamaClient,
317        app_state: Arc<AppState>,
318        status_tx: mpsc::UnboundedSender<String>,
319    ) {
320        use crate::llm::actions::protocol_trait::Protocol;
321        use crate::state::client_handles::ClientSendOutcome;
322        use crate::state::AccessLogOwner;
323
324        let protocol = Arc::new(SmbClientProtocol::new());
325
326        while let Some(command) = command_rx.recv().await {
327            let action = command.action.clone();
328
329            // `execute_action` is the only step that can fail before the share is
330            // touched, so its error is a rejection (unknown verb / bad params) rather
331            // than an SMB failure.
332            let outcome = match protocol.execute_action(action.clone()) {
333                Err(e) => Ok(ClientSendOutcome::Rejected {
334                    error: e.to_string(),
335                }),
336                Ok(result) => Self::apply_smb_result(
337                    result,
338                    &smb_client,
339                    client_id,
340                    &protocol,
341                    &llm_client,
342                    &app_state,
343                    &status_tx,
344                )
345                .await
346                .map(|applied| match applied {
347                    SmbApplied::Executed(detail) => ClientSendOutcome::Executed { detail },
348                    SmbApplied::Disconnected => ClientSendOutcome::Disconnected,
349                }),
350            };
351
352            let outcome_json = match &outcome {
353                Ok(outcome) => serde_json::to_value(outcome).unwrap_or(serde_json::Value::Null),
354                Err(e) => serde_json::json!({"error": e.to_string()}),
355            };
356            app_state
357                .record_access_log(
358                    AccessLogOwner::Client(client_id.as_u32()),
359                    protocol.protocol_name(),
360                    None,
361                    "injected_action",
362                    action,
363                    vec![outcome_json],
364                )
365                .await;
366
367            let disconnect = matches!(outcome, Ok(ClientSendOutcome::Disconnected));
368            if let Err(e) = &outcome {
369                error!("SMB client {} injected action failed: {}", client_id, e);
370                let _ = status_tx.send(format!(
371                    "[WARN] Client {} injected action failed: {}",
372                    client_id, e
373                ));
374            }
375            let _ = status_tx.send("__UPDATE_UI__".to_string());
376            crate::client::command_support::reply(command, outcome);
377
378            if disconnect {
379                break;
380            }
381        }
382
383        // Every exit path lands here: drop the command handle so the dashboard stops
384        // offering [ send ] on a dead client and a late send fails fast.
385        app_state.remove_client_handle(client_id).await;
386        let _ = status_tx.send("__UPDATE_UI__".to_string());
387    }
388
389    /// Execute an SMB action and call LLM with result
390    async fn execute_smb_action(
391        smb_client: &SharedSmb,
392        action: serde_json::Value,
393        client_id: ClientId,
394        protocol: &Arc<SmbClientProtocol>,
395        llm_client: &OllamaClient,
396        app_state: &Arc<AppState>,
397        status_tx: &mpsc::UnboundedSender<String>,
398    ) -> Result<SmbApplied> {
399        let result = protocol.execute_action(action)?;
400        Self::apply_smb_result(
401            result, smb_client, client_id, protocol, llm_client, app_state, status_tx,
402        )
403        .await
404    }
405
406    /// Carry one already-decoded action out against the share. Shared by the
407    /// connected-event path, the follow-up event path and injected commands, so the
408    /// libsmbclient calls exist exactly once.
409    async fn apply_smb_result(
410        action_result: crate::llm::actions::client_trait::ClientActionResult,
411        smb_client: &SharedSmb,
412        client_id: ClientId,
413        protocol: &Arc<SmbClientProtocol>,
414        llm_client: &OllamaClient,
415        app_state: &Arc<AppState>,
416        status_tx: &mpsc::UnboundedSender<String>,
417    ) -> Result<SmbApplied> {
418        // Assigned exactly once on every path that does not return early.
419        let detail: String;
420        let mut failure = None;
421        match action_result {
422            crate::llm::actions::client_trait::ClientActionResult::Custom { name, data } => {
423                match name.as_str() {
424                    "smb_list_dir" => {
425                        let path = data
426                            .get("path")
427                            .and_then(|v| v.as_str())
428                            .context("Missing path")?;
429
430                        debug!("SMB client {} listing directory: {}", client_id, path);
431
432                        // List directory using pavao. The guard is scoped to the
433                        // synchronous call only — never held across the LLM await below.
434                        let owned_path = path.to_string();
435                        let list_result =
436                            blocking_smb(smb_client, move |smb| Ok(smb.list_dir(&owned_path)?))
437                                .await;
438                        match list_result {
439                            Ok(entries) => {
440                                let entry_list: Vec<serde_json::Value> = entries
441                                    .iter()
442                                    .map(|entry| {
443                                        let dirent: &SmbDirent = entry;
444                                        serde_json::json!({
445                                            "name": dirent.name(),
446                                            "type": match dirent.get_type() {
447                                                SmbDirentType::Workgroup => "workgroup",
448                                                SmbDirentType::Server => "server",
449                                                SmbDirentType::FileShare => "file_share",
450                                                SmbDirentType::PrinterShare => "printer_share",
451                                                SmbDirentType::CommsShare => "comms_share",
452                                                SmbDirentType::IpcShare => "ipc_share",
453                                                SmbDirentType::Dir => "dir",
454                                                SmbDirentType::File => "file",
455                                                SmbDirentType::Link => "link",
456                                            },
457                                            "comment": dirent.comment(),
458                                        })
459                                    })
460                                    .collect();
461
462                                info!(
463                                    "SMB client {} listed {} entries in {}",
464                                    client_id,
465                                    entry_list.len(),
466                                    path
467                                );
468                                detail = format!(
469                                    "list_directory {:?}: {} entries",
470                                    path,
471                                    entry_list.len()
472                                );
473
474                                let event = Event::new(
475                                    &SMB_CLIENT_DIR_LISTED_EVENT,
476                                    serde_json::json!({
477                                        "path": path,
478                                        "entries": entry_list,
479                                    }),
480                                );
481
482                                Self::call_llm_with_event(
483                                    &event, client_id, protocol, llm_client, app_state, status_tx,
484                                    smb_client,
485                                )
486                                .await?;
487                            }
488                            Err(e) => {
489                                error!("SMB client {} list_dir error: {}", client_id, e);
490                                detail = format!("list_directory {path:?} failed: {e}");
491                                failure = Some(e.to_string());
492                                let error_event = Event::new(
493                                    &SMB_CLIENT_ERROR_EVENT,
494                                    serde_json::json!({
495                                        "error": e.to_string(),
496                                        "operation": "list_directory",
497                                    }),
498                                );
499
500                                Self::call_llm_with_event(
501                                    &error_event,
502                                    client_id,
503                                    protocol,
504                                    llm_client,
505                                    app_state,
506                                    status_tx,
507                                    smb_client,
508                                )
509                                .await?;
510                            }
511                        }
512                    }
513                    "smb_read_file" => {
514                        let path = data
515                            .get("path")
516                            .and_then(|v| v.as_str())
517                            .context("Missing path")?;
518
519                        debug!("SMB client {} reading file: {}", client_id, path);
520
521                        // Open file for reading and immediately read contents
522                        // We need to close the file before any await (SmbFile contains raw pointer, not Send)
523                        let owned_path = path.to_string();
524                        let read_result = blocking_smb(smb_client, move |smb| {
525                            let file =
526                                smb.open_with(&owned_path, SmbOpenOptions::default().read(true))?;
527                            Ok(read_file_bounded(file)?)
528                        })
529                        .await;
530
531                        match read_result {
532                            Ok(content_bytes) => {
533                                let size = content_bytes.len();
534
535                                // Try to convert to UTF-8 string, fallback to base64 for binary
536                                let content =
537                                    if let Ok(text) = String::from_utf8(content_bytes.clone()) {
538                                        text
539                                    } else {
540                                        use base64::{engine::general_purpose, Engine as _};
541                                        format!(
542                                            "base64:{}",
543                                            general_purpose::STANDARD.encode(&content_bytes)
544                                        )
545                                    };
546
547                                info!("SMB client {} read {} bytes from {}", client_id, size, path);
548                                detail = format!("read_file {path:?}: {size} bytes read");
549
550                                let event = Event::new(
551                                    &SMB_CLIENT_FILE_READ_EVENT,
552                                    serde_json::json!({
553                                        "path": path,
554                                        "content": content,
555                                        "size": size,
556                                    }),
557                                );
558
559                                Self::call_llm_with_event(
560                                    &event, client_id, protocol, llm_client, app_state, status_tx,
561                                    smb_client,
562                                )
563                                .await?;
564                            }
565                            Err(e) => {
566                                error!("SMB client {} read_file error: {}", client_id, e);
567                                detail = format!("read_file {path:?} failed: {e}");
568                                failure = Some(e.to_string());
569                                let error_event = Event::new(
570                                    &SMB_CLIENT_ERROR_EVENT,
571                                    serde_json::json!({
572                                        "error": e.to_string(),
573                                        "operation": "read_file",
574                                    }),
575                                );
576
577                                Self::call_llm_with_event(
578                                    &error_event,
579                                    client_id,
580                                    protocol,
581                                    llm_client,
582                                    app_state,
583                                    status_tx,
584                                    smb_client,
585                                )
586                                .await?;
587                            }
588                        }
589                    }
590                    "smb_write_file" => {
591                        let path = data
592                            .get("path")
593                            .and_then(|v| v.as_str())
594                            .context("Missing path")?;
595                        let content = data
596                            .get("content")
597                            .and_then(|v| v.as_str())
598                            .context("Missing content")?;
599
600                        debug!("SMB client {} writing file: {}", client_id, path);
601
602                        // `smb_file_read` emits binary as `base64:<encoded>`, so write must
603                        // understand the same sentinel or the round trip is broken: reading a
604                        // binary file and writing it back wrote the literal string
605                        // "base64:iVBORw0KG..." to the share. The prefix is the marker the
606                        // read side already chose; this is the other half of it.
607                        let content_bytes = match content.strip_prefix("base64:") {
608                            Some(encoded) => {
609                                use base64::{engine::general_purpose, Engine as _};
610                                general_purpose::STANDARD.decode(encoded.trim()).context(
611                                    "content began with \"base64:\" but the rest is not valid \
612                                     base64. Binary content must be base64 exactly as \
613                                     smb_file_read reports it; text content must not start \
614                                     with \"base64:\".",
615                                )?
616                            }
617                            None => content.as_bytes().to_vec(),
618                        };
619                        if content_bytes.len() > MAX_FILE_BYTES {
620                            anyhow::bail!("SMB write exceeds buffered byte cap");
621                        }
622                        let owned_path = path.to_string();
623                        let write_result = blocking_smb(smb_client, move |smb| {
624                            let mut file = smb.open_with(
625                                &owned_path,
626                                SmbOpenOptions::default()
627                                    .write(true)
628                                    .create(true)
629                                    .truncate(true),
630                            )?;
631                            file.write_all(&content_bytes)?;
632                            Ok(content_bytes.len())
633                        })
634                        .await;
635
636                        match write_result {
637                            Ok(bytes_written) => {
638                                info!(
639                                    "SMB client {} wrote {} bytes to {}",
640                                    client_id, bytes_written, path
641                                );
642                                detail =
643                                    format!("write_file {path:?}: {bytes_written} bytes written");
644
645                                let event = Event::new(
646                                    &SMB_CLIENT_FILE_WRITTEN_EVENT,
647                                    serde_json::json!({
648                                        "path": path,
649                                        "bytes_written": bytes_written,
650                                    }),
651                                );
652
653                                Self::call_llm_with_event(
654                                    &event, client_id, protocol, llm_client, app_state, status_tx,
655                                    smb_client,
656                                )
657                                .await?;
658                            }
659                            Err(e) => {
660                                error!("SMB client {} write_file error: {}", client_id, e);
661                                detail = format!("write_file {path:?} failed: {e}");
662                                failure = Some(e.to_string());
663                                let error_event = Event::new(
664                                    &SMB_CLIENT_ERROR_EVENT,
665                                    serde_json::json!({
666                                        "error": e.to_string(),
667                                        "operation": "write_file",
668                                    }),
669                                );
670
671                                Self::call_llm_with_event(
672                                    &error_event,
673                                    client_id,
674                                    protocol,
675                                    llm_client,
676                                    app_state,
677                                    status_tx,
678                                    smb_client,
679                                )
680                                .await?;
681                            }
682                        }
683                    }
684                    "smb_create_dir" => {
685                        let path = data
686                            .get("path")
687                            .and_then(|v| v.as_str())
688                            .context("Missing path")?;
689
690                        debug!("SMB client {} creating directory: {}", client_id, path);
691
692                        let owned_path = path.to_string();
693                        let mkdir_result = blocking_smb(smb_client, move |smb| {
694                            Ok(smb.mkdir(&owned_path, pavao::SmbMode::from(0o755))?)
695                        })
696                        .await;
697                        match mkdir_result {
698                            Ok(()) => {
699                                info!("SMB client {} created directory {}", client_id, path);
700                                detail = format!("create_directory {path:?}: created");
701                                let _ = status_tx.send(format!(
702                                    "[CLIENT] SMB client {} created directory: {}",
703                                    client_id, path
704                                ));
705                            }
706                            Err(e) => {
707                                error!("SMB client {} mkdir error: {}", client_id, e);
708                                detail = format!("create_directory {path:?} failed: {e}");
709                                failure = Some(e.to_string());
710                                let error_event = Event::new(
711                                    &SMB_CLIENT_ERROR_EVENT,
712                                    serde_json::json!({
713                                        "error": e.to_string(),
714                                        "operation": "create_directory",
715                                    }),
716                                );
717
718                                Self::call_llm_with_event(
719                                    &error_event,
720                                    client_id,
721                                    protocol,
722                                    llm_client,
723                                    app_state,
724                                    status_tx,
725                                    smb_client,
726                                )
727                                .await?;
728                            }
729                        }
730                    }
731                    "smb_delete_file" => {
732                        let path = data
733                            .get("path")
734                            .and_then(|v| v.as_str())
735                            .context("Missing path")?;
736
737                        debug!("SMB client {} deleting file: {}", client_id, path);
738
739                        let owned_path = path.to_string();
740                        let unlink_result =
741                            blocking_smb(smb_client, move |smb| Ok(smb.unlink(&owned_path)?)).await;
742                        match unlink_result {
743                            Ok(()) => {
744                                info!("SMB client {} deleted file {}", client_id, path);
745                                detail = format!("delete_file {path:?}: deleted");
746                                let _ = status_tx.send(format!(
747                                    "[CLIENT] SMB client {} deleted file: {}",
748                                    client_id, path
749                                ));
750                            }
751                            Err(e) => {
752                                error!("SMB client {} unlink error: {}", client_id, e);
753                                detail = format!("delete_file {path:?} failed: {e}");
754                                failure = Some(e.to_string());
755                                let error_event = Event::new(
756                                    &SMB_CLIENT_ERROR_EVENT,
757                                    serde_json::json!({
758                                        "error": e.to_string(),
759                                        "operation": "delete_file",
760                                    }),
761                                );
762
763                                Self::call_llm_with_event(
764                                    &error_event,
765                                    client_id,
766                                    protocol,
767                                    llm_client,
768                                    app_state,
769                                    status_tx,
770                                    smb_client,
771                                )
772                                .await?;
773                            }
774                        }
775                    }
776                    "smb_delete_dir" => {
777                        let path = data
778                            .get("path")
779                            .and_then(|v| v.as_str())
780                            .context("Missing path")?;
781
782                        debug!("SMB client {} deleting directory: {}", client_id, path);
783
784                        let owned_path = path.to_string();
785                        let rmdir_result =
786                            blocking_smb(smb_client, move |smb| Ok(smb.rmdir(&owned_path)?)).await;
787                        match rmdir_result {
788                            Ok(()) => {
789                                info!("SMB client {} deleted directory {}", client_id, path);
790                                detail = format!("delete_directory {path:?}: deleted");
791                                let _ = status_tx.send(format!(
792                                    "[CLIENT] SMB client {} deleted directory: {}",
793                                    client_id, path
794                                ));
795                            }
796                            Err(e) => {
797                                error!("SMB client {} rmdir error: {}", client_id, e);
798                                detail = format!("delete_directory {path:?} failed: {e}");
799                                failure = Some(e.to_string());
800                                let error_event = Event::new(
801                                    &SMB_CLIENT_ERROR_EVENT,
802                                    serde_json::json!({
803                                        "error": e.to_string(),
804                                        "operation": "delete_directory",
805                                    }),
806                                );
807
808                                Self::call_llm_with_event(
809                                    &error_event,
810                                    client_id,
811                                    protocol,
812                                    llm_client,
813                                    app_state,
814                                    status_tx,
815                                    smb_client,
816                                )
817                                .await?;
818                            }
819                        }
820                    }
821                    _ => {
822                        error!("SMB client {} unknown action: {}", client_id, name);
823                        detail = format!("custom result '{name}' has no SMB handler");
824                    }
825                }
826            }
827            crate::llm::actions::client_trait::ClientActionResult::Disconnect => {
828                info!("SMB client {} disconnecting", client_id);
829                app_state
830                    .update_client_status(client_id, ClientStatus::Disconnected)
831                    .await;
832                // Drop the command handle here rather than only in the command loop:
833                // the LLM can disconnect too, and a handle left behind would offer
834                // [ send ] into a closed client.
835                app_state.remove_client_handle(client_id).await;
836                let _ = status_tx.send(format!("[CLIENT] SMB client {} disconnected", client_id));
837                let _ = status_tx.send("__UPDATE_UI__".to_string());
838                return Ok(SmbApplied::Disconnected);
839            }
840            crate::llm::actions::client_trait::ClientActionResult::WaitForMore => {
841                debug!("SMB client {} waiting for more", client_id);
842                detail = "wait_for_more".to_string();
843            }
844            other => {
845                debug!("SMB client {} unhandled action result", client_id);
846                detail = format!("unhandled action result {other:?}");
847            }
848        }
849
850        if let Some(error) = failure {
851            anyhow::bail!("SMB operation failed: {error}");
852        }
853        Ok(SmbApplied::Executed(detail))
854    }
855
856    /// Call LLM with an event and execute resulting actions
857    async fn call_llm_with_event(
858        event: &Event,
859        client_id: ClientId,
860        protocol: &Arc<SmbClientProtocol>,
861        llm_client: &OllamaClient,
862        app_state: &Arc<AppState>,
863        status_tx: &mpsc::UnboundedSender<String>,
864        smb_client: &SharedSmb,
865    ) -> Result<()> {
866        if let Some(instruction) = app_state.get_instruction_for_client(client_id).await {
867            let memory = app_state
868                .get_memory_for_client(client_id)
869                .await
870                .unwrap_or_default();
871
872            match call_llm_for_client(
873                llm_client,
874                app_state,
875                client_id.to_string(),
876                &instruction,
877                &memory,
878                Some(event),
879                protocol.as_ref(),
880                status_tx,
881            )
882            .await
883            {
884                Ok(ClientLlmResult {
885                    actions,
886                    memory_updates,
887                }) => {
888                    // Update memory
889                    if let Some(mem) = memory_updates {
890                        app_state.set_memory_for_client(client_id, mem).await;
891                    }
892
893                    // Execute actions
894                    for action in actions {
895                        Box::pin(Self::execute_smb_action(
896                            smb_client, action, client_id, protocol, llm_client, app_state,
897                            status_tx,
898                        ))
899                        .await?;
900                    }
901                }
902                Err(e) => {
903                    error!("LLM error for SMB client {}: {}", client_id, e);
904                }
905            }
906        }
907
908        Ok(())
909    }
910}