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}