Skip to main content

netget/server/ssh_agent/
mod.rs

1//! SSH Agent server implementation
2//!
3//! Platform: Unix/Linux/macOS (uses Unix domain sockets)
4#![cfg(unix)]
5
6pub mod actions;
7
8use anyhow::{Context, Result};
9use bytes::{BufMut, Bytes, BytesMut};
10use std::collections::HashMap;
11use std::path::PathBuf;
12use std::sync::Arc;
13use std::time::Duration;
14use tokio::io::{AsyncReadExt, AsyncWriteExt};
15use tokio::net::UnixStream;
16use tokio::sync::{mpsc, Mutex};
17use tracing::{debug, error, info, trace};
18
19use super::connection::ConnectionId;
20use crate::llm::action_helper::call_llm;
21use crate::llm::ollama_client::OllamaClient;
22use crate::llm::ActionResult;
23use crate::protocol::Event;
24use crate::server::SshAgentProtocol;
25use crate::state::app_state::AppState;
26use actions::*;
27
28/// SSH Agent message types (from SSH Agent Protocol specification)
29const SSH_AGENTC_REQUEST_IDENTITIES: u8 = 11;
30const SSH_AGENTC_SIGN_REQUEST: u8 = 13;
31const SSH_AGENTC_ADD_IDENTITY: u8 = 17;
32const SSH_AGENTC_REMOVE_IDENTITY: u8 = 18;
33const SSH_AGENTC_REMOVE_ALL_IDENTITIES: u8 = 19;
34const SSH_AGENTC_ADD_ID_CONSTRAINED: u8 = 25;
35const SSH_AGENTC_LOCK: u8 = 22;
36const SSH_AGENTC_UNLOCK: u8 = 23;
37
38const SSH_AGENT_FAILURE: u8 = 5;
39const SSH_AGENT_SUCCESS: u8 = 6;
40const SSH_AGENT_IDENTITIES_ANSWER: u8 = 12;
41const SSH_AGENT_SIGN_RESPONSE: u8 = 14;
42
43/// Largest agent message accepted, matching OpenSSH's own limit.
44///
45/// The length prefix is attacker-controlled, so without a cap a client could make the server
46/// buffer arbitrary memory by announcing a huge message and never sending it.
47pub const MAX_AGENT_MESSAGE_LEN: usize = 256 * 1024;
48
49/// Pull one complete length-prefixed agent message off the front of `pending`.
50///
51/// The wire format is `uint32 length || byte type || payload`, and one TCP read is not one
52/// message: a client may pipeline several requests into a single segment, or split one
53/// request across segments. The previous reader treated every `read()` as exactly one message,
54/// so pipelined requests after the first were discarded and split requests failed to parse.
55///
56/// Returns the frame including its 4-byte length prefix (which is what `parse_message` expects),
57/// or `None` when more bytes are needed. `Err` means the stream is unusable and must be closed.
58fn take_framed_message(pending: &mut Vec<u8>) -> Result<Option<Vec<u8>>> {
59    if pending.len() < 4 {
60        return Ok(None);
61    }
62
63    let length = u32::from_be_bytes([pending[0], pending[1], pending[2], pending[3]]) as usize;
64
65    if length == 0 {
66        anyhow::bail!("SSH Agent message declared zero length");
67    }
68    if length > MAX_AGENT_MESSAGE_LEN {
69        anyhow::bail!(
70            "SSH Agent message length {length} exceeds the {MAX_AGENT_MESSAGE_LEN} byte limit"
71        );
72    }
73    if pending.len() < 4 + length {
74        return Ok(None);
75    }
76
77    let frame = pending[..4 + length].to_vec();
78    pending.drain(..4 + length);
79    Ok(Some(frame))
80}
81
82/// How long a connected peer may send nothing at all before the server closes it.
83///
84/// The agent protocol is client-speaks-first — the server has nothing to say until asked — and
85/// a real client (`ssh-add`, `ssh`) writes its first request the moment it connects. The peer
86/// that does not is NetGet's own `ssh_agent` client: it connects inside `connect()`, writes
87/// nothing, and waits for a model action or for a person to use `[ send message ]` —
88/// **connected and silent**. So 300 seconds, the window a `manual` rule gives a human
89/// (`src/state/intercepts.rs`), for `tcp`'s reason. Declared as the `first_byte_timeout_secs`
90/// startup parameter; a stranger is still bounded, and capped at [`MAX_CONNECTIONS`].
91const FIRST_BYTE_READ_TIMEOUT: Duration = Duration::from_secs(300);
92
93/// How long an established session may be silent between requests.
94///
95/// OpenSSH's own agent sets no idle bound on a client connection, so there is no upstream
96/// number to copy; `ssh` holds its agent connection for the authentication phase and closes it,
97/// and a forwarded agent opens a fresh connection per request. Fifteen minutes, `tcp`'s number,
98/// is far longer than any of those. The read is not polled while a request is being answered —
99/// each one is handled inline, sequentially, before the loop reads again — so a model round-trip
100/// or a `manual` rule parked for a human can never run against this clock. Declared as the
101/// `idle_timeout_secs` startup parameter.
102const IDLE_BETWEEN_REQUESTS_TIMEOUT: Duration = Duration::from_secs(900);
103
104/// Concurrent connections this server admits.
105///
106/// [`crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS`]. Each connection may buffer up to
107/// [`MAX_AGENT_MESSAGE_LEN`] of a partial message, so this is the multiplier that turns that
108/// per-connection bound into a total one.
109const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
110
111/// What a peer over [`MAX_CONNECTIONS`] is told before the socket closes: nothing.
112///
113/// The protocol's only negative is `SSH_AGENT_FAILURE`, which is an *answer* — written to a peer
114/// that has asked nothing it would be read as the reply to whatever the client sends first. The
115/// same reasoning keeps `raise_connection_opened` from writing one. A clean EOF, logged by
116/// `accept_bounded_unix` with `decision=fail_closed_connection_cap`, is the honest refusal.
117const CONNECTION_CAP_REFUSAL: &[u8] = b"";
118
119/// Connection state for LLM processing
120#[derive(Debug, Clone, PartialEq)]
121enum ConnectionState {
122    Idle,
123    Processing,
124    Accumulating,
125}
126
127/// Per-connection data for LLM handling
128struct ConnectionData {
129    state: ConnectionState,
130    queued_data: Vec<u8>,
131    memory: String,
132    write_half: Arc<Mutex<tokio::io::WriteHalf<UnixStream>>>,
133}
134
135/// SSH Agent server that listens for incoming connections
136pub struct SshAgentServer;
137
138impl SshAgentServer {
139    /// Spawn the SSH Agent server with integrated LLM actions
140    pub async fn spawn_with_llm_actions(
141        socket_path: PathBuf,
142        llm_client: OllamaClient,
143        app_state: Arc<AppState>,
144        status_tx: mpsc::UnboundedSender<String>,
145        server_id: crate::state::ServerId,
146        first_byte_timeout_secs: Option<u64>,
147        idle_timeout_secs: Option<u64>,
148    ) -> Result<PathBuf> {
149        let first_byte_timeout = first_byte_timeout_secs
150            .map(Duration::from_secs)
151            .unwrap_or(FIRST_BYTE_READ_TIMEOUT);
152        let idle_timeout = idle_timeout_secs
153            .map(Duration::from_secs)
154            .unwrap_or(IDLE_BETWEEN_REQUESTS_TIMEOUT);
155
156        // Remove a stale socket left behind by a previous run, so bind() does not fail with
157        // EADDRINUSE. Only ever unlink an actual socket: `socket_path` comes from a startup
158        // parameter, i.e. ultimately from model output, and blindly removing whatever is at
159        // that path would let a bad value delete a regular file.
160        if let Ok(metadata) = std::fs::symlink_metadata(&socket_path) {
161            use std::os::unix::fs::FileTypeExt;
162            if !metadata.file_type().is_socket() {
163                anyhow::bail!(
164                    "Refusing to start SSH Agent: {:?} already exists and is not a Unix socket. \
165                     Choose a different socket_path.",
166                    socket_path
167                );
168            }
169            std::fs::remove_file(&socket_path).with_context(|| {
170                format!("Failed to remove existing socket file: {:?}", socket_path)
171            })?;
172        }
173
174        // Create and bind Unix domain socket server
175        let listener = tokio::net::UnixListener::bind(&socket_path)
176            .with_context(|| format!("Failed to bind to socket path: {:?}", socket_path))?;
177
178        info!("SSH Agent server listening on {:?}", socket_path);
179        let _ = status_tx.send(format!("SSH Agent server listening on {:?}", socket_path));
180
181        let connections = Arc::new(Mutex::new(HashMap::new()));
182        let protocol = Arc::new(SshAgentProtocol::new());
183
184        let socket_path_clone = socket_path.clone();
185
186        // Spawn accept loop
187        let task_registrar = app_state.clone();
188        let limiter = crate::server::accept_bounded::ConnectionLimiter::new(MAX_CONNECTIONS);
189        let accept_handle = tokio::spawn(async move {
190            loop {
191                match crate::server::accept_bounded::accept_bounded_unix(
192                    &listener,
193                    &limiter,
194                    CONNECTION_CAP_REFUSAL,
195                    "SSH Agent",
196                    Some(&status_tx),
197                )
198                .await
199                {
200                    Ok((stream, permit)) => {
201                        let connection_id =
202                            ConnectionId::new(app_state.get_next_unified_id().await);
203                        info!("Accepted SSH Agent connection {}", connection_id);
204                        let _ = status_tx
205                            .send(format!("✓ SSH Agent connection {} opened", connection_id));
206
207                        // Split stream
208                        let (read_half, write_half) = tokio::io::split(stream);
209                        let write_half_arc = Arc::new(Mutex::new(write_half));
210
211                        // Add connection to ServerInstance
212                        use crate::state::server::{
213                            ConnectionState as ServerConnectionState, ConnectionStatus,
214                            ProtocolConnectionInfo,
215                        };
216                        let now = crate::utils::clock::Instant::now();
217                        let dummy_addr = "127.0.0.1:0".parse().unwrap();
218                        let conn_state = ServerConnectionState {
219                            id: connection_id,
220                            remote_addr: dummy_addr,
221                            local_addr: dummy_addr,
222                            bytes_sent: 0,
223                            bytes_received: 0,
224                            packets_sent: 0,
225                            packets_received: 0,
226                            last_activity: now,
227                            status: ConnectionStatus::Active,
228                            status_changed_at: now,
229                            protocol_info: ProtocolConnectionInfo::new(serde_json::json!({
230                                "state": "Idle",
231                                "socket_path": socket_path_clone.to_string_lossy()
232                            })),
233                        };
234                        app_state
235                            .add_connection_to_server(server_id, conn_state)
236                            .await;
237                        let _ = status_tx.send("__UPDATE_UI__".to_string());
238
239                        // Register the connection HERE, before either task is spawned.
240                        //
241                        // This used to be the first thing the connection-opened task did,
242                        // racing the reader task spawned immediately after it, and
243                        // handle_data_with_actions returns silently when the connection is
244                        // not in the map. For an agent that is worse than a dropped byte:
245                        // the frame has already been taken off the read buffer, the protocol
246                        // is a strict request/response sequence with no retry, so the client
247                        // (`ssh-add -l`) blocks forever on a reply that was never generated.
248                        // 7 of 64 clients that wrote immediately after connect hung this way.
249                        connections.lock().await.insert(
250                            connection_id,
251                            ConnectionData {
252                                state: ConnectionState::Idle,
253                                queued_data: Vec::new(),
254                                memory: String::new(),
255                                write_half: write_half_arc.clone(),
256                            },
257                        );
258
259                        // Raise the connection-opened event.
260                        let llm_client_clone = llm_client.clone();
261                        let app_state_clone = app_state.clone();
262                        let status_tx_clone = status_tx.clone();
263                        let connections_clone = connections.clone();
264                        let protocol_clone = protocol.clone();
265                        // Tracked, not detached: stop_server must abort this task too.
266                        let task_owner = app_state.clone();
267                        task_owner
268                            .spawn_server_task(server_id, async move {
269                                Self::raise_connection_opened(
270                                    connection_id,
271                                    server_id,
272                                    llm_client_clone,
273                                    app_state_clone,
274                                    status_tx_clone,
275                                    connections_clone,
276                                    protocol_clone,
277                                )
278                                .await;
279                            })
280                            .await;
281
282                        // Spawn reader task
283                        let llm_client_clone = llm_client.clone();
284                        let app_state_clone = app_state.clone();
285                        let status_tx_clone = status_tx.clone();
286                        let connections_clone = connections.clone();
287                        let protocol_clone = protocol.clone();
288                        // Tracked, not detached: stop_server must abort this task too.
289                        let task_owner = app_state.clone();
290                        task_owner.spawn_server_task(server_id, async move {
291                            // Held for the life of the connection, so the cap counts live
292                            // connections rather than accepts.
293                            let _permit = permit;
294                            let mut buffer = vec![0u8; 8192];
295                            let mut read_half = read_half;
296                            // Bytes received but not yet forming a complete message.
297                            let mut pending: Vec<u8> = Vec::new();
298                            let mut seen_bytes = false;
299
300                            loop {
301                                let bound = if seen_bytes {
302                                    idle_timeout
303                                } else {
304                                    first_byte_timeout
305                                };
306                                // The deadline wraps this read and nothing else: every request
307                                // is answered inline below, so the clock is not running while
308                                // the model — or a human — composes the reply.
309                                let read = match tokio::time::timeout(
310                                    bound,
311                                    read_half.read(&mut buffer),
312                                )
313                                .await
314                                {
315                                    Ok(read) => read,
316                                    Err(_) => {
317                                        connections_clone.lock().await.remove(&connection_id);
318                                        app_state_clone
319                                            .close_connection_on_server(server_id, connection_id)
320                                            .await;
321                                        let _ = status_tx_clone.send(format!(
322                                            "SSH Agent connection {} sent nothing for {}s; \
323                                             closing idle connection",
324                                            connection_id,
325                                            bound.as_secs()
326                                        ));
327                                        let _ = status_tx_clone.send("__UPDATE_UI__".to_string());
328                                        break;
329                                    }
330                                };
331                                match read {
332                                    Ok(0) => {
333                                        // Connection closed
334                                        connections_clone.lock().await.remove(&connection_id);
335                                        app_state_clone
336                                            .close_connection_on_server(server_id, connection_id)
337                                            .await;
338                                        let _ = status_tx_clone.send(format!(
339                                            "✗ SSH Agent connection {} closed",
340                                            connection_id
341                                        ));
342                                        let _ = status_tx_clone.send("__UPDATE_UI__".to_string());
343                                        break;
344                                    }
345                                    Ok(n) => {
346                                        seen_bytes = true;
347                                        trace!(
348                                            "SSH Agent received {} bytes on connection {}",
349                                            n,
350                                            connection_id
351                                        );
352                                        pending.extend_from_slice(&buffer[..n]);
353
354                                        // Drain every complete message this read produced.
355                                        // Handled sequentially and inline: spawning a task per
356                                        // read let concurrent requests race for the connection
357                                        // lock and be answered out of order, which the agent
358                                        // protocol - a strict request/response sequence on one
359                                        // socket - cannot tolerate.
360                                        let mut fatal = false;
361                                        loop {
362                                            match take_framed_message(&mut pending) {
363                                                Ok(Some(frame)) => {
364                                                    Self::handle_data_with_actions(
365                                                        connection_id,
366                                                        server_id,
367                                                        Bytes::from(frame),
368                                                        llm_client_clone.clone(),
369                                                        app_state_clone.clone(),
370                                                        status_tx_clone.clone(),
371                                                        connections_clone.clone(),
372                                                        protocol_clone.clone(),
373                                                    )
374                                                    .await;
375                                                }
376                                                Ok(None) => break,
377                                                Err(e) => {
378                                                    error!(
379                                                        "Malformed framing on SSH Agent connection {}: {}",
380                                                        connection_id, e
381                                                    );
382                                                    let _ = status_tx_clone.send(format!(
383                                                        "[ERROR] SSH Agent framing error on connection {}: {}",
384                                                        connection_id, e
385                                                    ));
386                                                    fatal = true;
387                                                    break;
388                                                }
389                                            }
390                                        }
391
392                                        if fatal {
393                                            connections_clone.lock().await.remove(&connection_id);
394                                            app_state_clone
395                                                .close_connection_on_server(
396                                                    server_id,
397                                                    connection_id,
398                                                )
399                                                .await;
400                                            let _ =
401                                                status_tx_clone.send("__UPDATE_UI__".to_string());
402                                            break;
403                                        }
404                                    }
405                                    Err(e) => {
406                                        error!(
407                                            "Read error on SSH Agent connection {}: {}",
408                                            connection_id, e
409                                        );
410                                        connections_clone.lock().await.remove(&connection_id);
411                                        break;
412                                    }
413                                }
414                            }
415                        }).await;
416                    }
417                    Err(e) => {
418                        // A listener error (EMFILE, the socket torn down) recurs immediately,
419                        // so continuing would spin a hot loop flooding the status channel.
420                        error!("SSH Agent accept failed, listener stopped: {}", e);
421                        let _ = status_tx.send(format!(
422                            "[ERROR] SSH Agent accept failed, listener stopped: {}",
423                            e
424                        ));
425                        break;
426                    }
427                }
428            }
429        });
430
431        task_registrar
432            .register_server_task(server_id, accept_handle)
433            .await;
434
435        Ok(socket_path)
436    }
437
438    /// Raise `ssh_agent_connection_opened` for a new connection.
439    ///
440    /// The connection is already registered by the accept loop by the time this runs.
441    async fn raise_connection_opened(
442        connection_id: ConnectionId,
443        server_id: crate::state::ServerId,
444        llm_client: OllamaClient,
445        app_state: Arc<AppState>,
446        status_tx: mpsc::UnboundedSender<String>,
447        connections: Arc<Mutex<HashMap<ConnectionId, ConnectionData>>>,
448        protocol: Arc<SshAgentProtocol>,
449    ) {
450        // Call LLM with connection opened event
451        let event = Event::new(
452            &SSH_AGENT_CONNECTION_OPENED_EVENT,
453            serde_json::json!({
454                "connection_id": connection_id.to_string(),
455            }),
456        );
457
458        match call_llm(
459            &llm_client,
460            &app_state,
461            server_id,
462            Some(connection_id),
463            &event,
464            protocol.as_ref(),
465        )
466        .await
467        {
468            Ok(execution_result) => {
469                // Display messages
470                for msg in execution_result.messages {
471                    let _ = status_tx.send(msg);
472                }
473
474                // Execute protocol actions. There is no pending agent request at this
475                // point - the client has connected but asked nothing - so the fail-closed
476                // rule below does not apply here: writing an unsolicited SSH_AGENT_FAILURE
477                // would answer a request that was never made.
478                for action in execution_result.protocol_results {
479                    if let Err(e) = Self::execute_action_result(
480                        action,
481                        connection_id,
482                        server_id,
483                        &connections,
484                        &app_state,
485                        &status_tx,
486                    )
487                    .await
488                    {
489                        error!("Failed to execute action: {}", e);
490                    }
491                }
492            }
493            Err(e) => {
494                error!("LLM error on connection opened: {}", e);
495            }
496        }
497    }
498
499    /// Handle incoming data with LLM actions
500    async fn handle_data_with_actions(
501        connection_id: ConnectionId,
502        server_id: crate::state::ServerId,
503        data: Bytes,
504        llm_client: OllamaClient,
505        app_state: Arc<AppState>,
506        status_tx: mpsc::UnboundedSender<String>,
507        connections: Arc<Mutex<HashMap<ConnectionId, ConnectionData>>>,
508        protocol: Arc<SshAgentProtocol>,
509    ) {
510        // Check connection state
511        let mut conns = connections.lock().await;
512        let conn_data = match conns.get_mut(&connection_id) {
513            Some(cd) => cd,
514            None => return,
515        };
516
517        match conn_data.state {
518            ConnectionState::Idle => {
519                // Process immediately
520                conn_data.state = ConnectionState::Processing;
521                let current_memory = conn_data.memory.clone();
522                drop(conns);
523
524                // Parse and handle message
525                Box::pin(Self::process_message_with_llm(
526                    connection_id,
527                    server_id,
528                    data.to_vec(),
529                    llm_client,
530                    app_state,
531                    status_tx,
532                    connections.clone(),
533                    protocol,
534                    current_memory,
535                ))
536                .await;
537            }
538            ConnectionState::Processing => {
539                // Queue data
540                conn_data.queued_data.extend_from_slice(&data);
541                conn_data.state = ConnectionState::Accumulating;
542                debug!(
543                    "SSH Agent: Queuing {} bytes (total queued: {})",
544                    data.len(),
545                    conn_data.queued_data.len()
546                );
547            }
548            ConnectionState::Accumulating => {
549                // Continue queuing
550                conn_data.queued_data.extend_from_slice(&data);
551                debug!(
552                    "SSH Agent: Queuing {} bytes (total queued: {})",
553                    data.len(),
554                    conn_data.queued_data.len()
555                );
556            }
557        }
558    }
559
560    /// Process SSH Agent message and call LLM
561    async fn process_message_with_llm(
562        connection_id: ConnectionId,
563        server_id: crate::state::ServerId,
564        data: Vec<u8>,
565        llm_client: OllamaClient,
566        app_state: Arc<AppState>,
567        status_tx: mpsc::UnboundedSender<String>,
568        connections: Arc<Mutex<HashMap<ConnectionId, ConnectionData>>>,
569        protocol: Arc<SshAgentProtocol>,
570        _current_memory: String,
571    ) {
572        // Parse SSH Agent message
573        let event = match Self::parse_message(&data) {
574            Ok(Some(event)) => event,
575            Ok(None) => {
576                // Unknown or unsupported message type - send failure
577                Self::send_failure(connection_id, &connections).await;
578                Self::check_queued_data(
579                    connection_id,
580                    server_id,
581                    llm_client,
582                    app_state,
583                    status_tx,
584                    connections,
585                    protocol,
586                )
587                .await;
588                return;
589            }
590            Err(e) => {
591                error!("Failed to parse SSH Agent message: {}", e);
592                Self::send_failure(connection_id, &connections).await;
593                Self::check_queued_data(
594                    connection_id,
595                    server_id,
596                    llm_client,
597                    app_state,
598                    status_tx,
599                    connections,
600                    protocol,
601                )
602                .await;
603                return;
604            }
605        };
606
607        // Call LLM
608        match call_llm(
609            &llm_client,
610            &app_state,
611            server_id,
612            Some(connection_id),
613            &event,
614            protocol.as_ref(),
615        )
616        .await
617        {
618            Ok(execution_result) => {
619                // Send messages to TUI
620                for msg in execution_result.messages {
621                    let _ = status_tx.send(msg);
622                }
623
624                // Execute actions, tracking whether the request ends up answered.
625                let mut answered = false;
626                let mut execution_failed = false;
627                for action in execution_result.protocol_results {
628                    match Self::execute_action_result(
629                        action,
630                        connection_id,
631                        server_id,
632                        &connections,
633                        &app_state,
634                        &status_tx,
635                    )
636                    .await
637                    {
638                        Ok(true) => answered = true,
639                        Ok(false) => {}
640                        Err(e) => {
641                            error!("Failed to execute action: {}", e);
642                            execution_failed = true;
643                        }
644                    }
645                }
646
647                // FAIL CLOSED. An SSH agent request is a question the client blocks on; if
648                // the model answered with nothing usable, saying nothing leaves `ssh` or
649                // `ssh-add` waiting on a read that will never complete, which the user reads
650                // as a hung agent rather than as a refusal. SSH_AGENT_FAILURE is the
651                // protocol's only way to say no, so it is what silence has to become.
652                //
653                // The decision token keeps the three cases apart the way `src/server/radius/`
654                // does. `send_failure` from the model is decision=model_reject and is logged
655                // by nothing here; these two are the server's own decision and must never be
656                // mistaken for it.
657                if !answered {
658                    let decision = if execution_failed {
659                        "fail_closed_action_error"
660                    } else {
661                        "fail_closed_no_action"
662                    };
663                    let message = format!(
664                        "SSH Agent request on connection {}: decision={} (denied because no \
665                         usable answer was produced)",
666                        connection_id, decision
667                    );
668                    error!("{}", message);
669                    let _ = status_tx.send(message);
670                    Self::send_failure(connection_id, &connections).await;
671                }
672            }
673            Err(e) => {
674                // The backend is unreachable. An unanswerable request is refused, never left
675                // to hang, and the error itself stays in the log - the agent wire format has
676                // no field that could carry it to the peer anyway.
677                let category = crate::utils::WireFailure::classify(&e);
678                error!(
679                    "SSH Agent request on connection {}: decision=fail_closed_llm_error \
680                     category={:?}: {}",
681                    connection_id, category, e
682                );
683                let _ = status_tx.send(format!(
684                    "SSH Agent request on connection {}: decision=fail_closed_llm_error \
685                     category={:?}",
686                    connection_id, category
687                ));
688                Self::send_failure(connection_id, &connections).await;
689            }
690        }
691
692        // Check for queued data
693        Self::check_queued_data(
694            connection_id,
695            server_id,
696            llm_client,
697            app_state,
698            status_tx,
699            connections,
700            protocol,
701        )
702        .await;
703    }
704
705    /// Parse SSH Agent protocol message
706    fn parse_message(data: &[u8]) -> Result<Option<Event>> {
707        if data.len() < 5 {
708            return Ok(None);
709        }
710
711        // SSH Agent wire format: [uint32: length][byte: type][data...]
712        // Skip the 4-byte length prefix
713        let _length = u32::from_be_bytes([data[0], data[1], data[2], data[3]]);
714        let msg_type = data[4];
715        let mut cursor = &data[5..];
716
717        debug!(
718            "SSH Agent: parsing message type {} from {} bytes",
719            msg_type,
720            data.len()
721        );
722
723        match msg_type {
724            SSH_AGENTC_REQUEST_IDENTITIES => Ok(Some(Event::new(
725                &SSH_AGENT_REQUEST_IDENTITIES_EVENT,
726                serde_json::json!({}),
727            ))),
728            SSH_AGENTC_SIGN_REQUEST => {
729                // Parse: string public_key_blob, string data, uint32 flags
730                let public_key_blob = Self::read_string(&mut cursor)?;
731                let data_to_sign = Self::read_string(&mut cursor)?;
732                let flags = Self::read_uint32(&mut cursor)?;
733
734                Ok(Some(Event::new(
735                    &SSH_AGENT_SIGN_REQUEST_EVENT,
736                    serde_json::json!({
737                        "key_type": Self::key_type_of(public_key_blob),
738                        "public_key_blob_hex": hex::encode(public_key_blob),
739                        "data_hex": hex::encode(data_to_sign),
740                        "flags": flags,
741                    }),
742                )))
743            }
744            SSH_AGENTC_ADD_IDENTITY | SSH_AGENTC_ADD_ID_CONSTRAINED => {
745                // Parse: string key_type, (key-specific data), string comment
746                let key_type = Self::read_string(&mut cursor)?;
747                let key_type_str = String::from_utf8_lossy(key_type);
748
749                // For simplicity, we'll just extract key type and skip to comment
750                // In a full implementation, we'd parse the key-specific data
751                let public_key_blob = Self::read_string(&mut cursor)?;
752                let _private_key_blob = Self::read_string(&mut cursor)?; // Skip private key
753                let comment = Self::read_string(&mut cursor)?;
754
755                Ok(Some(Event::new(
756                    &SSH_AGENT_ADD_IDENTITY_EVENT,
757                    serde_json::json!({
758                        "key_type": key_type_str,
759                        "public_key_blob_hex": hex::encode(public_key_blob),
760                        "comment": String::from_utf8_lossy(comment),
761                        "constrained": msg_type == SSH_AGENTC_ADD_ID_CONSTRAINED,
762                    }),
763                )))
764            }
765            SSH_AGENTC_REMOVE_IDENTITY => {
766                let public_key_blob = Self::read_string(&mut cursor)?;
767
768                Ok(Some(Event::new(
769                    &SSH_AGENT_REMOVE_IDENTITY_EVENT,
770                    serde_json::json!({
771                        "public_key_blob_hex": hex::encode(public_key_blob),
772                    }),
773                )))
774            }
775            SSH_AGENTC_REMOVE_ALL_IDENTITIES => Ok(Some(Event::new(
776                &SSH_AGENT_REMOVE_ALL_IDENTITIES_EVENT,
777                serde_json::json!({}),
778            ))),
779            // Both carry a single `string passphrase`. It was previously parsed away and the
780            // event delivered empty, so a handler could not tell a correct unlock passphrase
781            // from a wrong one - the whole point of the operation.
782            SSH_AGENTC_LOCK => {
783                let passphrase = Self::read_string(&mut cursor)?;
784                Ok(Some(Event::new(
785                    &SSH_AGENT_LOCK_EVENT,
786                    serde_json::json!({
787                        "passphrase": String::from_utf8_lossy(passphrase),
788                    }),
789                )))
790            }
791            SSH_AGENTC_UNLOCK => {
792                let passphrase = Self::read_string(&mut cursor)?;
793                Ok(Some(Event::new(
794                    &SSH_AGENT_UNLOCK_EVENT,
795                    serde_json::json!({
796                        "passphrase": String::from_utf8_lossy(passphrase),
797                    }),
798                )))
799            }
800            _ => {
801                debug!("Unknown SSH Agent message type: {}", msg_type);
802                Ok(None)
803            }
804        }
805    }
806
807    /// Extract the algorithm name from an SSH public key blob.
808    ///
809    /// Every blob starts with its algorithm as a length-prefixed string ("ssh-ed25519",
810    /// "ssh-rsa", ...). Surfacing it saves a handler from having to decode hex just to work
811    /// out which key a sign request refers to. Returns "" for a blob it cannot read.
812    fn key_type_of(blob: &[u8]) -> String {
813        let mut cursor = blob;
814        match Self::read_string(&mut cursor) {
815            Ok(name) => String::from_utf8_lossy(name).to_string(),
816            Err(_) => String::new(),
817        }
818    }
819
820    /// Read SSH wire format string (uint32 length + bytes)
821    fn read_string<'a>(cursor: &mut &'a [u8]) -> Result<&'a [u8]> {
822        if cursor.len() < 4 {
823            anyhow::bail!("Not enough data to read string length");
824        }
825        let len = u32::from_be_bytes([cursor[0], cursor[1], cursor[2], cursor[3]]) as usize;
826        *cursor = &cursor[4..];
827
828        if cursor.len() < len {
829            anyhow::bail!("Not enough data to read string of length {}", len);
830        }
831        let result = &cursor[..len];
832        *cursor = &cursor[len..];
833        Ok(result)
834    }
835
836    /// Read SSH wire format uint32
837    fn read_uint32(cursor: &mut &[u8]) -> Result<u32> {
838        if cursor.len() < 4 {
839            anyhow::bail!("Not enough data to read uint32");
840        }
841        let result = u32::from_be_bytes([cursor[0], cursor[1], cursor[2], cursor[3]]);
842        *cursor = &cursor[4..];
843        Ok(result)
844    }
845
846    /// Execute one action the model produced.
847    ///
848    /// Returns `true` when the pending agent request has been *answered* - a reply written to
849    /// the socket, or the connection torn down. The caller uses that to enforce the
850    /// fail-closed rule: an agent request that draws no answer at all leaves `ssh`/`ssh-add`
851    /// blocked on a read that never completes, which is indistinguishable from a hung agent.
852    async fn execute_action_result(
853        action: ActionResult,
854        connection_id: ConnectionId,
855        server_id: crate::state::ServerId,
856        connections: &Arc<Mutex<HashMap<ConnectionId, ConnectionData>>>,
857        app_state: &Arc<AppState>,
858        status_tx: &mpsc::UnboundedSender<String>,
859    ) -> Result<bool> {
860        match action {
861            ActionResult::Custom { name, data } => match name.as_str() {
862                "send_identities_list" => {
863                    let identities = data["identities"]
864                        .as_array()
865                        .context("Missing 'identities' field")?;
866                    Self::send_identities_list(connection_id, identities, connections).await;
867                    Ok(true)
868                }
869                "send_sign_response" => {
870                    let signature_hex = data["signature_hex"]
871                        .as_str()
872                        .context("Missing 'signature_hex' field")?;
873                    Self::send_sign_response(connection_id, signature_hex, connections).await;
874                    Ok(true)
875                }
876                "send_success" => {
877                    Self::send_success(connection_id, connections).await;
878                    Ok(true)
879                }
880                "send_failure" => {
881                    Self::send_failure(connection_id, connections).await;
882                    Ok(true)
883                }
884                other => {
885                    debug!("Unknown custom action: {}", other);
886                    Ok(false)
887                }
888            },
889            ActionResult::CloseConnection => {
890                connections.lock().await.remove(&connection_id);
891                app_state
892                    .close_connection_on_server(server_id, connection_id)
893                    .await;
894                let _ = status_tx.send(format!("✗ SSH Agent connection {} closed", connection_id));
895                let _ = status_tx.send("__UPDATE_UI__".to_string());
896                Ok(true)
897            }
898            // WaitForMore is a deliberate "say nothing yet": the model wants the rest of a
899            // pipelined request before answering. It is the one non-answer that must NOT be
900            // turned into a failure.
901            ActionResult::WaitForMore => Ok(true),
902            _ => Ok(false),
903        }
904    }
905
906    /// Send SSH_AGENT_IDENTITIES_ANSWER
907    async fn send_identities_list(
908        connection_id: ConnectionId,
909        identities: &Vec<serde_json::Value>,
910        connections: &Arc<Mutex<HashMap<ConnectionId, ConnectionData>>>,
911    ) {
912        // Decode every key blob before committing to a reply. Previously an unparseable hex
913        // string became an empty blob via unwrap_or_default(), so the client silently received
914        // an identity with a zero-length key instead of an error it could act on.
915        let mut decoded: Vec<(Vec<u8>, &str)> = Vec::with_capacity(identities.len());
916        for identity in identities {
917            let key_blob_hex = identity["public_key_blob_hex"].as_str().unwrap_or("");
918            let comment = identity["comment"].as_str().unwrap_or("");
919
920            match hex::decode(key_blob_hex) {
921                Ok(blob) if !blob.is_empty() => decoded.push((blob, comment)),
922                Ok(_) => {
923                    error!(
924                        "SSH Agent: identity {:?} has an empty public_key_blob_hex; failing the request",
925                        comment
926                    );
927                    Self::send_failure(connection_id, connections).await;
928                    return;
929                }
930                Err(e) => {
931                    error!(
932                        "SSH Agent: identity {:?} has invalid hex in public_key_blob_hex: {}",
933                        comment, e
934                    );
935                    Self::send_failure(connection_id, connections).await;
936                    return;
937                }
938            }
939        }
940
941        let mut response = BytesMut::new();
942        response.put_u8(SSH_AGENT_IDENTITIES_ANSWER);
943        response.put_u32(decoded.len() as u32);
944
945        for (key_blob, comment) in &decoded {
946            // Write key blob (string)
947            response.put_u32(key_blob.len() as u32);
948            response.put_slice(key_blob);
949
950            // Write comment (string)
951            response.put_u32(comment.len() as u32);
952            response.put_slice(comment.as_bytes());
953        }
954
955        Self::send_response(connection_id, response.freeze(), connections).await;
956    }
957
958    /// Send SSH_AGENT_SIGN_RESPONSE
959    async fn send_sign_response(
960        connection_id: ConnectionId,
961        signature_hex: &str,
962        connections: &Arc<Mutex<HashMap<ConnectionId, ConnectionData>>>,
963    ) {
964        // A malformed signature used to be sent as a zero-length blob, which the client reads
965        // as a valid-but-empty signature and then fails to verify with no useful diagnostic.
966        let signature_blob = match hex::decode(signature_hex) {
967            Ok(blob) if !blob.is_empty() => blob,
968            Ok(_) => {
969                error!("SSH Agent: signature_hex decoded to zero bytes; failing the sign request");
970                Self::send_failure(connection_id, connections).await;
971                return;
972            }
973            Err(e) => {
974                error!("SSH Agent: invalid hex in signature_hex: {}", e);
975                Self::send_failure(connection_id, connections).await;
976                return;
977            }
978        };
979
980        let mut response = BytesMut::new();
981        response.put_u8(SSH_AGENT_SIGN_RESPONSE);
982        response.put_u32(signature_blob.len() as u32);
983        response.put_slice(&signature_blob);
984
985        Self::send_response(connection_id, response.freeze(), connections).await;
986    }
987
988    /// Send SSH_AGENT_SUCCESS
989    async fn send_success(
990        connection_id: ConnectionId,
991        connections: &Arc<Mutex<HashMap<ConnectionId, ConnectionData>>>,
992    ) {
993        let mut response = BytesMut::new();
994        response.put_u8(SSH_AGENT_SUCCESS);
995        Self::send_response(connection_id, response.freeze(), connections).await;
996    }
997
998    /// Send SSH_AGENT_FAILURE
999    async fn send_failure(
1000        connection_id: ConnectionId,
1001        connections: &Arc<Mutex<HashMap<ConnectionId, ConnectionData>>>,
1002    ) {
1003        let mut response = BytesMut::new();
1004        response.put_u8(SSH_AGENT_FAILURE);
1005        Self::send_response(connection_id, response.freeze(), connections).await;
1006    }
1007
1008    /// Send response with SSH wire format (length prefix + data)
1009    async fn send_response(
1010        connection_id: ConnectionId,
1011        data: Bytes,
1012        connections: &Arc<Mutex<HashMap<ConnectionId, ConnectionData>>>,
1013    ) {
1014        let conns = connections.lock().await;
1015        if let Some(conn_data) = conns.get(&connection_id) {
1016            // Prepend length
1017            let mut message = BytesMut::new();
1018            message.put_u32(data.len() as u32);
1019            message.put_slice(&data);
1020
1021            // Wait for the write half rather than try_lock: a failed try_lock silently
1022            // dropped the response, leaving the client blocked forever on a reply that was
1023            // never sent.
1024            let mut write_half = conn_data.write_half.lock().await;
1025            if let Err(e) = write_half.write_all(&message).await {
1026                error!("Failed to write SSH Agent response: {}", e);
1027            }
1028        } else {
1029            debug!(
1030                "SSH Agent: dropping response for closed connection {}",
1031                connection_id
1032            );
1033        }
1034    }
1035
1036    /// Check for queued data and process if present
1037    async fn check_queued_data(
1038        connection_id: ConnectionId,
1039        server_id: crate::state::ServerId,
1040        llm_client: OllamaClient,
1041        app_state: Arc<AppState>,
1042        status_tx: mpsc::UnboundedSender<String>,
1043        connections: Arc<Mutex<HashMap<ConnectionId, ConnectionData>>>,
1044        protocol: Arc<SshAgentProtocol>,
1045    ) {
1046        let mut conns = connections.lock().await;
1047        if let Some(conn_data) = conns.get_mut(&connection_id) {
1048            if conn_data.state == ConnectionState::Accumulating {
1049                let queued = std::mem::take(&mut conn_data.queued_data);
1050                conn_data.state = ConnectionState::Processing;
1051                let current_memory = conn_data.memory.clone();
1052                drop(conns);
1053
1054                Box::pin(Self::process_message_with_llm(
1055                    connection_id,
1056                    server_id,
1057                    queued,
1058                    llm_client,
1059                    app_state,
1060                    status_tx,
1061                    connections,
1062                    protocol,
1063                    current_memory,
1064                ))
1065                .await;
1066            } else {
1067                conn_data.state = ConnectionState::Idle;
1068            }
1069        }
1070    }
1071}